Secure Paceboard with PostgreSQL
Add secure sessions, persistent activities, and race-safe Kudos to Paceboard.
Introduction
30 Second Summary
A running feed feels unreliable when activities disappear after a refresh. Trust falls further when one runner can change another runner's entry or duplicate the same reaction.
In this project, you will evolve the existing Paceboard feed into an authenticated full-stack application backed by PostgreSQL. You will add persistent activities, ownership controls, database sessions, and concurrency tests without rebuilding the Project 1 frontend.
What You'll Build
You demo a shared Paceboard feed that keeps each runner's activities, owner controls, derived pace, and Kudos correct across refreshes.
By the end of this project, you'll have:
- A secure account flow where you can register, log in, log out, and return through a server-validated database session.
- A persistent activity feed where a logged run survives refreshes. Its average pace is derived from stored distance and duration.
- An ownership-aware data layer that lets runners edit or delete their own activities. Another authenticated user remains blocked even when they submit a valid activity ID directly.
- Secret Mission: Force two concurrent Kudos requests through the same race window. Preserve the failed naive result before repairing the operation with an idempotent database insert.
Are there any prerequisites?
You need a completed local copy of Project 1 plus Node.js 22.12.0 or newer. Docker Desktop must already be installed and running.
Before We Start
Before any hands-on work, lock in the migration you are about to make to Paceboard. The existing ActivityRepository boundary protects the tested feed while PostgreSQL persistence introduces durable activities and server-derived identity.
Set Up PostgreSQL and Prisma
Project 1 proved that Paceboard can render a tested activity feed through its repository boundary. Its mock adapter cannot prove that data survives a restart or that database constraints hold under concurrent requests.
In this step, PostgreSQL becomes the local data store. Docker Compose gives that database a repeatable runtime. Prisma ORM provides the relational schema while the existing ActivityRepository boundary stays intact.
In this step, get ready to:
- Protect the Project 1 architecture with repository guidance and exact dependency pins.
- Run healthy development and test databases in one local PostgreSQL container.
- Apply the relational schema and generate the Prisma Client.
Pin dependencies and protect the architecture
A storage migration can spread database details into the UI if the project rules are vague. The repository instructions keep Claude Code focused on replacing the adapter behind the existing port.
- Select CLAUDE.md from the Paceboard repository file tree.
- Replace its contents with the project guidance below:
# Product
- Paceboard is Project 2 in a progressive Strava-inspired application series.
- Preserve the tested Project 1 feed and evolve it through existing boundaries.
- Project 3 will add GPS ingestion, route processing, privacy controls, and interactive maps.
# Architecture
- `app/page.tsx` is the server composition boundary.
- UI code depends on `Activity`, not Prisma records.
- Server code depends on `ActivityRepository`, not directly on fixtures.
- Authentication derives identity from a database-backed cookie session.
- Authorization belongs in the data layer and must include ownership predicates.
- Average pace is derived from stored distance and elapsed time.
- Kudos uniqueness is enforced by the database and API retries must be idempotent.
# Quality gates
- Keep TypeScript strict and do not introduce `any`.
- Validate all Server Action input with Zod.
- Re-check authentication and authorization inside every mutation.
- Run `npm test` against the real test database.
- Run `npm run build` before reporting completion.
- Keep the Mermaid architecture and documented limitations current.
What does this guidance protect?
- The architecture rules preserve the existing Activity contract and ActivityRepository port.
- The security rules keep authentication and ownership authorization inside server-side code.
- The quality gates require real database tests plus production-build evidence.
- Save CLAUDE.md.
- Confirm the file now contains the Product, Architecture, and Quality gates sections.
Repository guidance missing?
Confirm that CLAUDE.md sits at the top level of the Paceboard repository. A nested copy does not provide repository-wide guidance.
Ask for help with checking the location and contents of CLAUDE.md.
The manifest now needs the database packages used by the next adapter. Exact pins keep the generated client and PostgreSQL adapter on the same Prisma release.
- Switch to package.json in the repository file tree.
- Keep the existing Next.js 16.4.3, Tailwind CSS 4.3.3, TypeScript 7.0.2, and Vitest 5.0.5 pins unchanged.
- Add @prisma/client at 7.10.1, @prisma/adapter-pg at 7.10.1, and zod at 4.6.7 to the production dependencies.
- Add prisma at 7.10.1 to the development dependencies.
- Add a db:migrate script that applies a named development migration and generates the client.
- Add a db:test:push script that points DATABASE_URL at postgresql://paceboard:paceboard@localhost:5432/paceboard_test before synchronizing the schema.
- Save package.json.
- Install the pinned manifest dependencies by running:
npm install
What does this command change?
The command installs the dependency versions declared in package.json. It also updates package-lock.json so another machine can reproduce the same package graph.
Your dependency installation is now reproducible. The existing framework and test-runner pins remain anchored to Project 1.
Dependency installation failed?
Check the commas and quotation marks around the new dependency entries. Also confirm that all three Prisma packages use the same 7.10.1 version.
Ask for help with diagnosing the package manifest without changing existing pins.
Start the development and test databases
The local container hosts two isolated databases. paceboard stores development data while paceboard_test gives integration tests a disposable target.
- Create compose.yaml at the top level of the Paceboard repository.
- Paste the database service configuration below:
services:
postgres:
image: postgres:18.6-bookworm
environment:
POSTGRES_USER: paceboard
POSTGRES_PASSWORD: paceboard
POSTGRES_DB: paceboard
ports: ["5432:5432"]
volumes:
- paceboard_postgres:/var/lib/postgresql
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paceboard -d paceboard"]
interval: 2s
timeout: 5s
retries: 15
volumes:
paceboard_postgres:
What does this configuration do?
- The postgres:18.6-bookworm image provides the pinned PostgreSQL server with Intel Mac support.
- The port mapping exposes the database at localhost:5432.
- The paceboard_postgres volume keeps database files after the container stops.
- The health check prevents later commands from racing a database that is still starting.
- Save compose.yaml.
- Confirm the file contains one postgres service and one paceboard_postgres volume.
Compose file showing a YAML problem?
YAML indentation controls nesting. Keep the service fields beneath postgres and the named volume beneath the final volumes key.
Ask for help with checking the compose.yaml indentation.
- Create docker/postgres/init.sql from the Paceboard repository file tree.
- Paste the test-database initialization statement below:
CREATE DATABASE paceboard_test;
Why create a separate test database?
The initialization statement creates paceboard_test beside the development database. Integration cleanup can erase test rows without touching activities you create manually.
- Save docker/postgres/init.sql.
- Confirm the repository file tree now shows the nested docker/postgres path.
The first image pull can take a few minutes while Docker downloads PostgreSQL. A quiet terminal during that download does not mean the command has stalled.
Before you start the service, do you expect Compose to finish before or after PostgreSQL passes its health check?
- Start the local database and wait for its health check by running:
docker compose up --detach --wait
What does this command prove?
Compose starts the container in the background. The wait option keeps the command active until the configured PostgreSQL health check succeeds.
You should see the service reach a healthy state. That proves the development database is accepting connections and the initialization script had a chance to create the test database.
Database did not become healthy?
Confirm Docker Desktop is still running. Check whether another local database already occupies port 5432.
If this repository used the volume during an earlier attempt, the initialization script does not run again against that existing data directory. Use the cleanup command from the final project section only when you are ready to remove the old local database data.
Ask for help with diagnosing the PostgreSQL container health check.
A local environment file gives Prisma a connection string without placing machine-specific configuration inside application code. The example file documents the expected format for future contributors.
- Create .env.example at the top level of the Paceboard repository.
- Paste the development connection string below:
DATABASE_URL=postgresql://paceboard:paceboard@localhost:5432/paceboard
What does this value identify?
The connection string identifies the local database user, password, host, port, and development database. These credentials belong only to this local educational environment.
- Save .env.example.
- Duplicate .env.example as .env.local in the same Paceboard repository folder.
The repository now has a documented connection template plus the local runtime configuration Prisma reads during development.
Prisma cannot find the connection string?
Confirm the local file is named exactly .env.local. Check that it sits beside package.json and contains the same single line as .env.example.
Ask for help with checking the local DATABASE_URL configuration.
Configure and migrate Prisma
The schema turns identity, sessions, activities, and Kudos into related database records. Cascading relations clean up dependent rows while indexes support ownership and feed-order queries.
- Create prisma.config.ts at the top level of the Paceboard repository.
- Paste the Prisma configuration below:
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: { path: "prisma/migrations" },
datasource: {
url: process.env.DATABASE_URL ?? "postgresql://paceboard:paceboard@localhost:5432/paceboard",
},
});
What does this configuration control?
- The schema path points Prisma at prisma/schema.prisma.
- The migrations path keeps development migration history under prisma/migrations.
- The datasource uses DATABASE_URL when it exists and falls back to the local development database.
- Save prisma.config.ts.
- Confirm the file points to the schema and migration paths inside the prisma folder.
Configuration file showing an import problem?
Confirm npm install completed after the Prisma packages were added. Also check that the file name ends in .ts.
Ask for help with checking the Prisma configuration import.
- Create prisma/schema.prisma from the Paceboard repository file tree.
- Add the client generator, PostgreSQL datasource, and activity enum:
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum ActivityType {
RUN
RIDE
WALK
}
What does this schema foundation define?
The generator places client files under generated/prisma. The enum restricts every stored activity to one of the three supported sports.
- Save prisma/schema.prisma.
- Confirm the editor now recognizes ActivityType with RUN, RIDE, and WALK values.
Schema foundation not recognized?
Check that the file path is exactly prisma/schema.prisma. Confirm the generator output uses two leading dots because it resolves from the prisma folder.
Ask for help with checking the Prisma generator and datasource blocks.
- Append the User model to prisma/schema.prisma with the code below:
model User {
id String @id @default(uuid())
name String
email String @unique
passwordHash String
sessions Session[]
activities Activity[]
kudos Kudos[]
createdAt DateTime @default(now())
}
What does the user model own?
Each user has a unique email plus a password-hash field. Its relation lists connect one identity to sessions, owned activities, and given Kudos.
- Save prisma/schema.prisma.
- Confirm the editor now lists User as a schema model.
User relations showing unresolved types?
The related models arrive in the next chunks. Confirm the relation names are spelled exactly as shown before continuing.
Ask for help with checking the User model fields.
- Append the Session model to prisma/schema.prisma with the code below:
model Session {
id String @id
userId String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
Why is the session ID supplied?
The application supplies a hashed token verifier as the session ID. The indexes support identity lookup plus expiration cleanup while cascading deletion removes sessions with their user.
- Save prisma/schema.prisma.
- Confirm the editor now lists Session beside User.
Session relation showing a mismatch?
Check that userId appears in both the scalar field and the relation field list. Keep the referenced user field as id.
Ask for help with checking the Session relation and indexes.
- Append the Activity model to prisma/schema.prisma with the code below:
model Activity {
id String @id @default(uuid())
ownerId String
type ActivityType
title String
distanceHundredthsMiles Int
elapsedSeconds Int
elevationGainFeet Int
recordedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
kudos Kudos[]
@@index([ownerId])
@@index([recordedAt(sort: Desc)])
}
Why store activity metrics this way?
Distance uses integer hundredths of a mile to avoid floating-point storage drift. Pace stays out of the table because the repository derives it from distance and elapsed time.
The ownership index supports protected mutations. The descending time index supports newest-first feed queries.
- Save prisma/schema.prisma.
- Confirm the editor now lists Activity with ownership and recorded-time indexes.
Activity model showing a field problem?
Confirm the distance field is distanceHundredthsMiles with an Int type. Check that the time index includes descending sort order.
Ask for help with checking the Activity model and its indexes.
- Append the Kudos model to prisma/schema.prisma with the code below:
model Kudos {
id String @id @default(uuid())
userId String
activityId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
@@unique([userId, activityId], name: "oneKudosPerUserActivity")
@@index([activityId])
}
What enforces one Kudos?
The named composite unique constraint allows only one row for each user and activity pair. PostgreSQL becomes the final duplicate-prevention boundary even when requests overlap.
- Save prisma/schema.prisma.
- Confirm the editor now lists all four models with no unresolved relation names.
Kudos constraint showing a schema problem?
Check the capitalization of the Kudos model and the exact constraint name oneKudosPerUserActivity.
Ask for help with checking the Kudos relations and composite uniqueness.
Use the comparison tabs before creating the migration. The reference files must match because generated code reflects every schema field and relation.
✔️ Awesome, I've got everything!
Great. Save the configuration files and keep the healthy PostgreSQL service running for the migration.
ⓧ I'd like to double check the full code
Compare each file with the complete reference below. Keep the existing package.json content while checking only the pinned additions and database scripts described earlier.
DATABASE_URL=postgresql://paceboard:paceboard@localhost:5432/paceboard
Environment reference
This file documents the local development connection string.
services:
postgres:
image: postgres:18.6-bookworm
environment:
POSTGRES_USER: paceboard
POSTGRES_PASSWORD: paceboard
POSTGRES_DB: paceboard
ports: ["5432:5432"]
volumes:
- paceboard_postgres:/var/lib/postgresql
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paceboard -d paceboard"]
interval: 2s
timeout: 5s
retries: 15
volumes:
paceboard_postgres:
Compose reference
This file defines the PostgreSQL service, initialization mount, health check, port, and named volume.
CREATE DATABASE paceboard_test;
Initialization reference
This file creates the disposable integration-test database during first-time volume initialization.
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: { path: "prisma/migrations" },
datasource: {
url: process.env.DATABASE_URL ?? "postgresql://paceboard:paceboard@localhost:5432/paceboard",
},
});
Prisma configuration reference
This file connects the schema and migration locations to the development datasource.
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum ActivityType {
RUN
RIDE
WALK
}
model User {
id String @id @default(uuid())
name String
email String @unique
passwordHash String
sessions Session[]
activities Activity[]
kudos Kudos[]
createdAt DateTime @default(now())
}
model Session {
id String @id
userId String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
model Activity {
id String @id @default(uuid())
ownerId String
type ActivityType
title String
distanceHundredthsMiles Int
elapsedSeconds Int
elevationGainFeet Int
recordedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
kudos Kudos[]
@@index([ownerId])
@@index([recordedAt(sort: Desc)])
}
model Kudos {
id String @id @default(uuid())
userId String
activityId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
@@unique([userId, activityId], name: "oneKudosPerUserActivity")
@@index([activityId])
}
Schema reference
This file is the complete relational contract used by both local databases.
# Product
- Paceboard is Project 2 in a progressive Strava-inspired application series.
- Preserve the tested Project 1 feed and evolve it through existing boundaries.
- Project 3 will add GPS ingestion, route processing, privacy controls, and interactive maps.
# Architecture
- `app/page.tsx` is the server composition boundary.
- UI code depends on `Activity`, not Prisma records.
- Server code depends on `ActivityRepository`, not directly on fixtures.
- Authentication derives identity from a database-backed cookie session.
- Authorization belongs in the data layer and must include ownership predicates.
- Average pace is derived from stored distance and elapsed time.
- Kudos uniqueness is enforced by the database and API retries must be idempotent.
# Quality gates
- Keep TypeScript strict and do not introduce `any`.
- Validate all Server Action input with Zod.
- Re-check authentication and authorization inside every mutation.
- Run `npm test` against the real test database.
- Run `npm run build` before reporting completion.
- Keep the Mermaid architecture and documented limitations current.
Repository guidance reference
This file preserves the architecture and security boundaries during later Claude Code work.
Before you apply the migration, do you expect one schema to produce both database tables and a generated TypeScript client?
- Apply the named development migration and generate the Prisma Client by running:
npm run db:migrate
What should this command create?
The script applies the relational schema to paceboard and records migration history under prisma/migrations. It also generates the typed client under generated/prisma.
You should see the migration complete successfully. You should also see new migration history plus generated client files in the repository.
Development migration failed?
Confirm the PostgreSQL container is healthy and .env.local points to the paceboard database. A schema error usually identifies the model or relation that needs comparison.
Ask for help with diagnosing the development migration.
- Synchronize the same schema to the disposable test database by running:
npm run db:test:push
Why push the test schema separately?
The test database needs the same tables and constraints without its own migration history. This keeps integration tests isolated while the development database retains the auditable migration.
You should see the test database schema synchronize successfully. The development migration history remains unchanged.
Test schema push failed?
Confirm docker/postgres/init.sql existed before the named volume was initialized. Also check that the db:test:push script targets paceboard_test.
Ask for help with checking whether the test database exists.
That is the local data foundation complete. Paceboard now has two synchronized databases plus a generated client without disturbing the Project 1 feed boundary.
Your database layer is ready. Next, you will replace the fixture adapter with PostgreSQL while keeping the existing feed components unchanged.
Connect the PostgreSQL Activity Feed
Your local PostgreSQL database is healthy. Paceboard still needs a server-side path from that database into the Project 1 feed.
This step replaces fixture storage behind the existing ActivityRepository boundary. The preserved feed then reveals the first database-backed state without a frontend rewrite.
In this step, get ready to:
- Create one server-only Prisma Client for application data access.
- Implement a PostgreSQL repository adapter that maps database rows into the existing activity contract.
- Render the persistent empty-feed state through the existing Paceboard shell.
Create the server-only Prisma client
The Prisma Client gives server code typed access to PostgreSQL. A process-level instance prevents development reloads from creating a fresh connection client every time.
- Switch back to the existing Paceboard repository in VS Code.
- Create lib/prisma.ts from the Explorer sidebar.
- Add the server-side client by replacing the contents of lib/prisma.ts with this code:
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
const globalForPrisma = globalThis as unknown as { paceboardPrisma?: PrismaClient };
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is required");
export const prisma = globalForPrisma.paceboardPrisma ?? new PrismaClient({
adapter: new PrismaPg({ connectionString }),
});
globalForPrisma.paceboardPrisma = prisma;
What does this client do?
- The generated PrismaClient supplies types based on your current relational schema.
- The PrismaPg adapter connects that client to the local PostgreSQL database.
- The DATABASE_URL check stops the application before it attempts a connection with missing configuration.
- The global cache reuses one client while the development server reloads modules.
- Save lib/prisma.ts.
- Check the editor for TypeScript diagnostics in lib/prisma.ts.
You should see no unresolved imports or type errors. Your application now has one server-side database entry point.
✔️ The Prisma client is ready
Your generated client now connects through PrismaPg. Keep lib/prisma.ts saved.
This module remains inside server-side data access code. Browser components never receive the connection string.
The reference confirms that one exported client is reused during development.
ⓧ I'd like to double check the full code
Compare your complete lib/prisma.ts file with this reference.
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
const globalForPrisma = globalThis as unknown as { paceboardPrisma?: PrismaClient };
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is required");
export const prisma = globalForPrisma.paceboardPrisma ?? new PrismaClient({
adapter: new PrismaPg({ connectionString }),
});
globalForPrisma.paceboardPrisma = prisma;
This full-file reference includes the adapter configuration plus the process-level client cache.
Seeing a Prisma import error?
Confirm that generated/prisma still contains the client generated from the current schema. Check that the import path ends with generated/prisma/client.
Confirm that your local environment defines DATABASE_URL before starting Paceboard.
Help me diagnose my Paceboard Prisma Client setup.
Implement the PostgreSQL repository adapter
A repository adapter translates storage-specific records into the domain type expected by the feed. This keeps Prisma records out of pages and presentation components.
The implementation below arrives in two connected chunks. Paste both chunks before saving so the open class from the first chunk is completed by the second.
- Open the existing data/activity-repository.ts file from the Explorer sidebar.
- Select the existing file contents.
- Paste this first connected chunk at the top of the file:
import { prisma } from "@/lib/prisma";
import { activityId, type Activity, type ActivitySport } from "@/types/activity";
export interface ActivityRepository {
listForViewer(viewerId: string | null): Promise<readonly Activity[]>;
}
function toSport(value: string): ActivitySport {
return value === "RIDE" ? "Ride" : value === "WALK" ? "Walk" : "Run";
}
function initials(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() ?? "").join("");
}
class PostgresActivityRepository implements ActivityRepository {
async listForViewer(viewerId: string | null): Promise<readonly Activity[]> {
const records = await prisma.activity.findMany({
orderBy: { recordedAt: "desc" },
include: {
owner: true,
kudos: { select: { userId: true } },
_count: { select: { kudos: true } },
},
});
return records.map((record) => {
const distanceMiles = record.distanceHundredthsMiles / 100;
return {
What does the query load?
- The interface preserves the existing nullable viewer seam used by the Project 1 composition boundary.
- The query orders activities by the newest recordedAt value first.
- The relation include loads each owner plus the user IDs attached to Kudos rows.
- The count selection lets the feed display the database total without loading duplicate presentation state.
- Complete the open mapping object by pasting this second chunk immediately below the first:
id: activityId(record.id),
athlete: { id: record.owner.id, name: record.owner.name, initials: initials(record.owner.name) },
sport: toSport(record.type),
title: record.title,
recordedAtLabel: new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" }).format(record.recordedAt),
recordedAtIso: record.recordedAt.toISOString(),
note: "Logged with Paceboard.",
metrics: {
distanceMiles,
elapsedSeconds: record.elapsedSeconds,
averagePaceSecondsPerMile: Math.round(record.elapsedSeconds / distanceMiles),
elevationGainFeet: record.elevationGainFeet,
},
route: { path: "M10 68 C42 38 75 76 112 45 S170 18 210 31", start: { x: 10, y: 68 }, finish: { x: 210, y: 31 } },
initialKudos: record._count.kudos,
viewerHasKudos: viewerId !== null && record.kudos.some((kudos) => kudos.userId === viewerId),
ownedByViewer: viewerId !== null && record.ownerId === viewerId,
} satisfies Activity;
});
}
}
export const activityRepository: ActivityRepository = new PostgresActivityRepository();
How does the adapter preserve the feed contract?
- The activityId() helper converts each database UUID into the existing opaque activity ID type.
- The adapter derives miles from integer hundredths before calculating average pace from elapsed seconds.
- The owner relation becomes the athlete summary expected by the existing card.
- The current viewer ID determines Kudos state plus ownership state on the server.
- The route geometry remains a stable placeholder so the Project 1 route preview survives the storage migration.
- Save data/activity-repository.ts.
- Check the completed file for TypeScript diagnostics.
You should see a complete repository class with no unclosed braces. The exported activityRepository now reads activities from PostgreSQL.
✔️ The repository adapter is complete
The adapter now fulfills the original ActivityRepository interface. Pages can request activities without handling Prisma records.
The editor should report no syntax or type errors in data/activity-repository.ts.
The completed adapter keeps storage details behind the repository boundary.
ⓧ I'd like to double check the full code
Compare your complete data/activity-repository.ts file with this reference.
import { prisma } from "@/lib/prisma";
import { activityId, type Activity, type ActivitySport } from "@/types/activity";
export interface ActivityRepository {
listForViewer(viewerId: string | null): Promise<readonly Activity[]>;
}
function toSport(value: string): ActivitySport {
return value === "RIDE" ? "Ride" : value === "WALK" ? "Walk" : "Run";
}
function initials(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() ?? "").join("");
}
class PostgresActivityRepository implements ActivityRepository {
async listForViewer(viewerId: string | null): Promise<readonly Activity[]> {
const records = await prisma.activity.findMany({
orderBy: { recordedAt: "desc" },
include: {
owner: true,
kudos: { select: { userId: true } },
_count: { select: { kudos: true } },
},
});
return records.map((record) => {
const distanceMiles = record.distanceHundredthsMiles / 100;
return {
id: activityId(record.id),
athlete: { id: record.owner.id, name: record.owner.name, initials: initials(record.owner.name) },
sport: toSport(record.type),
title: record.title,
recordedAtLabel: new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" }).format(record.recordedAt),
recordedAtIso: record.recordedAt.toISOString(),
note: "Logged with Paceboard.",
metrics: {
distanceMiles,
elapsedSeconds: record.elapsedSeconds,
averagePaceSecondsPerMile: Math.round(record.elapsedSeconds / distanceMiles),
elevationGainFeet: record.elevationGainFeet,
},
route: { path: "M10 68 C42 38 75 76 112 45 S170 18 210 31", start: { x: 10, y: 68 }, finish: { x: 210, y: 31 } },
initialKudos: record._count.kudos,
viewerHasKudos: viewerId !== null && record.kudos.some((kudos) => kudos.userId === viewerId),
ownedByViewer: viewerId !== null && record.ownerId === viewerId,
} satisfies Activity;
});
}
}
export const activityRepository: ActivityRepository = new PostgresActivityRepository();
This reference combines the preserved port with the PostgreSQL query plus the complete activity mapping.
Seeing repository type errors?
Confirm that the first chunk ends with an open returned object. Confirm that the second chunk starts with the id field inside that object.
Check that types/activity.ts still exports activityId plus the existing activity types.
Help me fix the type errors in my PostgreSQL activity repository.
Reconnect the existing feed
The storage switch should leave the visual feed intact. An explicit empty state makes the database result observable before authentication creates the first user-owned activity.
- Open components/activity-feed.tsx from the Explorer sidebar.
- Replace the existing feed implementation with this database-backed presentation:
import { ActivityCard } from "@/components/activity-card";
import type { Activity } from "@/types/activity";
export function ActivityFeed({ activities, authenticated }: { readonly activities: readonly Activity[]; readonly authenticated: boolean }) {
if (activities.length === 0) return <p className="rounded-3xl bg-white p-8 text-slate-600 shadow-sm">No persistent activities yet. Register and log your first run.</p>;
return <div className="grid gap-5">{activities.map((activity) => <ActivityCard key={activity.id} activity={activity} authenticated={authenticated} />)}</div>;
}
What changed in the feed?
- The feed receives a readonly list of domain activities from the repository.
- An empty list now renders a visible message instead of falling back to seeded records.
- A non-empty list still delegates each activity to the preserved ActivityCard component.
- The authentication state is passed into each card without creating local persistence state.
- Save components/activity-feed.tsx.
✔️ The feed has a persistent empty state
The feed still renders the existing cards when activities exist. An empty PostgreSQL result now has a concrete visual state.
Keep components/activity-feed.tsx saved before reconnecting the page.
This preserves the Project 1 presentation while changing its source of truth.
ⓧ I'd like to double check the full code
Compare your complete components/activity-feed.tsx file with this reference.
import { ActivityCard } from "@/components/activity-card";
import type { Activity } from "@/types/activity";
export function ActivityFeed({ activities, authenticated }: { readonly activities: readonly Activity[]; readonly authenticated: boolean }) {
if (activities.length === 0) return <p className="rounded-3xl bg-white p-8 text-slate-600 shadow-sm">No persistent activities yet. Register and log your first run.</p>;
return <div className="grid gap-5">{activities.map((activity) => <ActivityCard key={activity.id} activity={activity} authenticated={authenticated} />)}</div>;
}
This full-file reference contains the empty state plus the preserved card mapping.
The page remains the server composition boundary. It asks the repository for domain activities before passing those values into the feed.
- Return to app/page.tsx in the Explorer sidebar.
- Change the page data load to call listForViewer with null as the viewer ID.
- Pass the returned activities into ActivityFeed with authenticated set to false.
- Save app/page.tsx.
Before you run Paceboard, do you expect the old sample activities or the database-backed empty state to appear?
- Start the development server from the Paceboard repository by running:
npm run dev
What does this check prove?
The development server compiles the page plus the repository adapter. A successful browser render proves that the generated client can query the local database through the preserved server composition boundary.
- Open http://localhost:3000 in your browser.
You should see the original Paceboard shell with the message No persistent activities yet. Register and log your first run. The old sample activities should be absent.
That is the first full-stack win: Paceboard now renders a real PostgreSQL result through the original repository boundary.
- Press Cmd+Shift+F (macOS) or Ctrl+Shift+F (Windows) in VS Code to open workspace search.
- Search for SeededActivityRepository inside page and component files.
You should see no page or component import the seeded adapter. This confirms that fixtures no longer supply the rendered feed.
- Refresh http://localhost:3000 in your browser.
You should see the same empty-feed message after the refresh. The repeated result comes from the empty development database.
Feed missing or server failing?
Confirm that the PostgreSQL container remains healthy. Check that the local connection string points to the development database named paceboard.
If the shell loads without the empty message, check that app/page.tsx passes the repository result into ActivityFeed.
Help me trace why my database-backed Paceboard feed is not rendering.
Your Project 1 feed now reads through PostgreSQL without exposing database records to the UI. Next, you will replace the nullable viewer seam with verified registration plus database-backed sessions.
Add Authentication and Sessions
Your PostgreSQL adapter now gives Paceboard durable data without changing the ActivityRepository boundary. The feed still lacks a trusted answer to who is making each request.
This step adds authentication for identity. A database-backed session carries that identity between requests. Authorization remains a separate ownership decision for the next step.
In this step, get ready to:
- Build input validation plus salted password hashing.
- Store expiring sessions through protected cookies.
- Connect registration, login, logout, and viewer-aware feed rendering.
Build password authentication primitives
Authentication identifies the caller through credentials verified on the server. Paceboard keeps ownership authorization separate so a valid login never grants automatic control over another runner's activity.
The implementation uses Zod to validate input. Node.js provides scrypt for password derivation plus a constant-time comparison for verification.
- Switch back to CLAUDE.md in the repository from earlier.
- Replace its contents with the project rules below:
# Product
- Paceboard is Project 2 in a progressive Strava-inspired application series.
- Preserve the tested Project 1 feed and evolve it through existing boundaries.
- Project 3 will add GPS ingestion, route processing, privacy controls, and interactive maps.
# Architecture
- `app/page.tsx` is the server composition boundary.
- UI code depends on `Activity`, not Prisma records.
- Server code depends on `ActivityRepository`, not directly on fixtures.
- Authentication derives identity from a database-backed cookie session.
- Authorization belongs in the data layer and must include ownership predicates.
- Average pace is derived from stored distance and elapsed time.
- Kudos uniqueness is enforced by the database and API retries must be idempotent.
# Quality gates
- Keep TypeScript strict and do not introduce `any`.
- Validate all Server Action input with Zod.
- Re-check authentication and authorization inside every mutation.
- Run `npm test` against the real test database.
- Run `npm run build` before reporting completion.
- Keep the Mermaid architecture and documented limitations current.
Why These Rules Matter
- The architecture rules preserve the repository boundary that already protects the feed from storage changes.
- The identity rule makes the database session the only source of the current viewer.
- The authorization rule keeps ownership checks beside protected mutations.
- Save CLAUDE.md.
- Confirm its Architecture section now names database-backed cookie identity plus ownership authorization.
Did the Project Rules Disappear?
Check that you edited the repository-level CLAUDE.md. A similarly named file in another folder does not define the same project guidance.
Ask for help if the file location is unclear. Help me locate and verify the repository-level CLAUDE.md file without changing other files.
A password hash stores a derived value plus a unique random salt. Verification derives a candidate key with the stored parameters before comparing equal-length buffers.
- Select the lib folder in the VS Code Explorer sidebar.
- Click the new file icon in the Explorer toolbar.
- Create password.ts inside lib.
- Add the password derivation plus verification implementation below:
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
const KEY_LENGTH = 64;
const N = 32768;
const R = 8;
const P = 3;
const MAX_MEMORY = 64 * 1024 * 1024;
function deriveKey(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, KEY_LENGTH, { N, r: R, p: P, maxmem: MAX_MEMORY }, (error, key) => error ? reject(error) : resolve(key));
});
}
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const key = await deriveKey(password, salt);
return ["scrypt", N, R, P, salt.toString("hex"), key.toString("hex")].join("$");
}
export async function verifyPassword(password: string, encoded: string): Promise<boolean> {
const [algorithm, n, r, p, saltHex, keyHex] = encoded.split("$");
if (algorithm !== "scrypt" || Number(n) !== N || Number(r) !== R || Number(p) !== P || !saltHex || !keyHex) return false;
const expected = Buffer.from(keyHex, "hex");
const actual = await deriveKey(password, Buffer.from(saltHex, "hex"));
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
How Password Protection Works
- The constants pin the derivation cost plus output length so hashing and verification use the same settings.
- The random salt gives identical passwords different stored values.
- The encoded value retains the algorithm metadata needed for later verification.
- The equal-length check protects timingSafeEqual from invalid buffer lengths.
- Save lib/password.ts.
- Inspect your editor's diagnostics panel for password.ts. It should show no TypeScript errors.
Seeing Crypto Type Errors?
Check that the import comes from node:crypto. Confirm every constant name matches the values passed into scrypt.
Use focused help if the callback types still fail. Help me compare my password helper with the expected Node.js crypto types.
The authentication service turns untrusted form values into normalized data. Every failed login returns the same public credential result so callers cannot distinguish an unknown account from a wrong password.
- Select the lib folder in the Explorer sidebar.
- Click the new file icon in the Explorer toolbar.
- Create auth-service.ts inside lib.
- Add the registration plus login service below:
import * as z from "zod";
import { prisma } from "@/lib/prisma";
import { hashPassword, verifyPassword } from "@/lib/password";
const emailSchema = z.string().trim().toLowerCase().pipe(z.email());
const passwordSchema = z.string().min(12).max(128);
const registrationSchema = z.object({ name: z.string().trim().min(2).max(80), email: emailSchema, password: passwordSchema });
const loginSchema = z.object({ email: emailSchema, password: passwordSchema });
export type AuthResult = { readonly ok: true; readonly user: { readonly id: string; readonly name: string } } | { readonly ok: false; readonly reason: "invalid" | "unavailable" | "credentials" | "database_error" };
export async function registerUser(input: unknown): Promise<AuthResult> {
const parsed = registrationSchema.safeParse(input);
if (!parsed.success) return { ok: false, reason: "invalid" };
try {
if (await prisma.user.findUnique({ where: { email: parsed.data.email }, select: { id: true } })) return { ok: false, reason: "unavailable" };
const user = await prisma.user.create({ data: { name: parsed.data.name, email: parsed.data.email, passwordHash: await hashPassword(parsed.data.password) }, select: { id: true, name: true } });
return { ok: true, user };
} catch { return { ok: false, reason: "database_error" }; }
}
export async function authenticateUser(input: unknown): Promise<AuthResult> {
const parsed = loginSchema.safeParse(input);
if (!parsed.success) return { ok: false, reason: "credentials" };
try {
const user = await prisma.user.findUnique({ where: { email: parsed.data.email } });
if (!user) { await hashPassword(parsed.data.password); return { ok: false, reason: "credentials" }; }
return await verifyPassword(parsed.data.password, user.passwordHash)
? { ok: true, user: { id: user.id, name: user.name } }
: { ok: false, reason: "credentials" };
} catch { return { ok: false, reason: "database_error" }; }
}
What Does the Authentication Service Do?
- The schemas trim names plus email addresses before checking their allowed formats.
- Registration checks email availability before storing the salted password hash.
- Login performs password work even when the account lookup returns no user.
- The AuthResult union keeps public outcomes structured for the Server Actions.
- Save lib/auth-service.ts.
- Inspect your editor's diagnostics panel for auth-service.ts. It should show no TypeScript errors.
Seeing Zod or Prisma Errors?
Confirm the Zod import uses import * as z from "zod". Check that database calls use the existing prisma singleton from lib/prisma.ts.
Ask for a boundary-focused review if the result types drift. Help me diagnose my authentication service without exposing account existence or bypassing Zod validation.
Store and validate database sessions
A raw session token acts like a temporary credential. Paceboard sends that token only through a protected cookie while PostgreSQL stores its SHA-256 verifier plus an absolute expiration.
A stolen session table therefore contains no raw browser tokens. Every protected lookup verifies the presented token before returning a user.
- Select the lib folder in the Explorer sidebar.
- Click the new file icon in the Explorer toolbar.
- Create session.ts inside lib.
- Add the token generation plus database lookup helpers below:
import { createHash, randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
function sessionCookieName(): string { return process.env.NODE_ENV === "production" ? "__Host-paceboard_session" : "paceboard_session"; }
function tokenVerifier(token: string): string { return createHash("sha256").update(token).digest("hex"); }
export async function startSession(userId: string) {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await prisma.session.create({ data: { id: tokenVerifier(token), userId, expiresAt } });
return { token, expiresAt };
}
export async function getSessionUser(token: string | null) {
if (!token) return null;
const session = await prisma.session.findUnique({ where: { id: tokenVerifier(token) }, include: { user: { select: { id: true, name: true } } } });
if (!session) return null;
if (session.expiresAt <= new Date()) { await prisma.session.deleteMany({ where: { id: session.id } }); return null; }
return session.user;
}
How Server-Side Session Lookup Works
- The raw random token returns to the caller for cookie storage.
- The database receives only the deterministic token verifier.
- The expiration check deletes a stale session before returning null.
- The valid lookup returns only the user identity needed by server composition.
- Save lib/session.ts.
- Inspect your editor's diagnostics panel for the session database helpers. They should show no TypeScript errors.
Session Lookup Failing Type Checks?
Check that tokenVerifier returns a hexadecimal string. Confirm the Prisma query includes the user's id plus name.
Use focused help if the database result shape differs. Help me preserve the session verifier and expiration checks while fixing my types.
Cookie helpers connect the verified session record to the browser. The cookie uses HttpOnly plus SameSite=Lax for every environment. Production also enables Secure plus the __Host- prefix.
- Insert the cookie import directly below the node:crypto import:
import { cookies } from "next/headers";
Why Import Cookies Here?
The async cookie API reads the incoming token during server rendering. It also writes or deletes the token from Server Actions.
- Append the browser cookie plus logout helpers to the end of lib/session.ts:
export async function setSessionCookie(token: string, expiresAt: Date): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(sessionCookieName(), token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", expires: expiresAt });
}
export async function getCurrentViewer() {
const cookieStore = await cookies();
return getSessionUser(cookieStore.get(sessionCookieName())?.value ?? null);
}
export async function destroyCurrentSession(): Promise<void> {
const cookieStore = await cookies();
const token = cookieStore.get(sessionCookieName())?.value ?? null;
if (token) await prisma.session.deleteMany({ where: { id: tokenVerifier(token) } });
cookieStore.delete(sessionCookieName());
}
export async function destroySession(token: string): Promise<void> {
await prisma.session.deleteMany({ where: { id: tokenVerifier(token) } });
}
How Cookie Sessions End
- The setter applies the expiration from the matching database row.
- The current-viewer helper converts the incoming cookie into a verified user.
- Logout deletes the database row before removing the browser cookie.
- The token-specific destroy helper supports session verification outside the browser flow.
- Save lib/session.ts.
- Inspect your editor's diagnostics panel for the complete file. It should show no TypeScript errors.
Cookie Helpers Showing Errors?
Confirm cookies is awaited before calling get, set, or delete. Check that the cookie path remains /.
Ask for help without weakening the cookie flags. Help me fix the session cookie helpers while preserving every security attribute.
Connect authentication actions and pages
Paceboard uses Next.js Server Actions as thin form entry points. Each action delegates credential work to the authentication service before creating or destroying a session.
The server page remains the composition boundary. It derives the viewer from getCurrentViewer() before passing only a validated user ID into the repository.
- Select the app folder in the Explorer sidebar.
- Create an actions folder inside app with the new folder icon.
- Create auth.ts inside app/actions with the new file icon.
- Add the registration, login, and logout actions below:
"use server";
import { redirect } from "next/navigation";
import { authenticateUser, registerUser } from "@/lib/auth-service";
import { destroyCurrentSession, setSessionCookie, startSession } from "@/lib/session";
export async function registerAction(formData: FormData): Promise<never> {
const result = await registerUser({ name: formData.get("name"), email: formData.get("email"), password: formData.get("password") });
if (!result.ok) redirect(`/register?error=${result.reason}`);
const session = await startSession(result.user.id);
await setSessionCookie(session.token, session.expiresAt);
redirect("/");
}
export async function loginAction(formData: FormData): Promise<never> {
const result = await authenticateUser({ email: formData.get("email"), password: formData.get("password") });
if (!result.ok) redirect("/login?error=credentials");
const session = await startSession(result.user.id);
await setSessionCookie(session.token, session.expiresAt);
redirect("/");
}
export async function logoutAction(): Promise<never> { await destroyCurrentSession(); redirect("/login"); }
Why Keep the Actions Thin?
- The actions translate form values into service inputs.
- Successful authentication creates the session before setting its cookie.
- Failed login attempts always redirect with the same public credential reason.
- Logout delegates database plus cookie cleanup to one session helper.
- Save app/actions/auth.ts.
- Inspect your editor's diagnostics panel for auth.ts. It should show no TypeScript errors.
Actions Not Recognized as Server Functions?
Confirm "use server" is the first line of app/actions/auth.ts. Check that each exported action accepts the same values submitted by its form.
Ask for help if redirects or return types fail. Help me fix my authentication Server Actions without moving security logic into the forms.
The login page displays one public error for every credential failure. That message protects account privacy while giving the runner enough information to try again.
- Create a login folder inside app with the new folder icon.
- Create page.tsx inside app/login with the new file icon.
- Add the login form below:
import { loginAction } from "@/app/actions/auth";
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
return <main className="grid min-h-screen place-items-center bg-slate-100 p-6"><form action={loginAction} className="grid w-full max-w-md gap-4 rounded-3xl bg-white p-6 shadow-sm"><h1 className="text-3xl font-black">Log in</h1>{error ? <p role="alert" className="text-red-700">Invalid email or password.</p> : null}<input className="rounded-xl border p-3" name="email" type="email" required /><input className="rounded-xl border p-3" name="password" type="password" minLength={12} required /><button className="rounded-full bg-orange-600 p-3 font-bold text-white">Log in</button><a href="/register">Create an account</a></form></main>;
}
How the Login Page Handles Failure
- The form sends its fields directly to loginAction.
- The page reads only the public error indicator from the URL.
- The alert reveals no account lookup details.
- Save app/login/page.tsx.
- Inspect your editor's diagnostics panel for the login page. It should show no TypeScript errors.
Login Form Not Compiling?
Check that the form action references loginAction. Confirm the two input names remain email plus password.
Use focused help if the page props fail. Help me fix the login page while preserving the generic public error.
Registration uses the same server boundary. A successful submission creates the user before immediately establishing the first database session.
- Create a register folder inside app with the new folder icon.
- Create page.tsx inside app/register with the new file icon.
- Add the registration form below:
import { registerAction } from "@/app/actions/auth";
export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
return <main className="grid min-h-screen place-items-center bg-slate-100 p-6"><form action={registerAction} className="grid w-full max-w-md gap-4 rounded-3xl bg-white p-6 shadow-sm"><h1 className="text-3xl font-black">Register</h1>{error ? <p role="alert" className="text-red-700">Registration failed. Check the fields or use another email.</p> : null}<input className="rounded-xl border p-3" name="name" placeholder="Name" required /><input className="rounded-xl border p-3" name="email" type="email" required /><input className="rounded-xl border p-3" name="password" type="password" minLength={12} required /><button className="rounded-full bg-orange-600 p-3 font-bold text-white">Create account</button><a href="/login">Already registered?</a></form></main>;
}
How Registration Establishes Identity
- The field names match the values read by registerAction.
- The minimum password length provides early browser feedback before server validation.
- The public error covers invalid fields plus unavailable email addresses.
- Save app/register/page.tsx.
- Inspect your editor's diagnostics panel for the registration page. It should show no TypeScript errors.
Registration Form Not Compiling?
Confirm the form action references registerAction. Check that the three input names remain name, email, plus password.
Ask for help if the route does not load. Help me fix the registration page without weakening server validation.
The home page already owns server composition for the feed. Its existing shell plus repository dependency remain unchanged while viewer identity becomes session-derived.
- Switch back to app/page.tsx from the previous step.
- Add an import for logoutAction from @/app/actions/auth.
- Add an import for getCurrentViewer from @/lib/session.
- Derive viewer by awaiting getCurrentViewer() at the start of Home.
- Replace listForViewer(null) with listForViewer(viewer?.id ?? null).
- Render the signed-in viewer name plus a form that invokes logoutAction when viewer exists.
- Render the existing Log in plus Register links when viewer is null.
- Pass viewer !== null to the authenticated prop on ActivityFeed.
- Save app/page.tsx.
- Inspect your editor's diagnostics panel for the home page. It should show no TypeScript errors.
Home Page Losing the Feed?
Keep activityRepository as the page's data source. Confirm only viewer?.id ?? null crosses into listForViewer.
Ask for a composition review if the original feed disappears. Help me connect getCurrentViewer to app/page.tsx without importing Prisma or rebuilding ActivityFeed.
Choose the second tab if you want to compare every new authentication file with the finished version.
✔️ Awesome, I've got everything!
Great. Save every authentication file before testing the complete browser flow.
ⓧ I'd like to double check the full code
Compare these complete files with your project. Each one should match exactly.
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
const KEY_LENGTH = 64;
const N = 32768;
const R = 8;
const P = 3;
const MAX_MEMORY = 64 * 1024 * 1024;
function deriveKey(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, KEY_LENGTH, { N, r: R, p: P, maxmem: MAX_MEMORY }, (error, key) => error ? reject(error) : resolve(key));
});
}
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const key = await deriveKey(password, salt);
return ["scrypt", N, R, P, salt.toString("hex"), key.toString("hex")].join("$");
}
export async function verifyPassword(password: string, encoded: string): Promise<boolean> {
const [algorithm, n, r, p, saltHex, keyHex] = encoded.split("$");
if (algorithm !== "scrypt" || Number(n) !== N || Number(r) !== R || Number(p) !== P || !saltHex || !keyHex) return false;
const expected = Buffer.from(keyHex, "hex");
const actual = await deriveKey(password, Buffer.from(saltHex, "hex"));
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
Password File Check
This file owns salt generation, password derivation, encoded hash storage, plus constant-time verification.
import * as z from "zod";
import { prisma } from "@/lib/prisma";
import { hashPassword, verifyPassword } from "@/lib/password";
const emailSchema = z.string().trim().toLowerCase().pipe(z.email());
const passwordSchema = z.string().min(12).max(128);
const registrationSchema = z.object({ name: z.string().trim().min(2).max(80), email: emailSchema, password: passwordSchema });
const loginSchema = z.object({ email: emailSchema, password: passwordSchema });
export type AuthResult = { readonly ok: true; readonly user: { readonly id: string; readonly name: string } } | { readonly ok: false; readonly reason: "invalid" | "unavailable" | "credentials" | "database_error" };
export async function registerUser(input: unknown): Promise<AuthResult> {
const parsed = registrationSchema.safeParse(input);
if (!parsed.success) return { ok: false, reason: "invalid" };
try {
if (await prisma.user.findUnique({ where: { email: parsed.data.email }, select: { id: true } })) return { ok: false, reason: "unavailable" };
const user = await prisma.user.create({ data: { name: parsed.data.name, email: parsed.data.email, passwordHash: await hashPassword(parsed.data.password) }, select: { id: true, name: true } });
return { ok: true, user };
} catch { return { ok: false, reason: "database_error" }; }
}
export async function authenticateUser(input: unknown): Promise<AuthResult> {
const parsed = loginSchema.safeParse(input);
if (!parsed.success) return { ok: false, reason: "credentials" };
try {
const user = await prisma.user.findUnique({ where: { email: parsed.data.email } });
if (!user) { await hashPassword(parsed.data.password); return { ok: false, reason: "credentials" }; }
return await verifyPassword(parsed.data.password, user.passwordHash)
? { ok: true, user: { id: user.id, name: user.name } }
: { ok: false, reason: "credentials" };
} catch { return { ok: false, reason: "database_error" }; }
}
Authentication Service Check
This file validates inputs before handling registration or credential verification.
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
function sessionCookieName(): string { return process.env.NODE_ENV === "production" ? "__Host-paceboard_session" : "paceboard_session"; }
function tokenVerifier(token: string): string { return createHash("sha256").update(token).digest("hex"); }
export async function startSession(userId: string) {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await prisma.session.create({ data: { id: tokenVerifier(token), userId, expiresAt } });
return { token, expiresAt };
}
export async function getSessionUser(token: string | null) {
if (!token) return null;
const session = await prisma.session.findUnique({ where: { id: tokenVerifier(token) }, include: { user: { select: { id: true, name: true } } } });
if (!session) return null;
if (session.expiresAt <= new Date()) { await prisma.session.deleteMany({ where: { id: session.id } }); return null; }
return session.user;
}
export async function setSessionCookie(token: string, expiresAt: Date): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(sessionCookieName(), token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", expires: expiresAt });
}
export async function getCurrentViewer() {
const cookieStore = await cookies();
return getSessionUser(cookieStore.get(sessionCookieName())?.value ?? null);
}
export async function destroyCurrentSession(): Promise<void> {
const cookieStore = await cookies();
const token = cookieStore.get(sessionCookieName())?.value ?? null;
if (token) await prisma.session.deleteMany({ where: { id: tokenVerifier(token) } });
cookieStore.delete(sessionCookieName());
}
export async function destroySession(token: string): Promise<void> {
await prisma.session.deleteMany({ where: { id: tokenVerifier(token) } });
}
Session File Check
This file joins random token generation, verifier storage, expiration checks, protected cookies, plus logout cleanup.
"use server";
import { redirect } from "next/navigation";
import { authenticateUser, registerUser } from "@/lib/auth-service";
import { destroyCurrentSession, setSessionCookie, startSession } from "@/lib/session";
export async function registerAction(formData: FormData): Promise<never> {
const result = await registerUser({ name: formData.get("name"), email: formData.get("email"), password: formData.get("password") });
if (!result.ok) redirect(`/register?error=${result.reason}`);
const session = await startSession(result.user.id);
await setSessionCookie(session.token, session.expiresAt);
redirect("/");
}
export async function loginAction(formData: FormData): Promise<never> {
const result = await authenticateUser({ email: formData.get("email"), password: formData.get("password") });
if (!result.ok) redirect("/login?error=credentials");
const session = await startSession(result.user.id);
await setSessionCookie(session.token, session.expiresAt);
redirect("/");
}
export async function logoutAction(): Promise<never> { await destroyCurrentSession(); redirect("/login"); }
Server Actions Check
This file keeps form handling thin while delegating security work to the service layer.
import { loginAction } from "@/app/actions/auth";
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
return <main className="grid min-h-screen place-items-center bg-slate-100 p-6"><form action={loginAction} className="grid w-full max-w-md gap-4 rounded-3xl bg-white p-6 shadow-sm"><h1 className="text-3xl font-black">Log in</h1>{error ? <p role="alert" className="text-red-700">Invalid email or password.</p> : null}<input className="rounded-xl border p-3" name="email" type="email" required /><input className="rounded-xl border p-3" name="password" type="password" minLength={12} required /><button className="rounded-full bg-orange-600 p-3 font-bold text-white">Log in</button><a href="/register">Create an account</a></form></main>;
}
Login Page Check
This page submits credentials through the Server Action while exposing one generic failure message.
import { registerAction } from "@/app/actions/auth";
export default async function RegisterPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
return <main className="grid min-h-screen place-items-center bg-slate-100 p-6"><form action={registerAction} className="grid w-full max-w-md gap-4 rounded-3xl bg-white p-6 shadow-sm"><h1 className="text-3xl font-black">Register</h1>{error ? <p role="alert" className="text-red-700">Registration failed. Check the fields or use another email.</p> : null}<input className="rounded-xl border p-3" name="name" placeholder="Name" required /><input className="rounded-xl border p-3" name="email" type="email" required /><input className="rounded-xl border p-3" name="password" type="password" minLength={12} required /><button className="rounded-full bg-orange-600 p-3 font-bold text-white">Create account</button><a href="/login">Already registered?</a></form></main>;
}
Registration Page Check
This page submits the three registration fields through the shared server boundary.
These credentials stay inside your local Paceboard database. Use test values that you can safely enter during the browser verification.
Before you test the two login attempts, do you expect the incorrect password to restore the same viewer as the correct password?
- Return to the Paceboard browser tab from the previous step.
- Click Register in the page header.
- Enter Alex Runner in the Name field.
- Enter alex@example.com in the email field.
- Enter correct-horse-12 in the password field.
- Click Create account.
You should return to Paceboard with Signed in as Alex Runner in the header. Registration has created both the user and the first database session.
- Click Log out in the page header.
- Enter alex@example.com in the login email field.
- Enter wrong-password-12 in the login password field.
- Click Log in.
You should remain logged out with Invalid email or password. displayed. The failed credential check creates no authenticated viewer.
- Replace the incorrect password with correct-horse-12.
- Click Log in again.
You should return to the feed as Alex Runner. That is the trust boundary working from form input through the database session to server-rendered viewer state.
- Open your browser's developer console.
- Evaluate document.cookie.
The Paceboard session cookie should be absent from the result. Its HttpOnly attribute blocks browser JavaScript from reading the raw token.
Registration or Login Not Working?
- Confirm the local PostgreSQL container from the earlier step remains healthy.
- Confirm the development process reloaded after you saved the new routes.
- Check the terminal for the first file path named in the error.
Use the complete browser sequence when asking for help. Help me trace my Paceboard registration, session creation, logout, and login flow.
Your feed now receives a server-validated viewer from an expiring database session. Next, you'll use that identity to create activities while enforcing ownership on every update and deletion.
Manage Owned Activities
Paceboard can now identify each signed-in viewer through authentication. Activity mutations introduce a separate question. Authorization decides whether that viewer owns the row they want to change.
In this step, you will validate activity input on the server. You will enforce ownership inside PostgreSQL mutation predicates before reusing the existing feed for create, edit, and delete operations.
In this step, get ready to:
- Validate activity fields before any database write.
- Add authenticated create, update, and delete Server Actions.
- Prove that only an activity owner can edit or delete its row.
Guard activity mutations at the database boundary
Browser forms can send missing fields, altered values, or another activity's stable ID. Zod checks the submitted fields while ownership predicates keep the final decision beside the database write.
- Return to the Paceboard repository in VS Code.
- Add the following ownership rules under the architecture guidance in CLAUDE.md:
- Authorization belongs in the data layer and must include ownership predicates.
- Average pace is derived from stored distance and elapsed time.
Why These Rules Matter
- The ownership rule keeps authorization inside the data layer even when a caller bypasses the visible controls.
- The pace rule prevents a browser from submitting a convenient but untrusted calculated value.
- Add the following checks under the quality gates in CLAUDE.md:
- Validate all Server Action input with Zod.
- Re-check authentication and authorization inside every mutation.
What Do These Checks Protect?
Every mutation treats its caller and payload as untrusted. The application validates both again at the server boundary before it changes a row.
- Save CLAUDE.md.
- Confirm the four new rules remain visible under the architecture and quality sections.
Rules Added in the Wrong Section?
Keep ownership and derived pace under architecture. Keep validation and mutation checks under quality gates.
Ask for help reviewing the placement without changing the rules: help me organize the new Paceboard rules in CLAUDE.md
The activity service converts form-shaped input into database-shaped values. It also returns structured outcomes so the Server Actions can stay thin.
- Select the lib folder in the VS Code Explorer sidebar.
- Click the new file icon in the Explorer sidebar.
- Create activity-service.ts inside the lib folder.
- Build the activity parser by pasting this first section into lib/activity-service.ts:
import * as z from "zod";
import { prisma } from "@/lib/prisma";
const activitySchema = z.object({
title: z.string().trim().min(1).max(100),
activityType: z.enum(["RUN", "RIDE", "WALK"]),
distanceMiles: z.coerce.number().min(0.01).max(1000),
durationSeconds: z.coerce.number().int().min(1).max(604800),
elevationGainFeet: z.coerce.number().int().min(0).max(100000),
recordedAt: z.string().min(1),
});
export type MutationResult = { readonly ok: true; readonly id: string } | { readonly ok: false; readonly reason: "unauthorized" | "forbidden" | "invalid" | "database_error" };
function parseActivity(input: unknown) {
const parsed = activitySchema.safeParse(input);
if (!parsed.success) return null;
const recordedAt = new Date(parsed.data.recordedAt);
if (Number.isNaN(recordedAt.getTime())) return null;
return { title: parsed.data.title, type: parsed.data.activityType, distanceHundredthsMiles: Math.round(parsed.data.distanceMiles * 100), elapsedSeconds: parsed.data.durationSeconds, elevationGainFeet: parsed.data.elevationGainFeet, recordedAt };
}
What Does This Parser Enforce?
- The schema accepts only RUN, RIDE, or WALK as the activity type.
- The numeric rules reject zero distance, zero duration, negative elevation, and configured out-of-range values.
- The parser converts miles into integer hundredths with Math.round(distanceMiles * 100).
- The parser constructs the timestamp on the server and rejects an invalid date before any write.
- Add the create, update, and delete operations directly below parseActivity() by pasting this code:
export async function createActivity(userId: string | null, input: unknown): Promise<MutationResult> {
if (!userId) return { ok: false, reason: "unauthorized" };
const data = parseActivity(input);
if (!data) return { ok: false, reason: "invalid" };
try { const activity = await prisma.activity.create({ data: { ...data, ownerId: userId }, select: { id: true } }); return { ok: true, id: activity.id }; }
catch { return { ok: false, reason: "database_error" }; }
}
export async function updateActivity(userId: string | null, activityId: string, input: unknown): Promise<MutationResult> {
if (!userId) return { ok: false, reason: "unauthorized" };
const data = parseActivity(input);
if (!data || !activityId) return { ok: false, reason: "invalid" };
try { const result = await prisma.activity.updateMany({ where: { id: activityId, ownerId: userId }, data }); return result.count === 1 ? { ok: true, id: activityId } : { ok: false, reason: "forbidden" }; }
catch { return { ok: false, reason: "database_error" }; }
}
export async function deleteActivity(userId: string | null, activityId: string): Promise<MutationResult> {
if (!userId) return { ok: false, reason: "unauthorized" };
try { const result = await prisma.activity.deleteMany({ where: { id: activityId, ownerId: userId } }); return result.count === 1 ? { ok: true, id: activityId } : { ok: false, reason: "forbidden" }; }
catch { return { ok: false, reason: "database_error" }; }
}
How Is Ownership Enforced?
- The create operation requires a validated viewer ID before attaching that identity as ownerId.
- The update predicate contains both activityId and the authenticated ownerId.
- The delete predicate applies the same two-part ownership boundary.
- A zero-row update or delete returns forbidden without revealing whether another user's stable ID exists.
- Save lib/activity-service.ts.
- Confirm the file contains createActivity(), updateActivity(), and deleteActivity().
Seeing TypeScript Errors in the Service?
Check that the parser section appears above the exported mutation functions. Confirm the import path remains @/lib/prisma.
Use this prompt for a focused comparison: help me compare activity-service.ts with the required Zod parser and ownership predicates
Wire authenticated forms into the feed
The service owns validation and authorization. The form actions now translate browser FormData into service input while deriving identity only from getCurrentViewer().
- Select the app/actions folder in the Explorer sidebar.
- Click the new file icon in the Explorer sidebar.
- Create activities.ts inside app/actions.
- Add the authenticated mutation actions by pasting this code into app/actions/activities.ts:
"use server";
import { revalidatePath } from "next/cache";
import { createActivity, deleteActivity, updateActivity } from "@/lib/activity-service";
import { getCurrentViewer } from "@/lib/session";
function activityInput(formData: FormData) {
return { title: formData.get("title"), activityType: formData.get("activityType"), distanceMiles: formData.get("distanceMiles"), durationSeconds: formData.get("durationSeconds"), elevationGainFeet: formData.get("elevationGainFeet"), recordedAt: formData.get("recordedAt") };
}
export async function createActivityAction(formData: FormData): Promise<void> {
const result = await createActivity((await getCurrentViewer())?.id ?? null, activityInput(formData));
if (!result.ok) throw new Error(result.reason);
revalidatePath("/");
}
export async function updateActivityAction(formData: FormData): Promise<void> {
const result = await updateActivity((await getCurrentViewer())?.id ?? null, String(formData.get("activityId") ?? ""), activityInput(formData));
if (!result.ok) throw new Error(result.reason);
revalidatePath("/");
}
export async function deleteActivityAction(formData: FormData): Promise<void> {
const result = await deleteActivity((await getCurrentViewer())?.id ?? null, String(formData.get("activityId") ?? ""));
if (!result.ok) throw new Error(result.reason);
revalidatePath("/");
}
Why Keep the Actions Thin?
- Each action resolves the current viewer from the protected database session.
- The actions delegate validation and ownership decisions to the activity service.
- A successful write calls revalidatePath("/") so the server-rendered feed reads the latest PostgreSQL state.
- Save app/actions/activities.ts.
- Confirm activities.ts appears under app/actions in the Explorer sidebar.
- Select the components folder in the Explorer sidebar.
- Click the new file icon in the Explorer sidebar.
- Create activity-form.tsx inside the components folder.
- Build the create form by pasting this code into components/activity-form.tsx:
import { createActivityAction } from "@/app/actions/activities";
export function ActivityForm() {
return <form action={createActivityAction} className="grid gap-3 rounded-3xl bg-white p-5 shadow-sm sm:grid-cols-2">
<h2 className="text-xl font-black sm:col-span-2">Log an activity</h2>
<input className="rounded-xl border p-3" name="title" placeholder="Title" required />
<select className="rounded-xl border p-3" name="activityType" defaultValue="RUN"><option value="RUN">Run</option><option value="RIDE">Ride</option><option value="WALK">Walk</option></select>
<input className="rounded-xl border p-3" name="distanceMiles" type="number" min="0.01" step="0.01" placeholder="Distance in miles" required />
<input className="rounded-xl border p-3" name="durationSeconds" type="number" min="1" step="1" placeholder="Duration in seconds" required />
<input className="rounded-xl border p-3" name="elevationGainFeet" type="number" min="0" step="1" placeholder="Elevation gain in feet" required />
<input className="rounded-xl border p-3" name="recordedAt" type="datetime-local" required />
<button className="rounded-full bg-orange-600 px-5 py-3 font-bold text-white sm:col-span-2" type="submit">Create activity</button>
</form>;
}
What Does the Form Submit?
The form submits title, type, distance, duration, elevation, and timestamp. It has no pace field because the repository derives pace from stored distance and elapsed time.
- Save components/activity-form.tsx.
- Confirm activity-form.tsx appears under components in the Explorer sidebar.
- Replace the contents of app/page.tsx with this server composition:
import { logoutAction } from "@/app/actions/auth";
import { ActivityFeed } from "@/components/activity-feed";
import { ActivityForm } from "@/components/activity-form";
import { activityRepository } from "@/data/activity-repository";
import { getCurrentViewer } from "@/lib/session";
export default async function Home() {
const viewer = await getCurrentViewer();
const activities = await activityRepository.listForViewer(viewer?.id ?? null);
return <main className="min-h-screen bg-slate-100 px-4 py-8 text-slate-950 sm:px-6"><div className="mx-auto max-w-5xl space-y-6"><header className="flex flex-wrap items-center justify-between gap-4"><div><p className="text-xs font-black uppercase tracking-widest text-orange-700">Community activity</p><h1 className="text-4xl font-black">Paceboard</h1></div>{viewer ? <form action={logoutAction} className="flex items-center gap-3"><span>Signed in as {viewer.name}</span><button className="font-bold text-orange-700">Log out</button></form> : <nav className="flex gap-4"><a href="/login">Log in</a><a className="font-bold text-orange-700" href="/register">Register</a></nav>}</header>{viewer ? <ActivityForm /> : null}<ActivityFeed activities={activities} authenticated={viewer !== null} /></div></main>;
}
What Changed in the Page?
The page still gets its viewer from getCurrentViewer() and reads activities through ActivityRepository. Signed-in viewers now receive the create form above the preserved feed.
- Save app/page.tsx.
Before you start the app, do you expect the activity form to appear for a signed-out viewer or only for a server-validated viewer?
- Start the Paceboard development server by running:
npm run dev
What Does This Check Prove?
The development server compiles the new page, form, action module, and activity service together. Keep this terminal running while you test the browser flow.
- Open http://localhost:3000 in your browser.
- Log in with the local Paceboard account from the previous step.
You should see the existing Paceboard shell with a Log an activity form above the feed. The form appears because the page received a server-validated viewer.
Form Missing or Page Failing to Compile?
- Confirm app/page.tsx imports ActivityForm from @/components/activity-form.
- Confirm components/activity-form.tsx imports createActivityAction from @/app/actions/activities.
Use this prompt for the import chain: help me trace why ActivityForm is missing from the Paceboard home page
Owner controls belong inside each existing activity card. The server-derived ownedByViewer value decides whether the edit and delete forms render.
- Add this import above the existing component imports in components/activity-card.tsx:
import { deleteActivityAction, updateActivityAction } from "@/app/actions/activities";
What Does This Import Connect?
The card posts owner edits and deletes to the authenticated actions. Those actions resolve the current viewer again before calling the ownership-aware service.
- Find the closing </article> element in components/activity-card.tsx.
- Add this owner-only block immediately before that closing element:
{activity.ownedByViewer ? <details className="mt-4 border-t pt-4"><summary className="cursor-pointer font-bold">Edit activity</summary><form action={updateActivityAction} className="mt-3 grid gap-2 sm:grid-cols-2"><input type="hidden" name="activityId" value={activity.id} /><input className="rounded-xl border p-2" name="title" defaultValue={activity.title} required /><select className="rounded-xl border p-2" name="activityType" defaultValue={activity.sport.toUpperCase()}><option value="RUN">Run</option><option value="RIDE">Ride</option><option value="WALK">Walk</option></select><input className="rounded-xl border p-2" name="distanceMiles" type="number" step="0.01" min="0.01" defaultValue={activity.metrics.distanceMiles} required /><input className="rounded-xl border p-2" name="durationSeconds" type="number" min="1" defaultValue={activity.metrics.elapsedSeconds} required /><input className="rounded-xl border p-2" name="elevationGainFeet" type="number" min="0" defaultValue={activity.metrics.elevationGainFeet} required /><input className="rounded-xl border p-2" name="recordedAt" type="datetime-local" defaultValue={activity.recordedAtIso.slice(0, 16)} required /><button className="rounded-full bg-teal-700 px-4 py-2 font-bold text-white" type="submit">Save changes</button></form><form action={deleteActivityAction} className="mt-3"><input type="hidden" name="activityId" value={activity.id} /><button className="font-bold text-red-700" type="submit">Delete activity</button></form></details> : null}
How Do the Owner Controls Work?
- The conditional renders the controls only when the repository marks the activity as owned by the current viewer.
- The hidden activityId identifies the target row without granting permission to change it.
- The service combines that stable ID with the authenticated owner ID in the update or delete predicate.
- Save components/activity-card.tsx.
- Refresh the Paceboard page.
The create form remains above the feed. Existing activities owned by the current viewer now expose an Edit activity control.
Owner Controls Not Appearing?
Confirm the repository maps ownedByViewer by comparing the authenticated viewer ID with the activity owner ID. The card must read that server-derived value from its existing activity prop.
Ask for a focused ownership trace: help me trace ownedByViewer from ActivityRepository to ActivityCard
Use the comparison tab before testing the complete mutation path.
✔️ Awesome, I've got everything!
Your activity service, authenticated actions, create form, page composition, and card ownership controls are saved.
ⓧ I'd like to double check the full code
Compare the complete new service with lib/activity-service.ts.
import * as z from "zod";
import { prisma } from "@/lib/prisma";
const activitySchema = z.object({
title: z.string().trim().min(1).max(100),
activityType: z.enum(["RUN", "RIDE", "WALK"]),
distanceMiles: z.coerce.number().min(0.01).max(1000),
durationSeconds: z.coerce.number().int().min(1).max(604800),
elevationGainFeet: z.coerce.number().int().min(0).max(100000),
recordedAt: z.string().min(1),
});
export type MutationResult = { readonly ok: true; readonly id: string } | { readonly ok: false; readonly reason: "unauthorized" | "forbidden" | "invalid" | "database_error" };
function parseActivity(input: unknown) {
const parsed = activitySchema.safeParse(input);
if (!parsed.success) return null;
const recordedAt = new Date(parsed.data.recordedAt);
if (Number.isNaN(recordedAt.getTime())) return null;
return { title: parsed.data.title, type: parsed.data.activityType, distanceHundredthsMiles: Math.round(parsed.data.distanceMiles * 100), elapsedSeconds: parsed.data.durationSeconds, elevationGainFeet: parsed.data.elevationGainFeet, recordedAt };
}
export async function createActivity(userId: string | null, input: unknown): Promise<MutationResult> {
if (!userId) return { ok: false, reason: "unauthorized" };
const data = parseActivity(input);
if (!data) return { ok: false, reason: "invalid" };
try { const activity = await prisma.activity.create({ data: { ...data, ownerId: userId }, select: { id: true } }); return { ok: true, id: activity.id }; }
catch { return { ok: false, reason: "database_error" }; }
}
export async function updateActivity(userId: string | null, activityId: string, input: unknown): Promise<MutationResult> {
if (!userId) return { ok: false, reason: "unauthorized" };
const data = parseActivity(input);
if (!data || !activityId) return { ok: false, reason: "invalid" };
try { const result = await prisma.activity.updateMany({ where: { id: activityId, ownerId: userId }, data }); return result.count === 1 ? { ok: true, id: activityId } : { ok: false, reason: "forbidden" }; }
catch { return { ok: false, reason: "database_error" }; }
}
export async function deleteActivity(userId: string | null, activityId: string): Promise<MutationResult> {
if (!userId) return { ok: false, reason: "unauthorized" };
try { const result = await prisma.activity.deleteMany({ where: { id: activityId, ownerId: userId } }); return result.count === 1 ? { ok: true, id: activityId } : { ok: false, reason: "forbidden" }; }
catch { return { ok: false, reason: "database_error" }; }
}
Compare the complete action module with app/actions/activities.ts.
"use server";
import { revalidatePath } from "next/cache";
import { createActivity, deleteActivity, updateActivity } from "@/lib/activity-service";
import { getCurrentViewer } from "@/lib/session";
function activityInput(formData: FormData) {
return { title: formData.get("title"), activityType: formData.get("activityType"), distanceMiles: formData.get("distanceMiles"), durationSeconds: formData.get("durationSeconds"), elevationGainFeet: formData.get("elevationGainFeet"), recordedAt: formData.get("recordedAt") };
}
export async function createActivityAction(formData: FormData): Promise<void> {
const result = await createActivity((await getCurrentViewer())?.id ?? null, activityInput(formData));
if (!result.ok) throw new Error(result.reason);
revalidatePath("/");
}
export async function updateActivityAction(formData: FormData): Promise<void> {
const result = await updateActivity((await getCurrentViewer())?.id ?? null, String(formData.get("activityId") ?? ""), activityInput(formData));
if (!result.ok) throw new Error(result.reason);
revalidatePath("/");
}
export async function deleteActivityAction(formData: FormData): Promise<void> {
const result = await deleteActivity((await getCurrentViewer())?.id ?? null, String(formData.get("activityId") ?? ""));
if (!result.ok) throw new Error(result.reason);
revalidatePath("/");
}
Compare the complete create form with components/activity-form.tsx.
import { createActivityAction } from "@/app/actions/activities";
export function ActivityForm() {
return <form action={createActivityAction} className="grid gap-3 rounded-3xl bg-white p-5 shadow-sm sm:grid-cols-2">
<h2 className="text-xl font-black sm:col-span-2">Log an activity</h2>
<input className="rounded-xl border p-3" name="title" placeholder="Title" required />
<select className="rounded-xl border p-3" name="activityType" defaultValue="RUN"><option value="RUN">Run</option><option value="RIDE">Ride</option><option value="WALK">Walk</option></select>
<input className="rounded-xl border p-3" name="distanceMiles" type="number" min="0.01" step="0.01" placeholder="Distance in miles" required />
<input className="rounded-xl border p-3" name="durationSeconds" type="number" min="1" step="1" placeholder="Duration in seconds" required />
<input className="rounded-xl border p-3" name="elevationGainFeet" type="number" min="0" step="1" placeholder="Elevation gain in feet" required />
<input className="rounded-xl border p-3" name="recordedAt" type="datetime-local" required />
<button className="rounded-full bg-orange-600 px-5 py-3 font-bold text-white sm:col-span-2" type="submit">Create activity</button>
</form>;
}
Compare the complete page composition with app/page.tsx.
import { logoutAction } from "@/app/actions/auth";
import { ActivityFeed } from "@/components/activity-feed";
import { ActivityForm } from "@/components/activity-form";
import { activityRepository } from "@/data/activity-repository";
import { getCurrentViewer } from "@/lib/session";
export default async function Home() {
const viewer = await getCurrentViewer();
const activities = await activityRepository.listForViewer(viewer?.id ?? null);
return <main className="min-h-screen bg-slate-100 px-4 py-8 text-slate-950 sm:px-6"><div className="mx-auto max-w-5xl space-y-6"><header className="flex flex-wrap items-center justify-between gap-4"><div><p className="text-xs font-black uppercase tracking-widest text-orange-700">Community activity</p><h1 className="text-4xl font-black">Paceboard</h1></div>{viewer ? <form action={logoutAction} className="flex items-center gap-3"><span>Signed in as {viewer.name}</span><button className="font-bold text-orange-700">Log out</button></form> : <nav className="flex gap-4"><a href="/login">Log in</a><a className="font-bold text-orange-700" href="/register">Register</a></nav>}</header>{viewer ? <ActivityForm /> : null}<ActivityFeed activities={activities} authenticated={viewer !== null} /></div></main>;
}
Confirm components/activity-card.tsx contains this action import.
import { deleteActivityAction, updateActivityAction } from "@/app/actions/activities";
Confirm the card contains this owner-only block immediately before its closing article element.
{activity.ownedByViewer ? <details className="mt-4 border-t pt-4"><summary className="cursor-pointer font-bold">Edit activity</summary><form action={updateActivityAction} className="mt-3 grid gap-2 sm:grid-cols-2"><input type="hidden" name="activityId" value={activity.id} /><input className="rounded-xl border p-2" name="title" defaultValue={activity.title} required /><select className="rounded-xl border p-2" name="activityType" defaultValue={activity.sport.toUpperCase()}><option value="RUN">Run</option><option value="RIDE">Ride</option><option value="WALK">Walk</option></select><input className="rounded-xl border p-2" name="distanceMiles" type="number" step="0.01" min="0.01" defaultValue={activity.metrics.distanceMiles} required /><input className="rounded-xl border p-2" name="durationSeconds" type="number" min="1" defaultValue={activity.metrics.elapsedSeconds} required /><input className="rounded-xl border p-2" name="elevationGainFeet" type="number" min="0" defaultValue={activity.metrics.elevationGainFeet} required /><input className="rounded-xl border p-2" name="recordedAt" type="datetime-local" defaultValue={activity.recordedAtIso.slice(0, 16)} required /><button className="rounded-full bg-teal-700 px-4 py-2 font-bold text-white" type="submit">Save changes</button></form><form action={deleteActivityAction} className="mt-3"><input type="hidden" name="activityId" value={activity.id} /><button className="font-bold text-red-700" type="submit">Delete activity</button></form></details> : null}
Prove ownership with a stale form
A successful owner workflow proves the normal path. A stale form provides a stronger check because it submits the correct stable activity ID after the browser session changes to another user.
- Enter Six Mile Ownership Check in the Title field.
- Select Run as the activity type.
- Enter 6.00 in the distance field.
- Enter 3600 in the duration field.
- Enter 120 in the elevation field.
- Enter 2026-10-26T10:00 in the timestamp field.
- Click Create activity.
You should see the new card in the existing feed with a derived pace of 10:00 /mi. That completes the full path from validated form input to a persistent PostgreSQL row.
- Refresh the browser page.
The activity remains in the feed after the refresh. Its persistence and derived pace now come from stored distance and elapsed time.
- Expand Edit activity on the new card.
- Replace the title with Owned Tempo Run.
- Click Save changes.
The feed should render the updated title. The authenticated owner's update matched exactly one row.
- Expand Edit activity again.
- Replace the title with Unauthorized Rewrite.
- Leave the edited form unsubmitted in this browser tab.
- Open a second browser tab.
- Open http://localhost:3000 in the second tab.
- Click Log out in the second tab.
- Open the registration page from the Paceboard navigation.
- Register a second local account with a different email address.
Before you submit the stale form, do you expect the hidden activity ID to override the second user's lack of ownership?
- Return to the first browser tab.
- Click Save changes on the stale form.
The request should fail without changing the activity. The browser now sends the second user's session while the stale form still carries the owner's stable activity ID.
- Return to the second browser tab.
- Refresh the Paceboard feed.
You should still see Owned Tempo Run on the owner's card. You should not see its Edit activity control while signed in as the second user.
Why the Hidden ID Does Not Grant Access
The hidden field identifies which activity the caller wants to change. The database predicate still requires that row's ownerId to match the authenticated viewer ID.
This is the boundary between authentication and authorization. Knowing a stable ID never grants ownership.
- Log out of the second account.
- Log back in as the activity owner.
- Expand Edit activity on Owned Tempo Run.
Before the final check, what do you expect the feed to show after the authenticated owner deletes the row?
- Click Delete activity.
- Refresh the Paceboard page.
The deleted activity should stay absent after the refresh. Paceboard now validates every activity write and enforces ownership even when a caller submits another user's stable ID.
Update Rejected for the Real Owner?
- Confirm the header shows the same account that created the activity.
- Confirm updateActivity() uses both id: activityId and ownerId: userId in its predicate.
- Confirm the edit form submits the existing activity.id through its hidden field.
Use this prompt to inspect the owner path: help me debug why an authenticated Paceboard owner cannot update or delete an activity
Your feed now supports persistent owner-controlled activities without trusting browser identity or pace values. Next, you will move Kudos into PostgreSQL and test the database invariants under concurrent requests.
Persist Kudos and Verify Contracts
Your Paceboard feed now accepts validated activity mutations from an authenticated owner. Those activities survive refreshes because the repository stores them in PostgreSQL.
Kudos still need the same persistence guarantees. A composite uniqueness constraint protects each stored reaction. Real database tests reveal how the first two-query implementation behaves when concurrent requests reach that constraint.
In this step, get ready to:
- Persist authenticated Kudos through database-backed Server Actions.
- Test authentication, authorization, persistence, removal, and concurrent requests against the test database.
- Verify the production build and document the system guarantees.
Persist Kudos through the database
The named uniqueness constraint allows one Kudos row for each user and activity pair. The first service checks for that row before attempting an insert.
Removal uses a filtered bulk delete. Repeating the same removal remains safe because deleting zero matching rows is a valid outcome.
- Create `lib/kudos-service.ts` inside the existing `lib` folder.
- Paste the database-backed Kudos service into `lib/kudos-service.ts`:
import { prisma } from "@/lib/prisma";
export interface KudosResult { readonly ok: true; readonly created?: boolean; readonly removed?: boolean; readonly count: number }
export async function giveKudosNaive(userId: string, activityId: string, afterCheck: () => Promise<void> = async () => {}): Promise<KudosResult> {
const existing = await prisma.kudos.findUnique({ where: { oneKudosPerUserActivity: { userId, activityId } } });
if (existing) return { ok: true, created: false, count: await prisma.kudos.count({ where: { activityId } }) };
await afterCheck();
await prisma.kudos.create({ data: { userId, activityId } });
return { ok: true, created: true, count: await prisma.kudos.count({ where: { activityId } }) };
}
export async function removeKudos(userId: string, activityId: string): Promise<KudosResult> {
const removed = await prisma.kudos.deleteMany({ where: { userId, activityId } });
return { ok: true, removed: removed.count === 1, count: await prisma.kudos.count({ where: { activityId } }) };
}
What does the Kudos service do?
- `giveKudosNaive()` checks the named compound key before creating a row.
- `afterCheck()` gives the integration test a deterministic pause between the read and write.
- `created` distinguishes a new row from a sequential duplicate.
- `removeKudos()` reports whether the current call removed a row.
- Save `lib/kudos-service.ts`.
- Confirm `lib/kudos-service.ts` appears inside the `lib` folder in your repository file tree.
Seeing a Prisma type error?
Confirm that `prisma/schema.prisma` still names the compound constraint `oneKudosPerUserActivity`. The generated compound field must match that name exactly.
Confirm that the generated Prisma Client reflects the current schema.
Ask for targeted help with the service: help me compare my Kudos service with my Prisma compound uniqueness constraint
✔️ Awesome, I've got everything!
Your database service now supports sequential duplicate detection and repeatable removal.
ⓧ I'd like to double check the full code
Compare your complete `lib/kudos-service.ts` file with this reference.
import { prisma } from "@/lib/prisma";
export interface KudosResult { readonly ok: true; readonly created?: boolean; readonly removed?: boolean; readonly count: number }
export async function giveKudosNaive(userId: string, activityId: string, afterCheck: () => Promise<void> = async () => {}): Promise<KudosResult> {
const existing = await prisma.kudos.findUnique({ where: { oneKudosPerUserActivity: { userId, activityId } } });
if (existing) return { ok: true, created: false, count: await prisma.kudos.count({ where: { activityId } }) };
await afterCheck();
await prisma.kudos.create({ data: { userId, activityId } });
return { ok: true, created: true, count: await prisma.kudos.count({ where: { activityId } }) };
}
export async function removeKudos(userId: string, activityId: string): Promise<KudosResult> {
const removed = await prisma.kudos.deleteMany({ where: { userId, activityId } });
return { ok: true, removed: removed.count === 1, count: await prisma.kudos.count({ where: { activityId } }) };
}
What should match?
The full file contains one naive give operation and one repeatable remove operation. Both return the current database count.
- Create `app/actions/kudos.ts` inside the existing `app/actions` folder.
- Paste the authenticated Kudos Server Actions into `app/actions/kudos.ts`:
"use server";
import { revalidatePath } from "next/cache";
import { giveKudosNaive, removeKudos } from "@/lib/kudos-service";
import { getCurrentViewer } from "@/lib/session";
export async function giveKudosAction(formData: FormData): Promise<void> {
const viewer = await getCurrentViewer();
if (!viewer) throw new Error("unauthorized");
const activityId = String(formData.get("activityId") ?? "");
if (!activityId) throw new Error("invalid");
await giveKudosNaive(viewer.id, activityId);
revalidatePath("/");
}
export async function removeKudosAction(formData: FormData): Promise<void> {
const viewer = await getCurrentViewer();
if (!viewer) throw new Error("unauthorized");
const activityId = String(formData.get("activityId") ?? "");
if (!activityId) throw new Error("invalid");
await removeKudos(viewer.id, activityId);
revalidatePath("/");
}
How do the actions protect the mutation?
- `getCurrentViewer()` derives identity from the validated database session.
- Each action rejects a missing viewer before calling the data layer.
- Each action validates that its submitted `activityId` is present.
- `revalidatePath()` refreshes the server-rendered feed after the database mutation.
- Save `app/actions/kudos.ts`.
- Confirm `app/actions/kudos.ts` appears inside the `app/actions` folder.
Seeing an action import error?
Check that `app/actions/kudos.ts` imports `giveKudosNaive` from `@/lib/kudos-service`. The function name must include the `Naive` suffix in this step.
Ask for help tracing the import: help me resolve the Kudos Server Action import without renaming the service
✔️ Awesome, I've got everything!
Your Kudos mutations now derive identity on the server and refresh the feed after each write.
ⓧ I'd like to double check the full code
Compare your complete `app/actions/kudos.ts` file with this reference.
"use server";
import { revalidatePath } from "next/cache";
import { giveKudosNaive, removeKudos } from "@/lib/kudos-service";
import { getCurrentViewer } from "@/lib/session";
export async function giveKudosAction(formData: FormData): Promise<void> {
const viewer = await getCurrentViewer();
if (!viewer) throw new Error("unauthorized");
const activityId = String(formData.get("activityId") ?? "");
if (!activityId) throw new Error("invalid");
await giveKudosNaive(viewer.id, activityId);
revalidatePath("/");
}
export async function removeKudosAction(formData: FormData): Promise<void> {
const viewer = await getCurrentViewer();
if (!viewer) throw new Error("unauthorized");
const activityId = String(formData.get("activityId") ?? "");
if (!activityId) throw new Error("invalid");
await removeKudos(viewer.id, activityId);
revalidatePath("/");
}
What should match?
Both actions validate server-derived identity before they delegate to the Kudos service. Each successful path revalidates `/`.
- Create `components/kudos-button.tsx` inside the existing `components` folder.
- Paste the authenticated Kudos form component into `components/kudos-button.tsx`:
import { giveKudosAction, removeKudosAction } from "@/app/actions/kudos";
import type { ActivityId } from "@/types/activity";
export function KudosButton({ activityId, activityTitle, pressed, authenticated }: { readonly activityId: ActivityId; readonly activityTitle: string; readonly pressed: boolean; readonly authenticated: boolean }) {
if (!authenticated) return <a className="font-bold text-orange-700" href="/login">Log in to give Kudos</a>;
return <form action={pressed ? removeKudosAction : giveKudosAction}>
<input type="hidden" name="activityId" value={activityId} />
<button type="submit" aria-label={`${pressed ? "Remove kudos from" : "Give kudos to"} ${activityTitle}`} aria-pressed={pressed} className={`rounded-full border px-4 py-2 text-sm font-bold ${pressed ? "border-orange-500 bg-orange-500 text-white" : "border-orange-500 bg-white text-orange-700"}`}>{pressed ? "Kudos given" : "Give kudos"}</button>
</form>;
}
What does the button decide?
- Unauthenticated visitors receive a link to the login page.
- Authenticated viewers submit the activity's opaque ID through a hidden field.
- `pressed` selects the give action or remove action from server-derived state.
- The accessible label includes the activity title and current operation.
- Save `components/kudos-button.tsx`.
- Confirm `components/kudos-button.tsx` appears inside the `components` folder.
Seeing an ActivityId type error?
Confirm that the prop uses the existing branded `ActivityId` type from `@/types/activity`. Avoid replacing it with a plain string.
Ask for help preserving the branded ID: help me connect KudosButton to the existing ActivityId type
✔️ Awesome, I've got everything!
Your feed now has an authenticated form component for giving or removing Kudos.
ⓧ I'd like to double check the full code
Compare your complete `components/kudos-button.tsx` file with this reference.
import { giveKudosAction, removeKudosAction } from "@/app/actions/kudos";
import type { ActivityId } from "@/types/activity";
export function KudosButton({ activityId, activityTitle, pressed, authenticated }: { readonly activityId: ActivityId; readonly activityTitle: string; readonly pressed: boolean; readonly authenticated: boolean }) {
if (!authenticated) return <a className="font-bold text-orange-700" href="/login">Log in to give Kudos</a>;
return <form action={pressed ? removeKudosAction : giveKudosAction}>
<input type="hidden" name="activityId" value={activityId} />
<button type="submit" aria-label={`${pressed ? "Remove kudos from" : "Give kudos to"} ${activityTitle}`} aria-pressed={pressed} className={`rounded-full border px-4 py-2 text-sm font-bold ${pressed ? "border-orange-500 bg-orange-500 text-white" : "border-orange-500 bg-white text-orange-700"}`}>{pressed ? "Kudos given" : "Give kudos"}</button>
</form>;
}
What should match?
The component posts only the activity ID. Identity comes from the server session inside the selected action.
- Replace the contents of `components/activity-card.tsx` with the connected card implementation below:
import { deleteActivityAction, updateActivityAction } from "@/app/actions/activities";
import { KudosButton } from "@/components/kudos-button";
import { RoutePreview } from "@/components/route-preview";
import { formatDistance, formatElapsedTime, formatElevation, formatPace } from "@/lib/format-activity";
import type { Activity } from "@/types/activity";
export function ActivityCard({ activity, authenticated }: { readonly activity: Activity; readonly authenticated: boolean }) {
const metrics = [["Distance", formatDistance(activity.metrics.distanceMiles)], ["Elapsed time", formatElapsedTime(activity.metrics.elapsedSeconds)], ["Average pace", formatPace(activity.metrics.averagePaceSecondsPerMile)], ["Elevation gain", formatElevation(activity.metrics.elevationGainFeet)]] as const;
return <article aria-label={`${activity.title} by ${activity.athlete.name}`} className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
<header className="flex items-center gap-3"><div aria-hidden="true" className="grid size-11 place-items-center rounded-full bg-teal-100 font-extrabold text-teal-800">{activity.athlete.initials}</div><div><p className="font-extrabold">{activity.athlete.name}</p><p className="text-sm text-slate-500">{activity.sport} · {activity.recordedAtLabel}</p></div></header>
<h2 className="mt-4 text-xl font-black">{activity.title}</h2><p className="mb-4 text-sm text-slate-600">{activity.note}</p><RoutePreview title={activity.title} route={activity.route} />
<dl className="my-4 grid grid-cols-2 gap-2 sm:grid-cols-4">{metrics.map(([label, value]) => <div key={label} className="rounded-xl bg-slate-50 p-3"><dt className="text-xs font-bold uppercase text-slate-500">{label}</dt><dd className="mt-1 font-extrabold">{value}</dd></div>)}</dl>
<footer className="flex items-center justify-between border-t pt-4"><span aria-live="polite" className="text-sm font-bold">{activity.initialKudos} kudos</span><KudosButton activityId={activity.id} activityTitle={activity.title} pressed={activity.viewerHasKudos} authenticated={authenticated} /></footer>
{activity.ownedByViewer ? <details className="mt-4 border-t pt-4"><summary className="cursor-pointer font-bold">Edit activity</summary><form action={updateActivityAction} className="mt-3 grid gap-2 sm:grid-cols-2"><input type="hidden" name="activityId" value={activity.id} /><input className="rounded-xl border p-2" name="title" defaultValue={activity.title} required /><select className="rounded-xl border p-2" name="activityType" defaultValue={activity.sport.toUpperCase()}><option value="RUN">Run</option><option value="RIDE">Ride</option><option value="WALK">Walk</option></select><input className="rounded-xl border p-2" name="distanceMiles" type="number" step="0.01" min="0.01" defaultValue={activity.metrics.distanceMiles} required /><input className="rounded-xl border p-2" name="durationSeconds" type="number" min="1" defaultValue={activity.metrics.elapsedSeconds} required /><input className="rounded-xl border p-2" name="elevationGainFeet" type="number" min="0" defaultValue={activity.metrics.elevationGainFeet} required /><input className="rounded-xl border p-2" name="recordedAt" type="datetime-local" defaultValue={activity.recordedAtIso.slice(0, 16)} required /><button className="rounded-full bg-teal-700 px-4 py-2 font-bold text-white" type="submit">Save changes</button></form><form action={deleteActivityAction} className="mt-3"><input type="hidden" name="activityId" value={activity.id} /><button className="font-bold text-red-700" type="submit">Delete activity</button></form></details> : null}
</article>;
}
How does the card stay server-driven?
The card receives the Kudos count and viewer state through the existing `Activity` contract. `KudosButton` turns that server-derived state into the correct form action.
The owner-only activity controls remain intact. Connecting Kudos does not weaken the ownership boundary.
- Save `components/activity-card.tsx`.
- Return to the Paceboard browser tab served by the development server from earlier.
- Click **Give kudos** on an existing activity.
- Refresh the browser.
You should see the count remain increased after the refresh. The button should still read **Kudos given**.
- Click **Kudos given** to remove your reaction.
- Refresh the browser again.
The count should remain decreased after the second refresh. That confirms both directions are backed by PostgreSQL.
Kudos state did not survive the refresh?
Confirm that `PostgresActivityRepository` maps `_count.kudos` into `initialKudos`. Confirm that it maps the viewer's matching row into `viewerHasKudos`.
Check that both Kudos actions call `revalidatePath()` after the database operation.
Ask for help tracing the persisted state: help me trace Kudos from the Server Action through Prisma into ActivityRepository
✔️ Awesome, I've got everything!
Your existing activity card now displays reversible database-backed Kudos without losing its owner controls.
ⓧ I'd like to double check the full code
Compare your complete `components/activity-card.tsx` file with this reference.
import { deleteActivityAction, updateActivityAction } from "@/app/actions/activities";
import { KudosButton } from "@/components/kudos-button";
import { RoutePreview } from "@/components/route-preview";
import { formatDistance, formatElapsedTime, formatElevation, formatPace } from "@/lib/format-activity";
import type { Activity } from "@/types/activity";
export function ActivityCard({ activity, authenticated }: { readonly activity: Activity; readonly authenticated: boolean }) {
const metrics = [["Distance", formatDistance(activity.metrics.distanceMiles)], ["Elapsed time", formatElapsedTime(activity.metrics.elapsedSeconds)], ["Average pace", formatPace(activity.metrics.averagePaceSecondsPerMile)], ["Elevation gain", formatElevation(activity.metrics.elevationGainFeet)]] as const;
return <article aria-label={`${activity.title} by ${activity.athlete.name}`} className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
<header className="flex items-center gap-3"><div aria-hidden="true" className="grid size-11 place-items-center rounded-full bg-teal-100 font-extrabold text-teal-800">{activity.athlete.initials}</div><div><p className="font-extrabold">{activity.athlete.name}</p><p className="text-sm text-slate-500">{activity.sport} · {activity.recordedAtLabel}</p></div></header>
<h2 className="mt-4 text-xl font-black">{activity.title}</h2><p className="mb-4 text-sm text-slate-600">{activity.note}</p><RoutePreview title={activity.title} route={activity.route} />
<dl className="my-4 grid grid-cols-2 gap-2 sm:grid-cols-4">{metrics.map(([label, value]) => <div key={label} className="rounded-xl bg-slate-50 p-3"><dt className="text-xs font-bold uppercase text-slate-500">{label}</dt><dd className="mt-1 font-extrabold">{value}</dd></div>)}</dl>
<footer className="flex items-center justify-between border-t pt-4"><span aria-live="polite" className="text-sm font-bold">{activity.initialKudos} kudos</span><KudosButton activityId={activity.id} activityTitle={activity.title} pressed={activity.viewerHasKudos} authenticated={authenticated} /></footer>
{activity.ownedByViewer ? <details className="mt-4 border-t pt-4"><summary className="cursor-pointer font-bold">Edit activity</summary><form action={updateActivityAction} className="mt-3 grid gap-2 sm:grid-cols-2"><input type="hidden" name="activityId" value={activity.id} /><input className="rounded-xl border p-2" name="title" defaultValue={activity.title} required /><select className="rounded-xl border p-2" name="activityType" defaultValue={activity.sport.toUpperCase()}><option value="RUN">Run</option><option value="RIDE">Ride</option><option value="WALK">Walk</option></select><input className="rounded-xl border p-2" name="distanceMiles" type="number" step="0.01" min="0.01" defaultValue={activity.metrics.distanceMiles} required /><input className="rounded-xl border p-2" name="durationSeconds" type="number" min="1" defaultValue={activity.metrics.elapsedSeconds} required /><input className="rounded-xl border p-2" name="elevationGainFeet" type="number" min="0" defaultValue={activity.metrics.elevationGainFeet} required /><input className="rounded-xl border p-2" name="recordedAt" type="datetime-local" defaultValue={activity.recordedAtIso.slice(0, 16)} required /><button className="rounded-full bg-teal-700 px-4 py-2 font-bold text-white" type="submit">Save changes</button></form><form action={deleteActivityAction} className="mt-3"><input type="hidden" name="activityId" value={activity.id} /><button className="font-bold text-red-700" type="submit">Delete activity</button></form></details> : null}
</article>;
}
What should match?
The card imports `KudosButton` and passes it the stable activity ID. It also passes the server-derived pressed state and authentication state.
Test the security and race contracts
Mocks cannot prove foreign keys or transaction-aware uniqueness. A real Vitest integration suite exercises the same PostgreSQL behavior used by the application.
The test inserts a deterministic barrier after both concurrent requests finish their initial read. That barrier forces both callers into the same race window.
- Create `tests/integration/paceboard.integration.test.ts` inside the repository.
- Paste the complete PostgreSQL contract suite into `tests/integration/paceboard.integration.test.ts`:
import { beforeEach, describe, expect, test } from "vitest";
import { activityRepository } from "@/data/activity-repository";
import { createActivity, updateActivity } from "@/lib/activity-service";
import { authenticateUser, registerUser } from "@/lib/auth-service";
import { giveKudosNaive, removeKudos } from "@/lib/kudos-service";
import { prisma } from "@/lib/prisma";
import { destroySession, getSessionUser, startSession } from "@/lib/session";
const validActivity = { title: "Tempo Test", activityType: "RUN", distanceMiles: 6, durationSeconds: 3600, elevationGainFeet: 120, recordedAt: "2026-10-26T10:00" };
async function createUser(email: string, name = "Runner") { const result = await registerUser({ name, email, password: "correct-horse-12" }); if (!result.ok) throw new Error(result.reason); return result.user; }
beforeEach(async () => { await prisma.user.deleteMany(); });
describe("Paceboard PostgreSQL contracts", () => {
test("authenticates valid credentials, rejects invalid credentials, and invalidates a session", async () => { const user = await createUser("runner@example.com"); expect((await authenticateUser({ email: "runner@example.com", password: "correct-horse-12" })).ok).toBe(true); expect((await authenticateUser({ email: "runner@example.com", password: "wrong-password-12" })).ok).toBe(false); const session = await startSession(user.id); expect((await getSessionUser(session.token))?.id).toBe(user.id); await destroySession(session.token); expect(await getSessionUser(session.token)).toBeNull(); });
test("blocks unauthenticated activity mutations", async () => { expect(await createActivity(null, validActivity)).toEqual({ ok: false, reason: "unauthorized" }); });
test("enforces ownership during updates", async () => { const owner = await createUser("owner@example.com", "Owner"); const other = await createUser("other@example.com", "Other"); const created = await createActivity(owner.id, validActivity); if (!created.ok) throw new Error(created.reason); expect(await updateActivity(other.id, created.id, { ...validActivity, title: "Stolen title" })).toEqual({ ok: false, reason: "forbidden" }); expect((await prisma.activity.findUnique({ where: { id: created.id } }))?.title).toBe("Tempo Test"); });
test("persists activity retrieval and derives pace", async () => { const owner = await createUser("pace@example.com", "Pace Runner"); expect((await createActivity(owner.id, validActivity)).ok).toBe(true); const [activity] = await activityRepository.listForViewer(owner.id); expect(activity.title).toBe("Tempo Test"); expect(activity.metrics.averagePaceSecondsPerMile).toBe(600); });
test("rejects invalid activity input", async () => { const owner = await createUser("invalid@example.com"); expect(await createActivity(owner.id, { ...validActivity, distanceMiles: 0 })).toEqual({ ok: false, reason: "invalid" }); });
test("prevents sequential duplicate Kudos and makes removal repeatable", async () => { const owner = await createUser("activity@example.com"); const fan = await createUser("fan@example.com"); const created = await createActivity(owner.id, validActivity); if (!created.ok) throw new Error(created.reason); expect((await giveKudosNaive(fan.id, created.id)).created).toBe(true); expect((await giveKudosNaive(fan.id, created.id)).created).toBe(false); expect(await prisma.kudos.count({ where: { activityId: created.id } })).toBe(1); expect((await removeKudos(fan.id, created.id)).removed).toBe(true); expect((await removeKudos(fan.id, created.id)).removed).toBe(false); });
test("captures the naive concurrent Kudos failure without corrupting the count", async () => { const owner = await createUser("race-owner@example.com"); const fan = await createUser("race-fan@example.com"); const created = await createActivity(owner.id, validActivity); if (!created.ok) throw new Error(created.reason); let arrivals = 0; let release = () => {}; const gate = new Promise<void>((resolve) => { release = resolve; }); const afterCheck = async () => { arrivals += 1; if (arrivals === 2) release(); await gate; }; const results = await Promise.allSettled([giveKudosNaive(fan.id, created.id, afterCheck), giveKudosNaive(fan.id, created.id, afterCheck)]); expect(results.filter((result) => result.status === "fulfilled").length).toBe(1); expect(results.filter((result) => result.status === "rejected").length).toBe(1); expect(await prisma.kudos.count({ where: { activityId: created.id } })).toBe(1); });
});
What does this suite prove?
Cascading cleanup starts each test with an empty database. The suite then exercises authentication, session invalidation, activity authorization, validation, persistence, and pace derivation.
The final tests exercise the named Kudos uniqueness constraint. One covers sequential calls. The other forces concurrent calls into the same pre-read window.
- Save `tests/integration/paceboard.integration.test.ts`.
- Confirm the file appears inside `tests/integration`.
✔️ Awesome, I've got everything!
Your integration suite now covers the application contracts against the real test database.
ⓧ I'd like to double check the full code
Compare your complete `tests/integration/paceboard.integration.test.ts` file with this reference.
import { beforeEach, describe, expect, test } from "vitest";
import { activityRepository } from "@/data/activity-repository";
import { createActivity, updateActivity } from "@/lib/activity-service";
import { authenticateUser, registerUser } from "@/lib/auth-service";
import { giveKudosNaive, removeKudos } from "@/lib/kudos-service";
import { prisma } from "@/lib/prisma";
import { destroySession, getSessionUser, startSession } from "@/lib/session";
const validActivity = { title: "Tempo Test", activityType: "RUN", distanceMiles: 6, durationSeconds: 3600, elevationGainFeet: 120, recordedAt: "2026-10-26T10:00" };
async function createUser(email: string, name = "Runner") { const result = await registerUser({ name, email, password: "correct-horse-12" }); if (!result.ok) throw new Error(result.reason); return result.user; }
beforeEach(async () => { await prisma.user.deleteMany(); });
describe("Paceboard PostgreSQL contracts", () => {
test("authenticates valid credentials, rejects invalid credentials, and invalidates a session", async () => { const user = await createUser("runner@example.com"); expect((await authenticateUser({ email: "runner@example.com", password: "correct-horse-12" })).ok).toBe(true); expect((await authenticateUser({ email: "runner@example.com", password: "wrong-password-12" })).ok).toBe(false); const session = await startSession(user.id); expect((await getSessionUser(session.token))?.id).toBe(user.id); await destroySession(session.token); expect(await getSessionUser(session.token)).toBeNull(); });
test("blocks unauthenticated activity mutations", async () => { expect(await createActivity(null, validActivity)).toEqual({ ok: false, reason: "unauthorized" }); });
test("enforces ownership during updates", async () => { const owner = await createUser("owner@example.com", "Owner"); const other = await createUser("other@example.com", "Other"); const created = await createActivity(owner.id, validActivity); if (!created.ok) throw new Error(created.reason); expect(await updateActivity(other.id, created.id, { ...validActivity, title: "Stolen title" })).toEqual({ ok: false, reason: "forbidden" }); expect((await prisma.activity.findUnique({ where: { id: created.id } }))?.title).toBe("Tempo Test"); });
test("persists activity retrieval and derives pace", async () => { const owner = await createUser("pace@example.com", "Pace Runner"); expect((await createActivity(owner.id, validActivity)).ok).toBe(true); const [activity] = await activityRepository.listForViewer(owner.id); expect(activity.title).toBe("Tempo Test"); expect(activity.metrics.averagePaceSecondsPerMile).toBe(600); });
test("rejects invalid activity input", async () => { const owner = await createUser("invalid@example.com"); expect(await createActivity(owner.id, { ...validActivity, distanceMiles: 0 })).toEqual({ ok: false, reason: "invalid" }); });
test("prevents sequential duplicate Kudos and makes removal repeatable", async () => { const owner = await createUser("activity@example.com"); const fan = await createUser("fan@example.com"); const created = await createActivity(owner.id, validActivity); if (!created.ok) throw new Error(created.reason); expect((await giveKudosNaive(fan.id, created.id)).created).toBe(true); expect((await giveKudosNaive(fan.id, created.id)).created).toBe(false); expect(await prisma.kudos.count({ where: { activityId: created.id } })).toBe(1); expect((await removeKudos(fan.id, created.id)).removed).toBe(true); expect((await removeKudos(fan.id, created.id)).removed).toBe(false); });
test("captures the naive concurrent Kudos failure without corrupting the count", async () => { const owner = await createUser("race-owner@example.com"); const fan = await createUser("race-fan@example.com"); const created = await createActivity(owner.id, validActivity); if (!created.ok) throw new Error(created.reason); let arrivals = 0; let release = () => {}; const gate = new Promise<void>((resolve) => { release = resolve; }); const afterCheck = async () => { arrivals += 1; if (arrivals === 2) release(); await gate; }; const results = await Promise.allSettled([giveKudosNaive(fan.id, created.id, afterCheck), giveKudosNaive(fan.id, created.id, afterCheck)]); expect(results.filter((result) => result.status === "fulfilled").length).toBe(1); expect(results.filter((result) => result.status === "rejected").length).toBe(1); expect(await prisma.kudos.count({ where: { activityId: created.id } })).toBe(1); });
});
What should match?
The suite uses `paceboard_test` through the existing test script. Its concurrent test uses `Promise.allSettled()` so it can inspect both request outcomes.
Before you run the suite, consider whether the naive pre-read allows both concurrent requests to complete successfully.
- Run the real PostgreSQL integration suite with this command:
npm test
What does this command verify?
The test script runs the unit suite and the PostgreSQL integration suite. The integration tests use the separate `paceboard_test` database.
The rejected request is intentional
The suite should pass while its race assertion records one fulfilled request and one rejected request. PostgreSQL keeps exactly one row because the composite unique constraint rejects the conflicting insert.
This is the designed shortfall. Stored data remains correct, but the naive API behavior is unpredictable for one concurrent caller.
Integration tests did not pass?
Confirm that the local database service is healthy. Confirm that the test script points to `paceboard_test`.
If the race test reports two fulfilled calls, check that `giveKudosNaive()` still uses `findUnique()` before `create()`. The idempotent repair belongs in the Secret Mission.
Ask for help isolating the failing contract: help me diagnose my Paceboard PostgreSQL integration test failure
Verify the build and document the guarantees
A passing suite proves runtime contracts. Repository guidance and documentation make those guarantees reproducible for another engineer.
The final evidence includes a Mermaid architecture diagram and a read-only Claude Code review skill. Together they show where identity, authorization, persistence, and concurrency correctness live.
- Replace the contents of `CLAUDE.md` with the project architecture and quality gates below:
# Product
- Paceboard is Project 2 in a progressive Strava-inspired application series.
- Preserve the tested Project 1 feed and evolve it through existing boundaries.
- Project 3 will add GPS ingestion, route processing, privacy controls, and interactive maps.
# Architecture
- `app/page.tsx` is the server composition boundary.
- UI code depends on `Activity`, not Prisma records.
- Server code depends on `ActivityRepository`, not directly on fixtures.
- Authentication derives identity from a database-backed cookie session.
- Authorization belongs in the data layer and must include ownership predicates.
- Average pace is derived from stored distance and elapsed time.
- Kudos uniqueness is enforced by the database and API retries must be idempotent.
# Quality gates
- Keep TypeScript strict and do not introduce `any`.
- Validate all Server Action input with Zod.
- Re-check authentication and authorization inside every mutation.
- Run `npm test` against the real test database.
- Run `npm run build` before reporting completion.
- Keep the Mermaid architecture and documented limitations current.
Why put these rules in CLAUDE.md?
The file preserves the architectural boundaries during future AI-assisted changes. It tells Claude Code which contracts must survive every edit.
The quality gates also define the evidence required before a change is complete.
- Save `CLAUDE.md`.
- Confirm the file ends with the Mermaid documentation requirement.
✔️ Awesome, I've got everything!
Your repository guidance now covers persistence, security boundaries, concurrency, tests, and documentation.
ⓧ I'd like to double check the full code
Compare your complete `CLAUDE.md` file with this reference.
# Product
- Paceboard is Project 2 in a progressive Strava-inspired application series.
- Preserve the tested Project 1 feed and evolve it through existing boundaries.
- Project 3 will add GPS ingestion, route processing, privacy controls, and interactive maps.
# Architecture
- `app/page.tsx` is the server composition boundary.
- UI code depends on `Activity`, not Prisma records.
- Server code depends on `ActivityRepository`, not directly on fixtures.
- Authentication derives identity from a database-backed cookie session.
- Authorization belongs in the data layer and must include ownership predicates.
- Average pace is derived from stored distance and elapsed time.
- Kudos uniqueness is enforced by the database and API retries must be idempotent.
# Quality gates
- Keep TypeScript strict and do not introduce `any`.
- Validate all Server Action input with Zod.
- Re-check authentication and authorization inside every mutation.
- Run `npm test` against the real test database.
- Run `npm run build` before reporting completion.
- Keep the Mermaid architecture and documented limitations current.
What should match?
The file separates product direction, architecture rules, and quality gates into three sections.
- Create `.claude/skills/feed-review/SKILL.md` inside the Paceboard repository.
- Paste the read-only review skill into `.claude/skills/feed-review/SKILL.md`:
---
name: feed-review
description: Review Paceboard authentication, authorization, PostgreSQL persistence, Kudos concurrency, tests, build health, and documentation
disable-model-invocation: true
---
Review Paceboard without editing files.
1. Read `CLAUDE.md`, `README.md`, `prisma/schema.prisma`, the data layer, Server Actions, feed components, and integration tests.
2. Run `npm test` and record the PostgreSQL integration evidence.
3. Run `npm run build` and record the production-build evidence.
4. Confirm salted password hashes, hashed session verifiers, protected cookies, ownership predicates, derived pace, the Kudos unique constraint, concurrent tests, and repeatable removal.
5. Confirm the README contains setup, teardown, a Mermaid diagram, evidence, limitations, and the Project 3 boundary.
Return PASS or FAIL for tests, build, authentication, authorization, persistence, concurrency, and documentation. Include evidence and required fixes. Do not edit files.
What does the review skill inspect?
The skill reviews the repository without editing it. It runs both verification commands before assigning evidence-backed results.
Its categories mirror the system boundaries you implemented. That makes the review repeatable after future changes.
- Save `.claude/skills/feed-review/SKILL.md`.
- Confirm `.claude/skills/feed-review/SKILL.md` appears in the repository file tree.
✔️ Awesome, I've got everything!
Your repository now includes a read-only skill for reviewing the new guarantees.
ⓧ I'd like to double check the full code
Compare your complete `.claude/skills/feed-review/SKILL.md` file with this reference.
---
name: feed-review
description: Review Paceboard authentication, authorization, PostgreSQL persistence, Kudos concurrency, tests, build health, and documentation
disable-model-invocation: true
---
Review Paceboard without editing files.
1. Read `CLAUDE.md`, `README.md`, `prisma/schema.prisma`, the data layer, Server Actions, feed components, and integration tests.
2. Run `npm test` and record the PostgreSQL integration evidence.
3. Run `npm run build` and record the production-build evidence.
4. Confirm salted password hashes, hashed session verifiers, protected cookies, ownership predicates, derived pace, the Kudos unique constraint, concurrent tests, and repeatable removal.
5. Confirm the README contains setup, teardown, a Mermaid diagram, evidence, limitations, and the Project 3 boundary.
Return PASS or FAIL for tests, build, authentication, authorization, persistence, concurrency, and documentation. Include evidence and required fixes. Do not edit files.
What should match?
The skill disables automatic invocation and forbids edits. Its final report covers seven explicit review categories.
The README now needs enough detail for another engineer to reproduce the databases and interpret the security boundaries. The first half covers setup, verification, and architecture.
- Replace the contents of `README.md` with this opening section:
# Paceboard
Paceboard Project 2 evolves the tested Project 1 activity feed into a local-first full-stack application with PostgreSQL persistence, database sessions, ownership-aware mutations, and concurrency-safe Kudos.
## Reproduce
1. Copy `.env.example` to `.env.local`.
2. Run `npm install`.
3. Run `docker compose up --detach --wait`.
4. Run `npm run db:migrate`.
5. Run `npm run db:test:push`.
6. Run `npm run dev` and open `http://localhost:3000`.
## Verify
Run `npm run verify`. The command executes unit and PostgreSQL integration tests, then creates the production build.
What does this section establish?
The opening names the Project 2 evolution and provides the complete local reproduction sequence. The verification section gives a reviewer one command for tests and the production build.
- Add the Mermaid architecture section directly below the verification paragraph:
## Architecture
```mermaid
flowchart LR
Browser[Browser forms] --> Actions[Next.js Server Actions]
Actions --> DAL[Authentication and mutation data layer]
Page[App Router page] --> Session[Database session validation]
Session --> Page
Page --> Port[ActivityRepository]
Port --> Adapter[PostgresActivityRepository]
DAL --> Prisma[Prisma Client]
Adapter --> Prisma
Prisma --> Postgres[(PostgreSQL)]
Postgres --> User[User and Session]
Postgres --> Activity[Activity]
Postgres --> Kudos[Kudos unique by user and activity]
Page --> Feed[Existing Project 1 feed components]
```
What does the architecture show?
The diagram separates browser forms from server mutation logic. It also shows the repository adapter and data layer converging on the same Prisma-backed database.
The preserved Project 1 feed remains downstream from the server-composed page.
- Save `README.md`.
- Confirm `README.md` now contains the **Reproduce**, **Verify**, and **Architecture** headings.
The second half records the security model and the race evidence. It also sets honest limits around this educational authentication implementation.
- Add the security, race evidence, and limitations sections below the Mermaid diagram:
## Security boundaries
- Authentication identifies the caller through a server-validated session.
- Authorization separately restricts activity updates and deletes by owner ID.
- Server Actions are treated as public POST entry points and re-check identity.
- Passwords use salted scrypt hashes. Raw session tokens stay only in protected cookies and only SHA-256 verifiers are stored.
- The Kudos composite unique constraint is the final duplicate-prevention boundary.
## Race evidence
Before the Secret Mission, the deterministic check-then-insert test records one fulfilled request, one rejected request, and one row. After the repair, both requests fulfill, exactly one reports a creation, and both observe one row and one count.
## Limitations
- This is an educational custom-auth implementation, not a replacement for a maintained authentication library in a production deployment.
- Local HTTP cannot use a Secure cookie, so the code enables Secure and the `__Host-` prefix only in production HTTPS.
- The project uses an eight-hour absolute session timeout but does not add idle renewal, password reset, email verification, MFA, rate limiting, or account lockout.
- Server Actions provide Origin and Host checks, but a broader deployment should review its CSRF policy, proxy configuration, and security headers.
- The feed is unpaginated, route previews remain deterministic SVG placeholders, and database credentials are development-only.
Why document the boundaries?
The security section distinguishes identity from ownership authorization. It also names the database constraint as the final duplicate-prevention boundary.
The limitations prevent an educational implementation from being presented as a complete production authentication platform.
- Add the Project 3 handoff and cleanup sections at the end of `README.md`:
## Project 3 boundary
Project 3 can add GPS file ingestion, route normalization, privacy zones, map-ready geometry, and interactive maps without replacing authentication, ownership, stable activity IDs, or `ActivityRepository`.
## Cleanup
- Pause and keep data: `docker compose down`
- Delete local database data: `docker compose down --volumes`
What does the handoff preserve?
Project 3 can extend route data without replacing identity or ownership. Stable activity IDs and `ActivityRepository` remain the foundation for that work.
The cleanup section distinguishes pausing the database from deleting its named volume.
- Save `README.md`.
- Confirm `README.md` ends with both local database cleanup options.
✔️ Awesome, I've got everything!
Your README now documents reproduction, architecture, security boundaries, race evidence, limitations, cleanup, and the Project 3 handoff.
ⓧ I'd like to double check the full code
Compare your complete `README.md` file with this reference.
# Paceboard
Paceboard Project 2 evolves the tested Project 1 activity feed into a local-first full-stack application with PostgreSQL persistence, database sessions, ownership-aware mutations, and concurrency-safe Kudos.
## Reproduce
1. Copy `.env.example` to `.env.local`.
2. Run `npm install`.
3. Run `docker compose up --detach --wait`.
4. Run `npm run db:migrate`.
5. Run `npm run db:test:push`.
6. Run `npm run dev` and open `http://localhost:3000`.
## Verify
Run `npm run verify`. The command executes unit and PostgreSQL integration tests, then creates the production build.
## Architecture
```mermaid
flowchart LR
Browser[Browser forms] --> Actions[Next.js Server Actions]
Actions --> DAL[Authentication and mutation data layer]
Page[App Router page] --> Session[Database session validation]
Session --> Page
Page --> Port[ActivityRepository]
Port --> Adapter[PostgresActivityRepository]
DAL --> Prisma[Prisma Client]
Adapter --> Prisma
Prisma --> Postgres[(PostgreSQL)]
Postgres --> User[User and Session]
Postgres --> Activity[Activity]
Postgres --> Kudos[Kudos unique by user and activity]
Page --> Feed[Existing Project 1 feed components]
```
## Security boundaries
- Authentication identifies the caller through a server-validated session.
- Authorization separately restricts activity updates and deletes by owner ID.
- Server Actions are treated as public POST entry points and re-check identity.
- Passwords use salted scrypt hashes. Raw session tokens stay only in protected cookies and only SHA-256 verifiers are stored.
- The Kudos composite unique constraint is the final duplicate-prevention boundary.
## Race evidence
Before the Secret Mission, the deterministic check-then-insert test records one fulfilled request, one rejected request, and one row. After the repair, both requests fulfill, exactly one reports a creation, and both observe one row and one count.
## Limitations
- This is an educational custom-auth implementation, not a replacement for a maintained authentication library in a production deployment.
- Local HTTP cannot use a Secure cookie, so the code enables Secure and the `__Host-` prefix only in production HTTPS.
- The project uses an eight-hour absolute session timeout but does not add idle renewal, password reset, email verification, MFA, rate limiting, or account lockout.
- Server Actions provide Origin and Host checks, but a broader deployment should review its CSRF policy, proxy configuration, and security headers.
- The feed is unpaginated, route previews remain deterministic SVG placeholders, and database credentials are development-only.
## Project 3 boundary
Project 3 can add GPS file ingestion, route normalization, privacy zones, map-ready geometry, and interactive maps without replacing authentication, ownership, stable activity IDs, or `ActivityRepository`.
## Cleanup
- Pause and keep data: `docker compose down`
- Delete local database data: `docker compose down --volumes`
What should match?
The full document contains every command needed to reproduce and verify the local project. It also preserves the before-and-after race evidence required by the Secret Mission.
Before you run the final verification, predict whether the intentional rejected race request causes the overall suite to fail.
- Run the complete test and production-build pipeline with this command:
npm run verify
What does the final verification cover?
The verification script runs the unit tests and the real PostgreSQL integration suite. It then creates the production build.
The concurrent naive test passes because its expected contract includes one rejected request and exactly one stored row.
That is the full contract verified. Paceboard now persists authenticated Kudos, protects ownership boundaries, proves database invariants, and builds for production.
Final verification failed?
Use the first failing command to narrow the problem. A test failure points to a contract mismatch. A build failure points to a type, import, or rendering mismatch.
Confirm that the local PostgreSQL service remains healthy before rerunning database tests.
Ask for a focused diagnosis: help me separate my Paceboard test failure from my production build failure
Secret mission
Secret Mission: Break and Repair a Concurrent Kudos Race
Your database already prevents duplicate Kudos rows, but the naive check-then-insert flow still rejects one concurrent caller. In this mission, you will replace that race with an idempotent insert so both callers complete predictably while one Kudos row remains.
Clean Up Your Resources
Clean Up Your Resources
Paceboard runs entirely on your machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them for another session, or delete the local runtime and database data.
Resources you used:
- A Next.js development server running from your existing Paceboard repository.
- A Docker Compose stack containing the local PostgreSQL 18.6 container and its Compose network.
- A named Docker volume containing both local Paceboard databases.
Keep everything running
No action needed. Choose this if you are continuing to test Paceboard or moving directly into Project 3.
- Leave the Next.js development server active for continued local access.
- Keep Docker Desktop running so Paceboard can reach its development and test databases.
- Retain the named database volume so your local accounts, sessions, activities, and Kudos remain available.
- Keep the existing Paceboard Git repository with its migrations, tests, build evidence, and documented race results.
Pause - I'll come back to this later
Shut down the running processes to free up memory. Your source files and named database volume stay available for your next session.
- Switch back to the terminal where the Next.js development server is running.
- Press Ctrl+C to stop the development server.
- Switch back to the terminal positioned in your existing Paceboard repository.
- Stop the local database stack while preserving its named volume by running this command:
docker compose down
What does this command do?
Docker Compose stops the local PostgreSQL container. It removes the Compose network.
The named volume remains on your machine. Your local database records return when you start the stack again.
- Switch back to Docker Desktop to check the Paceboard PostgreSQL container.
You should no longer see the Paceboard PostgreSQL container running. The named volume remains available for your next session.
Delete - I don't want to use this again
Removing the named volume permanently deletes your local accounts, sessions, activities, and Kudos. Your Paceboard source files and race evidence stay in the existing Git repository.
- Switch back to the terminal where the Next.js development server is running.
- Press Ctrl+C to stop the development server.
- Switch back to the terminal positioned in your existing Paceboard repository.
- Remove the local PostgreSQL container, Compose network, and named database volume by running this command:
docker compose down --volumes
What does this command remove?
Docker Compose stops the local PostgreSQL container. It removes the Compose network.
It also removes the named volume that stores both Paceboard databases. The command leaves your existing repository files untouched.
- Switch back to Docker Desktop to confirm the Paceboard PostgreSQL container and named volume are gone.
You should no longer see the Paceboard PostgreSQL container or its named database volume. Your repository still contains the application, migration history, integration tests, and before-and-after race evidence.
Nice Work!
Nice Work!
That is a wrap! Paceboard now runs as an authenticated full-stack activity feed backed by durable local data.
You've learned how to:
- Replace fixture storage with a PostgreSQL adapter powered by Prisma ORM behind ActivityRepository. The preserved Project 1 feed now renders persistent activities. Pace comes from stored distance plus elapsed time.
- Implement authentication from registration through logout. Protect passwords with salted hashes. Validate database sessions on the server. Enforce authorization for every owned activity mutation.
- Prove the security contracts with Vitest integration tests against real PostgreSQL. Confirm build health with a production build. Create reviewer-ready documentation for the Project 3 handoff.
- Secret Mission: Captured the naive concurrent Kudos failure before applying the repair. Replaced the pre-read race with an idempotent insert. Both concurrent calls now fulfill. Exactly one call reports creation. The composite unique constraint still keeps exactly one row. Repeated gives stay at one. Repeated removals end at zero.
Ready to quiz yourself?