Project Architecture
This document gives a high-level overview of the app: what it is, the technologies it's built with, and how the codebase is organized. It covers the user-facing product only.
Overview
This is a personal portfolio app built with Next.js. It has two goals:
- Present Alejandro's professional experience as an online CV (roles, skills, education, testimonials).
- Let visitors go deeper through an AI Assistant chat that answers questions about that experience, grounded in a knowledge base, so a recruiter or visitor can explore beyond what's written on the page.
Technologies
| Technology | Purpose |
|---|---|
| Next.js (App Router) | Core framework — routing, server rendering, and API routes |
| React | UI library |
| TypeScript | Static typing across the codebase |
| Tailwind CSS | Styling |
| shadcn/ui + Base UI | Accessible UI component primitives |
| Motion | Animations and page/element transitions |
| Vercel AI SDK | Streaming chat UI and model interaction |
| Mastra | AI agent framework powering the assistant (agent orchestration, memory, tools) |
| Mastra Observability | Tracing and structured logging for AI agent runs, with sensitive data redacted before it's persisted; built to also export to the hosted Mastra Platform for centralized trace review |
| Drizzle ORM + PostgreSQL | Database access and migrations |
| Better Auth | Authentication, including anonymous recruiter sessions |
| Upstash Redis | Rate limiting for chat usage |
| Zod | Schema validation |
| TanStack Markdown | Parses knowledge-base documents and renders them to HTML (e.g. this architecture page) |
| @react-pdf/renderer | Server-side PDF generation for the downloadable CV |
| Umami | Traffic and product analytics |
| Playwright | End-to-end browser tests, run against deployed preview and production environments |
Folder structure
At the root, the project is organized as:
alepalmer-cv/
├─ src/ # Application source code
├─ drizzle/ # Database migrations
├─ docs/ # Project documentation
└─ public/ # Static assets
Inside src/:
| Folder | Role |
|---|---|
| app/ | Next.js routes, layouts, and API handlers |
| features/ | Product capabilities (e.g. chat, cv), each owning its own domain, UI, and data access |
| services/ | Shared infrastructure used across features (database, auth, AI runtime, CMS, rate limiting) |
| components/ | Shared, feature-agnostic UI components |
| lib/ / utils/ | Small shared helpers |
Feature structure
Each feature under features/<name>/ is organized by concern rather than by file type, typically including:
model/— domain typesrepository/— data access and persistenceservices/— data access for Server Components, cached where needed (Reactcache, Next"use cache")components/— feature-specific UIactions/— server-side operations the UI triggersutil/— feature-specific helpers
Not every feature includes every slice — only what that feature needs.
Rendering strategy
The main site routes use different rendering strategies depending on how static their content is:
| Route | Strategy | What it means |
|---|---|---|
/ (CV) | ISR (Incremental Static Regeneration) | The CV content is a document stored in the database — the same one that powers the AI assistant's knowledge. The page is built once and served from cache, and only regenerated when that document changes |
/architecture | ISR (Incremental Static Regeneration) | Same idea: this document is also stored in the database, and the page is only regenerated when it changes |
/chat, /chat/[threadId] | PPR (Partial Prerendering) | A static shell is served instantly, while the parts that depend on live data — the initial chat messages and personalized recommendations — stream in as soon as they're ready |
Data Modeling
The database is a single PostgreSQL instance, but data is split across three Postgres schemas by ownership:
- App schema — first-party tables the product owns directly (via Drizzle ORM), covering the CV content, the knowledge-base documents (including this page), and the recruiter job-application flow.
- Better Auth schema — authentication tables managed by the Better Auth library (users, sessions, and related auth records), also modeled through Drizzle so the app can read/relate against them, but whose shape and migrations are owned by Better Auth.
- Mastra schema — storage for the AI agent framework, managed entirely by Mastra's own Postgres storage adapter (memory, conversation threads and messages, and observability traces). The app doesn't define these tables itself — it just points Mastra at this schema.
Below is the shape of each table in the app schema. Internal database and column identifiers are intentionally omitted — tables and fields are described by role and shape only.
Two of these tables are worth calling out specifically. The knowledge-base document that describes this project's own architecture (this page) is a single source of truth — the same stored document renders this page and is what the AI assistant reads from when answering questions about the project, so there's no separate copy to drift out of sync with.
The CV document is the single source of truth behind the public CV page, the AI assistant's CV tool, and the downloadable CV PDF (generated on demand via @react-pdf/renderer) — the same stored content backs all three, so there's no separate copy for any of them to drift out of sync with. Unlike the architecture document, though, a CV isn't necessarily one fixed row: exactly one document is flagged as the default CV, and a job application (see below) can additionally have its own CV document, customized for that specific role. At the moment, the default CV is still what the public page renders. The AI assistant however uses the personalized CV if exists, otheriwse fallback to the default CV.
CV Document — the structured content behind a CV, either the site-wide default or one customized for a specific job application.
- Identifier (unique ID, primary key)
- Version number (integer) — increments on each update
- Content (JSON) — the structured CV content itself
- Default flag (boolean) — marks this as the site-wide default CV. The application, not a database constraint, keeps this to exactly one row: saving the default CV always updates the existing default document rather than inserting a new one.
- Optional reference to a Job Application — when set, this document is a CV customized for that job application instead of the default; cascades on delete, and (by the same application-level convention as the default flag) at most one CV document is kept per job application
- Created-at / updated-at timestamps
Knowledge Base Document — long-form Markdown documents (e.g. this architecture page) that the AI assistant can ground its answers in.
- Identifier (unique ID, primary key)
- Title (text)
- Content (text, Markdown)
- Summary (text, optional) — used for retrieval/grounding
- Created-at / updated-at timestamps
Document Category — a tag that documents can be grouped under (e.g. to mark "this is the architecture doc").
- Name (text, primary key)
- Created-at timestamp
Document Category Assignment — join table linking documents to categories, many-to-many.
- Reference to a Knowledge Base Document (cascades on delete)
- Reference to a Document Category (cascades on delete)
- Composite primary key across both references
Job Application — a recruiter-facing job posting that a chat session can be scoped to.
- Identifier (unique ID, primary key)
- Company name (text)
- Job position (text)
- Job description (text)
- Optional instructions (text) — extra context for the assistant when discussing this role
- Optional application link (text) — URL to the original job posting, for reference
- Applied flag (boolean) — whether Alejandro has applied to this role; defaults to false
- Optional applied-at date — set together with the applied flag, only through a dedicated "mark as applied" action, never through a general edit
- Created-at timestamp
Clean Architecture & Dependency Inversion
Within a feature, model/, repository/, and the UI-facing layers (actions/, Server Components) don't depend on each other directly — they depend on an abstraction, following the Dependency Inversion Principle at the heart of Clean Architecture. Concretely, this is a model/interface + repository/implementation split, wired together by a small composition root, rather than a runtime dependency-injection container.
The job-application feature as a worked example (a recruiter-facing job posting, and the resource a chat access link is scoped to):
model/owns the domain types (JobApplication,CreateJobApplicationInput,UpdateJobApplicationInput) and the abstraction itself: aJobApplicationRepositoryinterface declaring only the operations the domain needs (create,findById,findByAccessCode,findAll,update,delete), with no mention of SQL, Drizzle, or any storage detail.repository/owns the concrete implementation:DrizzleJobApplicationRepositoryimplementsJobApplicationRepositoryusing Drizzle ORM and Postgres. This class depends inward on the interface defined bymodel/— not the other way around.repository/index.tsacts as the feature's composition root. It's the one place that knows a concreteDrizzle...class exists at all:
The exported constant is deliberately typed as the interface, not the class.export const jobApplicationRepository: JobApplicationRepository = new DrizzleJobApplicationRepository();- Consumers — Server Actions and Server Components — depend only on the abstraction: they import the pre-wired singleton from the composition root, but every reference to it is typed as
JobApplicationRepository. Nothing outsiderepository/ever namesDrizzleJobApplicationRepositorydirectly.
The effect is an inverted dependency graph: the low-level detail (Drizzle, Postgres) depends on the abstraction owned by the domain layer, and the UI/action layer is coupled only to that same abstraction — never to the database technology behind it. Swapping storage engines, or substituting a mock in tests, means writing a new class that satisfies the interface; nothing in model/ or actions/ has to change.
This same shape — interface in model/, implementation in repository/, a one-line composition root in repository/index.ts — repeats across the other features that persist data, just with different names. It's a lightweight, per-feature form of dependency inversion — one interface, one implementation, one hand-written composition root — rather than full hexagonal ports-and-adapters with a runtime DI container. It gets the main practical benefit (domain and UI code stay decoupled from infrastructure) without the ceremony of a framework, which fits a codebase of this size.
AI Agent
The AI assistant that the CV chat is built on is implemented with Mastra (@mastra/core), a TypeScript agent framework, running on top of Vercel's AI SDK for the underlying model plumbing. It's a single agent for user-facing conversations, plus a small internal agent used only at content-authoring time.
Two agents, two purposes
- The assistant agent is the conversational one. It's equipped with tools, given persistent memory, and instructed by a fixed system prompt to answer questions about Alejandro's professional background — and to decline anything outside that scope (general coding help, opinions, unrelated topics).
- A summarizer agent exists purely as an authoring-time utility: when a knowledge-base document is created or edited, this tool-less agent is invoked once to produce a short, fixed-format two-line synopsis of it, which is stored alongside the document and later used as the retrieval index (see below). It never participates in a live conversation.
Model selection is designed to go through the Vercel AI Gateway, letting the app choose between models from different providers rather than being locked to one. Regardless of provider, the model is referenced through Mastra's provider-agnostic model resolver rather than a directly-imported provider SDK.
Tools
The assistant agent's knowledge of Alejandro is entirely tool-mediated — nothing about his CV, work history, or a given job application is baked into the system prompt. Instead, the agent decides at conversation time which of the following it needs to call, with each tool's inputs and outputs validated against a schema (Zod):
- Get CV — returns the current CV document: profile, skills, work history, education, languages, testimonials. This is the "headline" layer — enough breadth to answer most factual questions without going deeper. It reads the customized CV (see Data Modeling above) when the conversation is scoped to one job application. If there is not customized CV it fallbacks to the default CV.
- Get job application — when the conversation is scoped to a specific job application, returns that role's company, position, job description, and any extra instructions left for the assistant. Returns nothing found when the conversation isn't tied to one.
- Get knowledge-base summaries — returns the full list of knowledge-base documents as (id, two-line summary) pairs. This is the entire "index" the agent has to work with — nothing is filtered or ranked before it reaches the model.
- Get knowledge base — given a list of document IDs, returns each document's full title, content, and categories, fetched by primary key.
A simplified, LLM-driven retrieval system
The knowledge base is grounded through retrieval, but deliberately not through the machinery usually associated with "RAG": there's no embedding model, no vector database, no chunking, and no similarity search anywhere in the stack. Instead, retrieval is split into two stages:
- Indexing, done once per document at authoring time. When a document is saved, the summarizer agent reads its full text and produces a short, structured synopsis — a one-line "situation" and a one-line "themes" — which is persisted as the document's summary. This is the only preprocessing a knowledge-base document ever gets; there's no chunking of long documents into passages.
- Retrieval, done live by the conversational agent's own reasoning. The assistant agent first pulls the entire summary index into its context via the summaries tool. It reads all the summaries itself and — by semantic judgment, guided by instructions in its system prompt — decides which document IDs, if any, are relevant to the question at hand. It then calls the knowledge-base tool with just those IDs, which does a straightforward filter-by-primary-key lookup, and the full text of the selected documents comes back as tool results for the agent to ground its answer in.
In short: the LLM itself performs the "search" step, reasoning over hand/LLM-written blurbs rather than vector embeddings, and the result is whole documents rather than ranked passages. It's a retrieval scheme sized for a knowledge base of a personal CV project — a handful of documents — rather than an index built to scale to a large corpus.
System prompt and orchestration
The assistant's instructions are a single static block of text, not assembled or interpolated per request. They define a strict scope gate around what topics the agent will engage with, a description of what each tool is for and when to reach for it, a decision procedure for routing a question to the CV, the job application, or the knowledge base, rules against inferring or hallucinating beyond what a source actually says, and style constraints (e.g. never revealing that answers come from tool calls). Anything that does vary by conversation — like which job application it's scoped to — flows in separately, through a small per-request context object threaded into the relevant tool, rather than being spliced into the prompt text itself; this keeps the prompt fixed regardless of conversation.
Multi-step tool use is handled by Mastra's agent runtime, not by hand-written orchestration code: the framework runs the standard model → tool call → tool result → model loop automatically, including the two-step retrieval flow above (summaries, then selected documents) within a single conversational turn. Conversation state is persisted through Mastra's own memory system, backed by the same Postgres database as the rest of the app, so multi-turn context and thread history survive across requests without any custom persistence logic in the application layer.
Evaluation
Currently there is no evaluation pipeline for the agent but it will be implemented soon.
Quotas
The system relies on a few coarser guardrails: an upper bound on how long an individual message can be, and a cumulative token budget enforced per job application scope, so usage stays bounded without needing to reason about context-window size directly. Every agent run is also traced, with sensitive data stripped from the recorded spans before anything is persisted (see Mastra Observability above).
Security
The AI assistant chat isn't open to the public — access is scoped per job application, and every job application gets its own isolated slice of chat resources. Two concepts matter here, and they're deliberately kept separate: the anonymous session (one visitor's individual identity) and the job application (the shared resource that visitor's session is scoped to).
Anonymous sessions: identifying a visitor
Each job application has a unique, unguessable access link. Visiting the link is read-only — it looks up which job application the link belongs to, but doesn't create any session or grant access by itself. Only once a visitor explicitly continues past that landing step does the app establish an anonymous session (via Better Auth) for that visitor. Creating the session is a server-side decision — the app verifies the link itself and stamps the resulting session with the job application it belongs to; a client can't set or influence that association directly.
An anonymous session belongs to one visitor's browser: if the same visitor returns using the same link, their existing session is reused rather than a new one being minted. Different visitors opening the same link, though, each get their own distinct anonymous session — the link identifies which job application they're accessing, not who they are.
Job applications: the shared resource
Chat history and usage quota are not owned by an individual anonymous session — they're owned by the job application. Every anonymous session that was created from a given job application's link draws on that same job application's chat history and quota, regardless of how many separate visitors/sessions that is. This is an intentional trade-off: the job application, not the individual visitor, is the unit of access and isolation.
Two different job applications are fully isolated from each other — one job application's conversation history, context, and usage never overlaps with another's. Anything the assistant needs to know about a specific role (company, position, job description, extra instructions) is only ever pulled in based on the job application tied to the caller's session — a session can't cause the assistant to surface another job application's data.
Rate limiting
Chat usage is rate-limited per job application using Upstash Redis, budgeting the tokens available to the assistant over a rolling window rather than just capping the number of messages — so the limit is shared across every anonymous session using that job application, consistent with the job application being the resource boundary. Separately, the step where a visitor's link converts into a session is rate-limited by IP, which caps how many sessions a single source can create in a short period — a guard against automated abuse of a link rather than normal recruiter usage.
Other safeguards
- Route protection: chat routes require a valid session associated with a job application. This check happens centrally, before a request reaches any page or API handler.
- Input validation: incoming chat messages and the inputs/outputs of every tool the AI assistant can call are validated against schemas (via Zod), and messages have a bounded maximum length.
- Observability with redaction: AI agent runs are traced and logged (see Mastra Observability above) with sensitive data stripped from spans before anything is persisted.
Analytics
Product analytics run through Umami, covering both standard traffic tracking (page views, sessions, referrers) and a set of custom events. The custom events are all fired from the /access/[accessCode] page — the landing page a recruiter reaches from their unique job-application access link, before any session exists (see Security above) — to understand how visitors move through the access-and-invite flow:
| Event | Fires when |
|---|---|
access-page-view | A visitor lands on the access page for a job application |
access-go-to-cv | The visitor clicks through to the public CV instead of continuing to the assistant |
access-get-access | The visitor opens the dialog to request access to the AI assistant |
access-accept-continue | The visitor confirms and accepts, triggering session creation and redirect into the chat |
Every event carries the same three pieces of context — the job application's ID, job position, and company name — so activity can be grouped and compared per job application.
End-to-End Testing
The project has an end-to-end suite built with Playwright. It lives in its own package next to the app and is deliberately standalone: it imports nothing from the application source and only ever talks to a deployed URL over HTTP. That makes it a true black-box check of a real deployment — the same code, database, authentication and edge configuration a visitor would hit.
The flows covered
| Flow | What it verifies |
|---|---|
| Navigation | The CV and architecture pages load and render real content — a page that silently degrades to an empty CV or an empty table of contents is a failure, not a pass — client-side navigation between them works. It additionally uses Next.js's instant() testing API to assert the streaming shell: that the static part of a page is visible the moment a navigation commits, while the parts behind Suspense (the chat thread list) are still held back. This turns the rendering strategy described above into something verified rather than assumed. |
| Chat is protected | Every chat route bounces a visitor without a valid session to a dedicated "no access" page, while the chat API answers an unauthorized call with a JSON 401 instead of a redirect. It also confirms that anonymous sessions cannot be minted directly, which is the guard the whole access model in Security rests on. |
| Access link, unknown vs valid | An unknown access code renders a not-found page and offers no route to the assistant; a valid one renders the greeting built from that job application. Most importantly, it pins the landing page as side-effect free: merely loading it — or opening the disclaimer and cancelling — creates no session and leaves the chat closed. |
| Accept the disclaimer, reach the chat | The happy path end to end: greeting → disclaimer dialog (asserted to still state the anonymity and data-storage terms, since dropping that copy would be a compliance regression rather than a styling one) → accept → land in the chat with a working composer and thread list. |
Where each flow runs
The suite runs against two targets, with a single rule dividing them: anything that runs against production must be read-only.
| Preview deployment | Production (live site) | |
|---|---|---|
| Flows | All four | Navigation and chat protection only |
| Writes | One anonymous session, in the preview database | Nothing at all |
| Credentials needed | Seeded access code, deployment-protection bypass | None |
instant() shell assertions | Yes | No — production deliberately doesn't expose the Next.js testing API these need |
| Trigger | Automatically after each successful preview deployment of the development branch | On a daily schedule |
The two access-link flows are preview-only because they need a seeded test job application and they create a session — neither belongs against the live site. What remains for production is a read-only smoke suite: the public pages are up and rendering real content, client navigation works, and the authorization boundaries around chat still hold.