Building JobCorporate: Trust by Design in a Moroccan Hiring Marketplace
A technical case study of JobCorporate: how a Morocco-focused hiring marketplace uses explicit product and system constraints to make discovery, applications, AI assistance, and operations more trustworthy.
Hiring software is often described as a matching problem. Put vacancies on one side, candidates on the other, add filters and messages, then let the marketplace do its work.
That description misses the part that matters most.
For a candidate, a hiring platform is a promise that the opportunity is real, that the company behind it can be understood, that an application will go somewhere, and that the next step will not disappear into silence. For a recruiter, it is a promise that publishing a role will not create noise without signal, that applicant information is protected, and that the workflow can be trusted when a decision has to be made. For the people operating the platform, it is a promise that public information can be governed and that consequential actions can be explained later.
Those promises are not made by a landing page. They are made by the system underneath it.
JobCorporate is a Morocco-focused hiring marketplace built around that premise. It connects candidates and recruiters through public job discovery, company profiles, applications, saved opportunities, shortlists, conversations, and notifications. It also widens the career journey with internship discovery, school and scholarship information, editorial content, and an X-Ray research workspace for building reproducible external searches. Administrators have the controls needed to maintain quality across those public and private surfaces.
The product’s central technical challenge is not scale for its own sake. It is preserving clarity as more workflows are added. A job, an internship, a candidate document, a school program, an article, and an audit record have very different lifecycles. A recruiter, a candidate, a guest applicant, and an administrator must not be treated as interchangeable versions of the same user. An AI response needs to be useful without becoming an unaccountable decision-maker.
The architecture is therefore built around a simple principle:
Trust is the result of constraints made explicit.
This article examines how that principle appears in JobCorporate’s current codebase: its modular-monolith structure, typed application boundary, dual-layer authorization, marketplace data model, resilient public search, application flows, structured AI review, file controls, operational governance, and testing posture. It also separates what exists now from what should change only when the evidence says it is time.
I. The Product Boundary: A Marketplace, Not a Dashboard
It is tempting to call JobCorporate an HR dashboard. That is incomplete. A dashboard is an authenticated workspace arranged around a single operator. A marketplace has to maintain confidence before a person creates an account, through a transaction between people who do not know one another, and after the transaction is complete.
The distinction changes the shape of the product.
Three roles, one public surface
JobCorporate serves three primary roles:
- Candidates discover opportunities, develop a credible profile, manage account-backed applications, save roles, follow recruiters, converse with employers, upload CVs, and request an AI-assisted CV review.
- Recruiters create and maintain a company presence, publish and manage jobs, review applications, create candidate shortlists, update application status, and communicate with relevant candidates.
- Administrators oversee the marketplace: users, administrator-owned companies and jobs, lookups, schools, editorial content, site pages, analytics, and audit history.
At the same time, a meaningful part of the platform is public. Visitors can explore jobs, internships, companies, schools, programs, scholarships, articles, and published site pages without being forced into an account funnel. A public job can support an account-backed workflow, a guest application workflow, or an email-delivery workflow depending on how the job is configured.
That is a material product choice. The platform is not optimized merely to acquire authenticated users; it is optimized to make qualified progress possible. Requiring an account for every first action can create false friction. Removing every identity boundary can weaken privacy and workflow continuity. JobCorporate retains both options because the application itself determines which one is appropriate.
Career progress is broader than a vacancy
The public catalogue is deliberately not limited to standard job posts. A student deciding which program to apply to, a graduate comparing an internship, and a candidate assessing a company are all making related career decisions. The product models these as distinct domains rather than squeezing them into a generic Listing table.
Public career journey
Find a role ────────────────> Apply or save it
│ │
│ ├──> Track an account application
│ └──> Receive email confirmation as a guest
│
├──> Understand the company
├──> Explore an internship
├──> Research schools and scholarships
└──> Read editorial guidance
This separation makes the public interface clearer and gives each resource its own metadata, publishing rules, URLs, and filters. An internship, for example, needs to communicate duration, start date, compensation status, allowance, and convention requirements. A school needs programs, admission campaigns, and scholarship relationships. Treating these records as if they were just jobs with extra optional fields would make both the schema and the user experience harder to reason about.
The product boundary is therefore intentionally expansive—but not vague. Every area exists because it helps one side of the marketplace make a more informed decision.
II. The Foundational Architecture Choice: A Modular Monolith
JobCorporate is a Next.js application using React, TypeScript, tRPC, Prisma, PostgreSQL, Zod, and Better Auth. The architecture is a modular monolith: one deployable product application with a primary relational database, organized into cohesive feature modules instead of prematurely separated network services.
For a team building a single product across a connected domain, this is not a compromise. It is the design that preserves the most leverage.
Consider a recruiter changing an application’s status. The action may require an ownership check, a write to the Application record, an audit event, an in-product notification, and an email notification whose copy changes for account-backed versus guest applicants. Those are not independent distributed business events accidentally occurring at the same time. They are one product action with several consequences.
Keeping that action within a single application yields useful properties:
| Concern | In a modular monolith | In a prematurely distributed design | | --- | --- | --- | | Data consistency | Related writes can share a database transaction when needed | Requires coordination, retries, and compensating actions across services | | Type contracts | tRPC lets server types flow to the TypeScript client | Requires separate API contracts, generated clients, or duplicated models | | Debugging | One trace can be followed through a request and its domain code | Logs and failure context must cross service boundaries | | Deployment | One application and configuration surface | Multiple pipelines, versions, secrets, and health dependencies | | Future extraction | A cohesive module provides a natural seam later | A guessed boundary becomes a costly permanent interface |
The argument is not that services are bad. Services are excellent when they represent a durable ownership boundary, a genuinely independent scaling profile, or an integration that must evolve separately. The argument is that those conditions should be demonstrated, not assumed. A product with a bounded domain and a small engineering team gains little by paying the coordination cost before it has a coordination problem.
The real risk of a monolith
The danger is not the word monolith. The danger is unbounded coupling: a codebase where a change to one workflow requires a scavenger hunt through unrelated global layers, and where any module can quietly reach into any other module’s data.
JobCorporate counters that risk by organizing around business capability rather than only around technical type:
src/modules/
candidate/ profile, applications, dashboard, CV review, shortlist
recruiter/ company, jobs, applications, dashboard, shortlist
jobs/ public search, detail data, application entry points
stages/ internship search and detail pages
companies/ public company profiles
schools/ schools, programs, admissions, scholarships
messaging/ conversations and messages
files/ uploads, signatures, metadata, access
notifications/ notification state and delivery helpers
articles/ public editorial content
search/ X-Ray query generation, history, saved searches
admin/ marketplace administration
The practical unit of reasoning is the feature. An engineer changing a recruiter application workflow finds the router, schema, view, and supporting utilities in the recruiter domain rather than locating “controllers,” “services,” and “components” across a generic technical hierarchy. This does not prevent shared primitives; it prevents shared primitives from becoming an excuse for feature ownership to disappear.
What the monolith deliberately does not promise
A modular monolith is not an excuse to keep every future workload synchronous or every external dependency in a request handler forever. AI processing, delivery retries, analytics pipelines, and search ranking can all acquire different runtime needs later. The point is that JobCorporate begins with direct, inspectable business code and earns each new layer of infrastructure with a measurable requirement.
That makes extraction safer. A feature is easiest to move when it already has a crisp internal boundary. Creating a service first and searching for a boundary afterward produces the opposite result.
III. The Application Boundary: Types and Validation Are Part of the Product
The browser should not be an untrusted remote control for the database. It should communicate through an application boundary that knows who is calling, what shape of input is valid, what business rule applies, and what failure is safe to reveal.
In JobCorporate, that boundary is tRPC. Procedures are organized in domain routers and collected in one root router. The client receives inferred TypeScript inputs and outputs, while Zod validates inputs at runtime. This dual approach matters because TypeScript disappears at runtime and user input remains user input no matter how carefully a frontend form is built.
A procedure has a posture
The application does not treat every route as “a function with an optional user.” It defines procedures whose permission posture is visible at the declaration site:
export const protectedProcedure = t.procedure
.use(errorCatchingMiddleware)
.use(enforceUserIsAuthed);
export const candidateProcedure = t.procedure
.use(errorCatchingMiddleware)
.use(enforceRole("CANDIDATE"));
export const recruiterProcedure = t.procedure
.use(errorCatchingMiddleware)
.use(enforceRole("RECRUITER"));
export const adminProcedure = t.procedure
.use(errorCatchingMiddleware)
.use(enforceRole("ADMIN"));
This is more than a naming convention. A recruiter-only mutation cannot accidentally become available to any authenticated person because the role check is in its execution pipeline. The authenticated middleware also rejects inactive users, preventing a deactivated account from continuing to use an otherwise valid session.
The application context carries a session, database access, the current user ID, and a request trace ID. Its error formatter logs server failures with the relevant procedure and trace context, maps known application errors into a stable client shape, and avoids exposing stack information outside development. The result is a boundary that is both strict and operable.
Validation belongs on the server
Client-side validation is valuable for immediate feedback. It can tell a candidate that an email is malformed before they submit a form or warn a recruiter that an internship requires an allowance when it is marked as paid. But it is an interface improvement, not the final authority.
The server validates the same inputs because that is where the invariant belongs. A public request can be made without using the form at all. A stale browser tab can submit an old shape. A malicious caller can construct any payload they want. The server must decide whether a value is acceptable.
That is why JobCorporate uses Zod schemas at procedure boundaries for actions such as application submission, saved research, article creation, file metadata registration, status updates, and job configuration. The rule exists once in a reusable, testable form rather than being implied by a particular React component.
IV. Authorization: A Redirect Is Good UX, Not Enough Security
Multi-role products often make a subtle mistake: they mistake navigation for authorization. Hiding a recruiter link from a candidate, or redirecting an anonymous visitor from a dashboard path, improves experience. It does not establish that a direct request to the underlying data is safe.
JobCorporate uses two layers that serve different purposes.
Layer one: route-aware request protection
The Next.js proxy recognizes authentication pages, authenticated shared routes, onboarding, and role-prefixed areas. It first uses the session cookie to avoid unnecessary work where a simple redirect is sufficient. For role-protected routes, it resolves the session and compares the role with a direct map:
const ROLE_PREFIXES: Record<string, Role> = {
"/candidate": "CANDIDATE",
"/recruiter": "RECRUITER",
"/admin": "ADMIN",
};
An unauthenticated request for /candidate is redirected to login with a callback URL. A recruiter who attempts to enter /candidate is redirected to the recruiter home area. A user who still needs onboarding is kept in that flow instead of being dropped into a dashboard whose assumptions do not yet hold.
This layer is intentionally concerned with navigation and experience. It keeps the application coherent before a page renders. It is not trusted as the sole gate.
Layer two: server procedures and record ownership
The tRPC procedures enforce authenticated state, role, and active-account status again at the API boundary. Then a second question is asked inside each business operation: does this specific record belong to, or legitimately relate to, this specific caller?
For example, a candidate can request an AI review only for a document attached to their own candidate profile:
const document = await ctx.prisma.candidateDocument.findFirst({
where: {
id: input.documentId,
candidate: { userId: ctx.session.user.id },
},
select: { id: true, filePath: true },
});
if (!document) {
throw new TRPCError({ code: "NOT_FOUND" });
}
The important feature of this query is not the exception. It is the ownership condition inside the lookup. A user who supplies another document ID gets no document they are allowed to act on. The query shape itself enforces the boundary.
The same principle appears in recruiter application management. The workflow retrieves the application, then verifies that the application’s job belongs to the authenticated recruiter before it allows a status update. Saved searches and favorites include the current user ID in every create, list, update, and delete path. File access checks participant and owner relationships rather than assuming that knowledge of a path is permission.
The anti-pattern and the correct pattern
The difference can look small in code and enormous in consequence:
// Weak: role is correct, but ownership is not established.
const job = await db.job.findUnique({ where: { id: input.jobId } });
if (!job) throw new TRPCError({ code: "NOT_FOUND" });
// Stronger: the record is selected only in the caller's authority scope.
const job = await db.job.findFirst({
where: {
id: input.jobId,
recruiter: { userId: ctx.session.user.id },
},
});
if (!job) throw new TRPCError({ code: "NOT_FOUND" });
The second approach has another benefit: it reduces information disclosure. The caller does not receive confirmation that another recruiter’s record exists, only that there is no record available in their scope.
Authorization is tested as behavior
This architecture is backed by unit and end-to-end coverage for anonymous access, role mismatches, inactive users, onboarding redirects, candidate and recruiter ownership, article administration, document visibility, and audit-log access. Those tests are important because permission logic is easy to make plausible and hard to make complete. The only convincing authorization test is one that attempts the action from the wrong identity.
V. The Marketplace Data Model: Identity, Context, and Invariants
The data model begins with one User identity and optional role-specific profiles. This keeps authentication, sessions, verified email state, account activation, and user-level settings in one place without flattening role-specific data into a wide table of mostly empty columns.
User
├── CandidateProfile
│ ├── education, experience, skills, languages, preferences
│ ├── documents and CV reviews
│ ├── applications and saved jobs
│ └── conversations and recruiter follows
├── RecruiterProfile
│ ├── company, jobs, candidate shortlists
│ └── conversations
└── AdminProfile
└── marketplace administration context
The optional nature of profiles is intentional. An account can exist before a candidate or recruiter has completed the profile data their workflow will eventually require. That supports staged onboarding instead of manufacturing empty domain records before there is meaningful information to place in them.
Relationships preserve the reason data exists
A marketplace becomes difficult to operate when relationships lose their context. JobCorporate’s schema keeps the important links explicit:
Company ──< Job ──< Application >── CandidateProfile
│ │
│ └── CandidateDocument ──< CvReview
│
└──< Conversation >── RecruiterProfile
CandidateProfile ──< CandidateShortlistJob >── Job
RecruiterProfile ──< RecruiterShortlistCandidate >── CandidateProfile
CandidateProfile ──< RecruiterFollow >── RecruiterProfile
An Application can be account-backed or guest-backed. A guest application stores applicant identity and CV metadata without pretending that it has a candidate profile. A conversation keeps references to a candidate, recruiter, and optional job so that a message is not detached from the opportunity that created the relationship. A recruiter shortlist may retain an optional job association, allowing a candidate to be saved generally or for a specific role.
These links are not database ornamentation. They determine what can safely be shown, changed, deleted, or retained.
Let the database protect what application code cannot
Some facts must remain true even if two browser requests arrive at nearly the same moment. That is why JobCorporate enforces them with database constraints:
model CandidateShortlistJob {
@@unique([candidateId, jobId])
}
model RecruiterShortlistCandidate {
@@unique([recruiterId, candidateId, jobId])
}
model RecruiterFollow {
@@unique([candidateId, recruiterId])
}
An application-level “is this already saved?” check is still useful for a friendly message. It is not enough for correctness. Between a check and an insert, another request can complete. A unique constraint gives the invariant one atomic home.
The same philosophy applies to indexes. Public jobs are indexed around active state, company, city, industry, job type, experience level, education level, remote option, expiry, and recency. School and program records are indexed around publication state and their public filters. The schema includes a GIN index on a job’s PostgreSQL tsvector search field, plus vector columns on jobs and candidate profiles for future similarity-oriented capabilities. These indexes are not a claim that an index can replace product relevance; they are an acknowledgement that frequent product questions should not require the database to rediscover its access path from scratch.
VI. Discovery Architecture: Relevance Must Degrade Gracefully
Public discovery is a high-trust surface. A search that returns an inactive role, collapses because a user typed punctuation, or silently mixes internships with permanent jobs tells candidates that the catalogue is not being cared for.
JobCorporate’s public jobs router uses a deliberate, layered search strategy. Structured filters such as city, job type, experience level, education level, industry, and remote-work preference can be expressed safely through Prisma. Textual search uses PostgreSQL full-text capabilities when the query has valid tokens, with a Prisma substring-search fallback when it does not or when the full-text path fails.
Search request
│
├── No text term ───────────────> Prisma filters + pagination
│
├── Invalid text tokens ────────> Prisma text filter + pagination
│ (for example, punctuation-only input)
└── Valid text tokens ──────────> PostgreSQL full-text ranking
│
└── failure ──> Prisma text fallback
The branch is visible in the implementation:
const tsQuery = buildTsQuery(searchTerm);
if (!tsQuery) {
return runPrismaSearch({
ctx,
where: buildPrismaWhere({ ...filterParams, searchTerm }),
page,
limit,
skip,
});
}
try {
return await runFullTextSearch({
ctx,
tsQuery,
searchTerm,
...filterParams,
page,
limit,
skip,
});
} catch {
return runPrismaSearch({
ctx,
where: buildPrismaWhere({ ...filterParams, searchTerm }),
page,
limit,
skip,
});
}
This is a practical trade-off. Full-text search provides relevance ranking that a normal ORM filter cannot express cleanly. Prisma provides a typed, composable fallback that is easier to maintain for structured search. The user should still get an understandable result when the best path is unavailable; that is more valuable than preserving an elegant internal abstraction while the public page errors.
Jobs and internships are intentionally separate discovery paths
The public job search explicitly returns no results when the requested type is STAGE. Internships have their own public stage router and pages. That decision prevents an apparently simple filter from obscuring a different product contract. Stage records have their own discovery filters and details such as compensation, allowance, convention requirements, duration, and start date; they also receive their own canonical URLs and structured SEO data.
The goal is not to make the domain look more complicated. It is to avoid making the candidate infer important facts from a generic job card.
Visible structured data is an interface for trust
Search quality includes what the user can verify. Job details include role information, employer context, requirements, benefits, location fields, and application type. Company pages make the organisation behind a role more legible. Public schools use publication status and canonical URLs to ensure that only intended records appear. Sitemap, robots, metadata, breadcrumbs, and structured data are treated as part of the public product rather than as a search-engine afterthought.
The broader lesson is that discovery should be optimized for qualified decisions, not merely for impressions. An opaque ranking score can look intelligent while giving a candidate less information. Clear attributes, fresh records, a reliable fallback path, and scoped public data often do more to earn trust.
VII. Applications Are a Reliability-Critical Workflow
An application looks like a form submission until it fails. Then its true nature becomes clear: it handles personal data, temporary files, duplicate prevention, business routing, email delivery, notifications, and the candidate’s confidence that an action was completed.
JobCorporate supports three application modes because the marketplace needs to meet opportunities where they are:
- Account-backed application for a signed-in candidate using the platform workflow and a candidate profile.
- Guest application for an unauthenticated candidate applying to a recruiter-managed job with a CV upload.
- Email-routed application for a configured job where JobCorporate packages the application and CV for delivery to an application email address.
The public job detail response exposes only the safe application mode, not the underlying email address itself. The server derives IN_APP or EMAIL from the job configuration after retrieving an active, unexpired public job. This prevents the public view from treating routing data as display data.
Guest application flow
The guest path shows why a product workflow should be described as a sequence, not as a single mutation:
Guest enters application and uploads PDF
│
▼
Validate user-scoped temporary upload path, MIME type, and size
│
▼
Load active, unexpired recruiter-managed job
│
▼
Reject duplicate guest submission for the same job and email
│
▼
Create Application record and retain the uploaded CV
│
├──> Create recruiter in-product notification
├──> Send recruiter email notification asynchronously
└──> Send candidate confirmation asynchronously
Failure before persistence
│
▼
Remove temporary uploaded CV
The implementation makes this cleanup intent explicit with a keepUploadedCv flag. The upload is retained only after the application record has been created. If an invalid job, duplicate application, or unexpected error stops the flow earlier, the temporary document is removed in finally. The unit suite specifically exercises duplicate guest applications and checks that their temporary uploads are cleaned up.
The workflow also validates the PDF against the document bucket’s server-side constraints. A document must be application/pdf and no larger than 2 MB. Client-side checks improve interaction; server-side checks keep the policy true.
Email-routed applications have different failure semantics
For jobs configured with an application email, the system validates the active job and collects a candidate’s existing account CV or a temporary public upload. It downloads the CV from storage and sends a branded email to the configured address. Account holders receive an in-product confirmation; both account and guest applicants receive confirmation email copy appropriate to their audience.
The distinction matters because delivery failure is not equivalent to a failed database write. If recruiter-email delivery fails, the system logs the error and attempts to notify administrators about the delivery problem. Temporary public uploads are removed regardless of outcome because they existed only to enable that transmission.
This is a useful example of failure-aware design: the product does not claim delivery succeeded if the transport failed, and it does not leave a private CV in temporary storage merely because an error branch was taken.
Status changes preserve a meaningful audit trail
When a recruiter updates an application, the server verifies that the application’s job belongs to that recruiter. It returns early if the status is already the requested value, avoiding unnecessary work and duplicate side effects. Otherwise it updates the status and timestamp, records an audit event with previous and next values, then branches by submission type:
- Account-backed candidates receive an in-product notification and an email attempt.
- Guest applicants receive an email update but no notification record tied to an account they do not have.
This is not an over-engineered detail. It is the difference between treating all applicants as rows and treating the chosen application channel as part of the record’s meaning.
VIII. AI CV Review: Structured Assistance, Not an Automated Verdict
AI features are easy to make impressive in a demo and difficult to make trustworthy in a product. The usual failure mode is a vague prompt that returns attractive prose with no dependable structure, unclear limits, and no safe way to persist or render the response.
JobCorporate takes a more constrained approach to CV review. A candidate selects one of their own uploaded documents, the server verifies ownership, downloads the PDF, sends it to Gemini with a structured-output schema, and stores the resulting review as an explicit candidate artifact.
Candidate-owned CV document
│
▼
Candidate procedure verifies document ownership
│
▼
Private PDF download from storage
│
▼
Gemini 2.5 Flash + French system instructions
│
▼
Zod-backed structured output validation
│
▼
Persist score columns and rich JSON sections
│
▼
Candidate review history and detailed report
The model is asked to respond exclusively in French and to evaluate a clearly defined set of areas: quantified impact, ATS optimisation, structure and format, content relevance, language and tone, and online presence. The prompt sets weighted scoring guidance, controlled seniority values, bounds for strengths and recommendations, and an expectation that advice be specific and actionable.
The important reliability mechanism is the schema:
export const CvReviewResponseSchema = z.object({
overallScore: z.number().min(0).max(100),
level: z.enum(["Insuffisant", "Passable", "Bien", "Tres_bien", "Excellent"]),
sections: z.object({
impactMetrics: CvSectionReviewSchema,
atsOptimization: CvSectionReviewSchema,
structureFormat: CvSectionReviewSchema,
contentRelevance: CvSectionReviewSchema,
languageTone: CvSectionReviewSchema,
onlinePresence: CvSectionReviewSchema,
}),
strengths: z.array(z.string()).min(1).max(5),
recommendations: z.array(z.string()).min(1).max(6),
ats: z.object({
detectedKeywords: z.array(z.string()),
missingKeywords: z.array(z.string()),
atsCompatible: z.boolean(),
}),
});
Without this layer, a model response is merely text that the rest of the product must interpret optimistically. With it, the response has a contract. If required fields, types, or bounds are not met, the application does not silently create a malformed review. It handles the AI error instead of pretending an incomplete object is useful data.
Queryable facts, flexible analysis
The persistence design balances future reporting with schema flexibility. High-value top-level facts—overall score, quality level, executive summary, target role, estimated level, and ATS compatibility—are stored in typed columns. Rich, evolving detail—section reviews, strengths, recommendations, red flags, and ATS keyword analysis—is stored as JSON.
This is the right split for the feature’s current shape. A candidate-facing report needs rich nested content, and a future product question may need to filter by score or review level without parsing JSON for every record. The system does not force every likely future nuance into columns before the product has proved that nuance needs independent querying.
The boundary of the feature matters
The CV review is advisory. It helps a candidate strengthen their presentation; it does not make a hiring decision, rank people for a recruiter, or claim to know a person’s capability from a document. The architecture supports that ethic. The model produces a transparent, structured critique with actionable recommendations. The user remains the decision-maker.
That distinction should remain intact as the platform adds AI capabilities. The safest AI feature is rarely the one that appears most autonomous. It is the one that makes the next human action clearer, more informed, and easier to challenge.
IX. Files, Messaging, and Notifications: Context Must Survive the Interaction
The most sensitive parts of a hiring marketplace are often not the obvious records. They are the files people upload, the messages they send, and the notifications that tell them whether something happened. These surfaces need ownership, access rules, and lifecycle thinking.
File storage is a domain policy
JobCorporate uses Cloudinary for storage and delivery, while its database records metadata and domain relationships. Buckets are explicit rather than generic:
| Bucket | Visibility | Allowed types | Maximum size | Intended use |
| --- | --- | --- | --- | --- |
| avatars | Public | JPEG, PNG, WebP | 2 MB | Profile images |
| company-logos | Public | JPEG, PNG, WebP | 2 MB | Employer branding |
| school-logos | Public | JPEG, PNG, WebP | 2 MB | School records |
| article-images | Public | JPEG, PNG, WebP | 4 MB | Editorial images |
| documents | Private | PDF | 2 MB | CVs and candidate documents |
Public assets use CDN URLs with automatic format and quality parameters. Documents are raw private assets accessed through signed URLs. That is a meaningful distinction: a CV should not become public simply because it was uploaded successfully.
The file router also checks the Cloudinary public ID structurally. It splits the path, finds the user-uploads segment, and verifies that the immediately following segment exactly matches the current user ID. This avoids the weakness of a simple substring check, where a malicious path could include a user ID in the wrong place.
const segments = publicId.split("/");
const uploadSegmentIdx = segments.indexOf("user-uploads");
if (uploadSegmentIdx === -1 || segments[uploadSegmentIdx + 1] !== userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
Metadata updates use transactions for the database relationships, then clean up obsolete Cloudinary resources after a successful commit. This ordering is intentional. If a database transaction rolls back, deleting the old storage object first would leave a valid database reference pointing at a missing file. A stale object after a successful transaction is not ideal, but it is safer and recoverable than a broken profile image or logo reference.
Conversations are restricted by participant membership
Messaging is not general social networking. A conversation belongs in a hiring context, and the data model retains the candidate, recruiter, and optional job connection that defines it. Every read or write path verifies that the caller is one of the participants before it exposes messages or accepts a send.
Message history uses cursor pagination. The router requests one extra record, derives the next cursor from it, and returns a stable page. This is better suited to a conversation than offset pagination because new messages can arrive while a user is reading older history. An offset can shift; a cursor remains attached to a known message.
Conversation list: ordered by lastMessageAt
│
▼
Participant membership check
│
▼
Messages ordered by creation time
│
▼
limit + 1 query ──> response items + next cursor
The platform complements the inbox with email notifications for new messages. It does not misrepresent this as real-time transport infrastructure. A reliable participant-scoped inbox, an unread state, and a delivery attempt are useful, current guarantees; a websocket architecture would be a separate decision with separate operational needs.
Notifications represent durable product state
In-product notifications store a recipient, typed event category, title, optional body, JSON data, read state, and timestamp. They are used for actions such as a recruiter receiving a new guest application or a candidate receiving an application-status update. The JSON payload retains contextual IDs such as the application and job, making a notification actionable rather than merely informational.
Email complements—not replaces—that persistent state for account holders. Guest applicants receive communications appropriate to the channel they chose, without the system creating fictional dashboard state for a user who does not exist in the database.
X. Governance Is a Product Capability
Trust has an operational half. A marketplace can have carefully designed candidate and recruiter flows while still becoming unreliable if public content, companies, schools, and administrative decisions cannot be governed.
JobCorporate brings these responsibilities into the product with deliberately narrow administrator procedures.
Structured publishing protects public content
Articles use categories, drafts, publication state, publication timestamps, author relationships, featured images, SEO descriptions, and stable slugs. The body is stored as structured Tiptap JSON rather than arbitrary HTML. That gives the application a controlled set of supported content blocks, makes content validation possible, and helps prevent public rendering from becoming an unbounded input surface.
An administrator must supply a meta description before an article can be published. When a title changes, the platform creates a redirect from the old slug within a transaction. The public article resolver honours a historical slug only when the current article is still published. These are details that are easy to postpone and expensive to repair after pages have been shared or indexed.
The same publication-state discipline appears in public site pages and school data. A program cannot be published ahead of its school. An admission campaign has its own validation rules; a concours route requires complete competition details rather than a vague date. Public school queries filter to published records, because a draft should not become discoverable simply because a URL was guessed.
Administrator ownership is explicit
The platform distinguishes recruiter-owned company data from administrator-owned companies and jobs. This prevents a generic administrative editor from casually becoming a back door into recruiter-managed records. Tests cover the boundary: an administrator can create a platform job for a selected administrator-owned company, but cannot use that path to edit a recruiter-managed job.
This is a valuable reminder that “admin” should not mean “ignore the data model.” Administrative capability can be broad while still respecting the ownership contracts that make the marketplace intelligible.
Audit logs preserve the question behind an action
The audit log records an actor link and an actor snapshot, role, action type, target type, target ID, human-readable target label, optional details, and timestamp. Application status changes, publishing lifecycle events, company and school actions, and article changes can therefore be reviewed as operations rather than reconstructed from scattered record timestamps.
An administrator or recruiter acts
│
▼
Business record changes
│
▼
Audit event records:
actor + role + action + target + before/after context + time
The snapshot matters. A user account can change name, email, role, or active state later. The audit event still needs to explain who performed the action as they were known at the time.
Audit logs do not replace authorization; they answer a different question. Authorization asks whether a person is allowed to act. Auditability asks what happened after they did.
XI. Production Readiness Means Being Able to Explain Failure
Production readiness is sometimes reduced to a deployment checklist. In reality, it is the system’s ability to remain understandable when an ordinary dependency fails, a request is malformed, an account is deactivated, or a candidate reports that something did not work.
JobCorporate includes several concrete foundations for this work.
Traceable, mapped errors
Each tRPC context generates a trace ID. The tRPC error formatter logs failures with that trace ID, the procedure path, and user ID when present. Known application errors carry a defined code, status, message, and optional field errors. Unknown failures are mapped through Prisma error handling into a generic safe response rather than exposing infrastructure details.
The same approach appears in public job search, application delivery, CV review, and message notification paths: the failure is logged with a feature-specific procedure label and the user receives a controlled application error. This makes a support report actionable. A user can provide a trace reference without needing to understand the database or an external email provider.
Health is intentionally boring
The health route performs a simple SELECT 1 against PostgreSQL and returns ok with no-store caching when the database probe succeeds. If it fails, the route logs the mapped error and returns an unavailable response without leaking database internals. Health checks should be boring because they will be read during the least interesting moment possible: an incident.
Tests focus on the paths where trust breaks
The repository includes unit and end-to-end coverage for the product’s critical boundaries, including:
- Authentication, onboarding redirects, role mismatches, and inactive accounts.
- Candidate and recruiter application ownership, duplicate applications, and status transitions.
- Public job search filtering, full-text fallback, inactive-job protection, and guest applications.
- Document-path forgery rejection, MIME validation, signed document access, and recruiter access to applicant documents.
- Article publication rules, slug stability, site-page management, school publication constraints, and audit-log access.
- Search query-builder escaping, source restrictions, deterministic snapshots, provider URLs, and query-length limits.
Tests do not prove the marketplace is complete. They do establish that its riskiest current claims are checked as behavior, not only as code that appears reasonable in a review.
Reliability is a product metric
This is the operational mindset behind the system: a timeout is not merely an exception; it is a candidate who may not know whether their application was sent. A stale public job is not merely a query result; it is time spent on an unavailable opportunity. A missing ownership check is not merely a bug; it is a breach of the marketplace’s central promise.
The best observability is therefore connected to user impact. Trace IDs, structured error labels, a database health probe, and focused tests are the current baseline. They make it possible to learn where the next operational investment belongs instead of buying telemetry because it looks sophisticated.
XII. What Exists Now, and What Should Come Next
The architecture described above is current: role-aware procedures, ownership-scoped data access, PostgreSQL full-text search with fallback, separate jobs and internship discovery, public and account application flows, structured CV review, private documents, persistent notifications, publication controls, audit records, health checks, and a substantial test suite.
The following are not claims about the current system. They are future-scale investments that should be made when operational evidence demands them.
Durable background jobs
Some work is acceptable in a request path at modest volume and inappropriate at higher volume. AI review generation, retries for transactional emails, media processing, and broad notification fan-out all benefit from durable job semantics: enqueue, retry policy, idempotency key, visibility into failure, and controlled backoff.
The trigger to introduce a queue should be observed latency, provider rate limits, retry needs, or requests whose outcome cannot reasonably be held open—not a desire to make the architecture diagram more elaborate.
Richer observability
Trace IDs and structured server errors are a strong base, but a growing marketplace will benefit from dashboards and alerts that answer questions such as:
- What percentage of application attempts succeeds by submission type?
- Are full-text search fallbacks occurring unusually often?
- How long do CV reviews take, and how often do AI quotas or schemas fail?
- Which public pages have the slowest database work?
- Does a particular delivery provider have elevated failures?
The principle is to measure user-facing outcomes and build alerts around degradation, not simply collect an impressive volume of logs.
AI quality evaluation before automated relevance
The schema already contains vector fields for jobs and candidate profiles, and the platform has AI infrastructure for CV review. That does not make a semantic matching score automatically trustworthy. Before candidate-to-job similarity becomes a prominent product signal, JobCorporate should define relevance criteria, gather representative evaluation cases, measure disagreements, inspect bias risks, and give users a clear explanation of what any score represents.
An opaque score that looks precise can reduce trust. A measured, explainable recommendation can increase it. The difference is evaluation, not model branding.
Service extraction only at a demonstrated seam
If a domain develops independent team ownership, a distinct deployment or scaling profile, a clear data boundary, and well-defined external contract needs, it may deserve extraction. Candidate-document processing or outbound communication could eventually meet that threshold. Until then, the modular monolith remains the simpler and more legible place to evolve the workflows.
Architecture should not be a prediction contest. It should be an instrument for responding well to what the product has actually become.
XIII. The Transferable Principles
Several principles emerge from JobCorporate’s design that apply beyond hiring software.
Model the relationship, not only the object. A message is safer when it belongs to participants and a job. A CV is safer when it belongs to a candidate. An application is more useful when its submission channel remains known. Context is often the permission model.
Place invariants where concurrency cannot bypass them. A client-side disabled button is useful. A database uniqueness constraint is true. Use the first for experience and the second for correctness.
Treat graceful degradation as an intentional feature. Public search has a full-text path and a typed fallback because users need a useful result more than the system needs a perfectly uniform implementation. Temporary files are cleaned up after failed application paths because failure is part of the lifecycle.
Give AI a contract. Structured output, explicit score bounds, a known language, and a persistence schema make an AI capability reviewable and renderable. Free-form output may be compelling; it is not automatically a product interface.
Make operations visible in the product. Publishing states, canonical URLs, slug redirects, publication dependencies, account deactivation, audit records, and health checks are not back-office trivia. They are how public confidence survives after the first release.
Earn complexity with evidence. Add a queue when retry semantics require one. Add semantic ranking when it improves outcomes under evaluation. Extract a service when ownership and workload justify it. The right design is not the one with the most components; it is the one whose boundaries remain clear under change.
Conclusion: Constraints Create Confidence
JobCorporate is built on a modest but demanding idea: hiring platforms should make consequential actions feel understandable.
That requires more than polished screens. It requires a public catalogue that distinguishes jobs from internships and hides inactive opportunities. It requires application flows that know the difference between an account candidate, a guest candidate, and email delivery. It requires procedure guards that enforce roles, queries that enforce ownership, and database constraints that hold under concurrency. It requires private documents, participant-scoped conversations, persistent notification state, structured editorial governance, and audit records that can explain the past.
It also requires restraint. AI is used as structured candidate assistance, not a hidden judgment engine. Vector fields are a foundation, not a marketing claim. A modular monolith remains a deliberate choice until the evidence calls for a different boundary. Future infrastructure is named clearly as future work rather than retroactively described as already built.
Those choices all express the same conviction: constraints are not what prevent a product from moving quickly. They are what allow a product to move quickly without becoming incomprehensible.
For JobCorporate, that is the architecture of trust. Not a promise made in copy, but a system of decisions that helps candidates, recruiters, and operators know what the platform will do—and why they can rely on it.