Prompt Pack
This Prompt Pack provides a complete 10-phase step-by-step instruction sequence for Replit to scaffold Strata, a production social habit tracker. Each prompt targets an isolated product phase—covering project foundation, database schema, background scheduling, social logic, and email integration.
Strata runs on a modern serverless tech stack comprising React with Vite, Node.js with Express, PostgreSQL (Neon), Drizzle ORM, and Resend for transactional email.
Prompt 1
You'll end up with a complete project skeleton — all the folders, configuration files, and database table definitions — but no running server yet. The database schema covers every table the app will ever need, so you won't have to change it later. Once this prompt is done, you'll run two commands in the Replit Shell to create the tables in your database.
Paste everything below into your AI coding tool:
We are building a full-stack Strata app on Replit. The stack is Node.js with Express on the backend, React with Vite on the frontend, PostgreSQL via Replit's managed database, and Drizzle ORM for database access. Nothing exists yet — this prompt sets up the entire project scaffold and database schema.
TypeScript configuration
Configure TypeScript for the project using CommonJS modules throughout (this ensures Express and all Node.js tooling work without ESM compatibility issues on Replit). The TypeScript config must apply to both the server/ and db/ directories. The client/ directory will use a separate Vite-managed TypeScript config added in a later prompt.
package.json scripts
Define the following scripts — do not fill in version numbers, just use the latest compatible versions for all dependencies:
"build" — runs vite build to compile the React frontend
"start" — runs the compiled server entry point (the output of the TypeScript compiler or tsx, whichever approach is idiomatic for Replit's Node.js environment)
"dev" — runs both the Express server and Vite simultaneously using concurrently "tsx watch server/index.ts" "vite" for fast iteration.
"db:generate" — runs drizzle-kit generate to produce migration SQL from the schema
"db:migrate" — runs drizzle-kit migrate to apply pending migrations to the database
Install all required dependencies for the full project now: Express, Drizzle ORM and Drizzle Kit, the drizzle-orm/node-postgres driver, Passport.js with the local and Google strategies, bcrypt, express-session, connect-pg-simple, node-cron, Zod, Resend SDK, Helmet, the pg package, concurrently, and the React + Vite + React Router stack for the frontend. Include all necessary TypeScript type packages.
drizzle.config.ts (project root)
Drizzle Kit configuration file. Points to db/schema.ts as the schema source and db/migrations/ as the migration output directory. Reads DATABASE_URL from the environment.
db/schema.ts
Define the complete Drizzle schema for all six tables. Every subsequent module imports its types from this single file — no table shape is defined anywhere else.
Tables and their columns:
users: id (UUID, primary key, default generated), email (text, unique, not null), password_hash (text, nullable), google_id (text, unique, nullable), timezone (text, not null, default 'UTC'), created_at (timestamp with time zone, not null, default now)
habits: id (UUID, primary key), user_id (UUID, foreign key → users, not null), name (text, not null), name_normalized (text, not null — add an index on this column), category (text, nullable), is_public (boolean, not null, default false), recurrence_days (integer array, not null), archived_at (timestamp with time zone, nullable)
check_ins: id (UUID, primary key), habit_id (UUID, foreign key → habits, not null), user_id (UUID, foreign key → users, not null), checked_at (timestamp with time zone, not null), note (text, nullable). Add a composite index on (habit_id, checked_at DESC) — this is required for streak calculation performance.
reminders: id (UUID, primary key), habit_id (UUID, foreign key → habits, not null — cascade delete when the habit is deleted), user_id (UUID, foreign key → users, not null), remind_at_time (time, not null)
accountability_partners: id (UUID, primary key), requester_id (UUID, foreign key → users, not null), addressee_id (UUID, foreign key → users, not null), status (text, not null — add a check constraint enforcing the value is one of 'pending', 'accepted', 'rejected'), created_at (timestamp with time zone, not null, default now). Add a unique constraint on (requester_id, addressee_id).
sessions: the shape required by connect-pg-simple: sid (text, primary key), sess (jsonb, not null), expire (timestamp with time zone, not null). Add an index on expire.
Export the following TypeScript types inferred by Drizzle from the schema — these are the shapes every other module uses:
User, NewUser (select and insert types for users)
Habit, NewHabit (select and insert types for habits)
CheckIn, NewCheckIn (select and insert types for check_ins)
Reminder, NewReminder (select and insert types for reminders)
AccountabilityPartner, NewAccountabilityPartner (select and insert types for accountability_partners)
db/index.ts
Creates and exports a single db instance — the Drizzle client connected to DATABASE_URL. Uses the drizzle-orm/node-postgres driver along with the standard pg package. Every module in the project that needs database access imports db from this file. No second connection is created anywhere.
Export:
db — the Drizzle database client parameterized over the full schema
db/migrations/
This directory is where Drizzle Kit writes generated SQL migration files. Create it (it can be empty at this point — the db:generate script populates it).
Do not build yet: the Express server, any routes, any authentication logic, or any frontend files.
Check before continuing (do this yourself, do not paste it):
- In the Replit Shell, run
npm run db:generate. It should complete without errors and create at least one SQL file inside db/migrations/.
- Then run
npm run db:migrate. It should apply the migration without errors.
- Ask your tool to confirm all six tables were created in the database and that no TypeScript errors exist in
db/schema.ts or db/index.ts.
- If something is wrong: paste the exact error message back to the agent and ask it to fix the schema or configuration before continuing.
Prompt 2
Run this after Prompt 1 is complete.
You'll end up with a running web server that responds to a health check and enforces security headers and CORS. No routes or login functionality yet — just the server foundation every later feature will build on. When this prompt is done, you'll be able to open your Repl's URL and see a response.
Paste everything below into your AI coding tool:
We are building a Strata on Replit. Prompt 1 established the database schema in db/schema.ts and db/index.ts. This prompt stands up the Express server with all middleware in place and a health check endpoint.
server/errors.ts
Defines the application's error class hierarchy. All phases use these — no raw database errors or SDK errors are ever thrown across a module boundary.
Export:
AppError — base class; constructor takes message: string and statusCode: number; exposes both as public properties
NotFoundError extends AppError — fixed status 404; used when a resource doesn't exist or the requesting user isn't authorized to know it exists
UnauthorizedError extends AppError — fixed status 401; used when no valid session exists
ValidationError extends AppError — fixed status 400; used when Zod rejects input
ConflictError extends AppError — fixed status 409; used for constraint violations such as duplicate partnership invites
ExternalServiceError extends AppError — fixed status 502; used when Resend or Google OAuth fails in a way the caller should know about
server/middleware/errorHandler.ts
Exports a single Express error-handling middleware (four-argument form):
errorHandler(err, req, res, next): void
- Logs the error to stdout including the route path and
req.user?.id if a session user is present. The log must never include a stack trace, internal database error detail, or raw SDK error text.
- If
err is an instance of AppError, responds with err.statusCode and { error: err.message }
- For any other error type, responds with a generic server error status and
{ error: 'An unexpected error occurred' }
server/router.ts
A barrel module that imports all domain route modules and mounts them under their path prefixes using an Express Router. In this prompt it is empty — it exports an Express Router with no routes mounted. Later prompts add their route modules here without modifying server/index.ts.
Export:
router — an Express Router instance (no routes mounted yet)
server/index.ts
The application entry point. Constructs and starts the Express app. Middleware must be mounted in this exact order:
- Helmet (default configuration)
- CORS — allowed origin is
APP_URL from the environment; credentials allowed; no wildcard
- JSON body parser
- express-session with connect-pg-simple as the store:
- Session secret from
SESSION_SECRET environment variable
- Cookie:
httpOnly: true, sameSite: 'strict', secure: true
- Session max age: 30 days
- The connect-pg-simple store uses the same
DATABASE_URL the rest of the app uses; it does not create a second Drizzle instance — it connects via pg directly as connect-pg-simple expects
passport.initialize() and passport.session() — Passport itself is configured in the next prompt; mount the middleware stubs here
- Static file serving from the
public/ directory at the project root (this serves the compiled React bundle added in Prompt 9)
- Mount
router from server/router.ts at /
- Mount
errorHandler from server/middleware/errorHandler.ts as the final middleware
Additionally, define a GET /health route directly in server/index.ts (before the router) that returns { status: 'ok' }.
When a request reaches an undefined route (and the router has no match), the error handler must return { error: 'Not found' } rather than Express's default HTML response. Ensure this is handled — either by a catch-all route in server/router.ts or an explicit 404 handler before the error handler.
Exports nothing — this is the process entry point. It starts the server listening when executed.
Do not build yet: authentication strategies, any domain routes, or any frontend files.
Check before continuing (do this yourself, do not paste it):
- In the Replit Shell, run
npm run dev. The server should start without errors.
- Open your Repl's public URL and add
/health to the end. You should see {"status":"ok"} in the browser.
- Open any other path (e.g.
/does-not-exist). You should see {"error":"Not found"} — not an HTML page.
- If something is wrong: paste the error from the Shell or browser back to the agent and ask it to fix it before continuing.
Prompt 3
Run this after Prompt 2 is complete.
You'll end up with a working login and registration system — users can create accounts with email and password, sign in with Google, and log out. The requireAuth middleware that protects every route in later prompts is also created here.
Paste everything below into your AI coding tool:
We are building a Strata on Replit. Prompts 1 and 2 established the database schema, the db client, and the Express server with middleware. This prompt implements the full authentication system: Passport.js local and Google OAuth strategies, session handling, and auth routes.
server/auth/validation.ts
Exports Zod schemas used in the auth routes and reused by the frontend in Prompt 9. These files must not import any Node.js-only modules so they can be bundled by Vite for the client.
Export:
registerSchema — object with email (Zod email string) and password (string, minimum 8 characters)
loginSchema — same shape as registerSchema
server/middleware/requireAuth.ts
Exports a single Express middleware:
requireAuth(req, res, next): void
- If
req.isAuthenticated() returns true, calls next()
- Otherwise throws
UnauthorizedError (imported from server/errors.ts)
server/auth/passport.ts
Configures Passport strategies and session serialization. Called once from server/index.ts for its side effects. Exports nothing.
LocalStrategy behavior (strategy name: 'local'):
- Receives
email and password from the request body
- Looks up the user in the
users table by email (case-insensitive comparison)
- If no user is found, calls done with the message
'Invalid email or password' — do not distinguish between "no account" and "wrong password"
- Compares the submitted password against the stored
password_hash using bcrypt
- If the hash does not match, calls done with the same message
'Invalid email or password'
- On success, passes the full
User row to done
GoogleStrategy behavior (using GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from the environment; callback URL constructed from APP_URL as ${APP_URL}/auth/google/callback):
- Receives the Google profile's email and the profile's ID field as
google_id
- Looks up an existing user by
google_id first; if found, passes that user to done
- If not found by
google_id, looks up by email (case-insensitive)
- If found by email, updates that user's
google_id field to link the accounts, then passes the updated user to done
- If neither lookup finds a match, creates a new
User row with password_hash: null and google_id set; passes the new user to done
- On any database error, passes the error to done
Session serialization:
serializeUser: stores user.id (the UUID string) in the session
deserializeUser: looks up the full User row by that ID; if no user is found, passes null (Passport treats this as a logged-out session)
After defining strategies and serialization, call configurePassport() — or equivalent initialization — from server/index.ts so Passport is configured before the first request arrives.
server/auth/routes.ts
Exports an Express Router. This router is imported and mounted at /auth in server/router.ts.
The PublicUser shape used in all responses below: { id: string, email: string, timezone: string }. Never include password_hash or google_id in any response.
Routes:
POST /register
- Validates the request body with
registerSchema; on failure throws ValidationError with the Zod error details
- Checks for an existing user with the same email (case-insensitive); if found throws
ConflictError with the message 'An account with this email already exists'
- Hashes the password with bcrypt at 12 rounds
- Inserts a new
User row
- Establishes a session (calls
req.login)
- Responds with status 201 and
{ user: PublicUser }
POST /login
- Validates the request body with
loginSchema; on failure throws ValidationError
- Delegates to Passport LocalStrategy via
passport.authenticate('local', ...)
- On success: responds with
{ user: PublicUser }
- On failure: throws
UnauthorizedError with the message returned by the strategy ('Invalid email or password')
GET /google
- Initiates Google OAuth redirect via Passport; requests
email and profile scopes
GET /google/callback
- OAuth callback handler
- On success: redirect to
/
- On failure: redirect to
/login?error=oauth_failed
POST /logout
- Destroys the session and clears the session cookie
- Responds with
{ success: true }
GET /me
- Protected by
requireAuth
- Responds with
{ user: PublicUser } for the currently authenticated user
Mount this router in server/router.ts at /auth.
Do not build yet: habit routes, check-in routes, reminder routes, social routes, or any frontend files.
Check before continuing (do this yourself, do not paste it):
- Use your Repl's web preview. Submit the registration form (or use a tool like the browser's network tab) to
POST /auth/register with a valid email and password — you should receive { user: { id, email, timezone } } with status 201.
POST /auth/login with the same credentials should return the same shape.
POST /auth/login with a wrong password should return a 401 with { error: 'Invalid email or password' }.
GET /auth/me immediately after login should return your user; in a fresh browser tab (no session) it should return 401.
- If something is wrong: paste the error response back to the agent and ask it to fix the auth logic before continuing.
Prompt 4
Run this after Prompt 3 is complete.
You'll end up with the core of the app: users can create habits, log daily completions, and see their current streak count. Streak calculation is done on every read — it walks backwards through your check-in history and counts consecutive days you completed the habit, skipping days it wasn't scheduled.
Paste everything below into your AI coding tool:
We are building a Strata on Replit. Prompts 1–3 established the database schema, Express server, and authentication. This prompt implements habits and check-ins: the CRUD endpoints, streak calculation, and check-in recording.
server/habits/validation.ts
Exports Zod schemas. These files must not import any Node.js-only modules so they can be bundled by Vite for the frontend in Prompt 9.
Export:
createHabitSchema — object: name (string, minimum 1 character, trimmed), category (string or null, optional), is_public (boolean), recurrence_days (array of integers each between 0 and 6 inclusive, minimum 1 element)
updateHabitSchema — same fields as createHabitSchema, all optional (partial), with the same individual field constraints
createCheckInSchema — object: note (string or null, optional)
server/habits/service.ts
Exports pure async business logic functions. No Express types. All functions throw an AppError subclass on failure — they never return error-shaped objects.
normalizeHabitName(name: string): string
- Lowercases and trims the input string
- Returns the normalized result
- This function is called server-side only; the
name_normalized field is never accepted from the client
createHabit(userId: string, data: CreateHabitInput): Promise<Habit>
CreateHabitInput: { name: string, category: string | null, is_public: boolean, recurrence_days: number[] }
- Computes
name_normalized by calling normalizeHabitName(data.name)
- Inserts the habit row and returns the full
Habit record
updateHabit(habitId: string, userId: string, data: Partial<CreateHabitInput>): Promise<Habit>
- Verifies that a habit with
habitId exists and has user_id === userId; throws NotFoundError if not
- If
data.name is present, recomputes name_normalized
- Updates only the fields provided in
data
- Returns the updated
Habit record
archiveHabit(habitId: string, userId: string): Promise<void>
- Verifies ownership (same check as
updateHabit); throws NotFoundError if not
- Sets
archived_at to the current timestamp; does not delete the row
getHabitsForUser(userId: string): Promise<Habit[]>
- Returns all habits where
user_id === userId and archived_at IS NULL
getHabitById(habitId: string, userId: string): Promise<Habit>
- Returns the habit if it exists and
user_id === userId; throws NotFoundError otherwise
- Returns archived habits as well — this function is the canonical ownership check used by check-in and reminder routes
createCheckIn(habitId: string, userId: string, data: { note: string | null }): Promise<CheckIn>
- Verifies the habit exists and belongs to
userId by calling getHabitById
- Sets
checked_at to the current server UTC timestamp
- Inserts and returns the
CheckIn row
getCheckInsForHabit(habitId: string, userId: string): Promise<CheckIn[]>
- Verifies ownership via
getHabitById
- Returns all check-ins for the habit ordered by
checked_at DESC
calculateStreak(habitId: string, recurrenceDays: number[], userTimezone: string): Promise<number>
- Fetches all
checked_at timestamps for the habit from check_ins, ordered descending
- Converts each timestamp to a calendar date in
userTimezone
- Starting from today (the current date in
userTimezone), walks backwards day by day:
- If the day is in
recurrenceDays: check whether at least one check-in date matches that day; if yes, increment the streak count and continue; if no, stop the walk
- If the day is not in
recurrenceDays: skip it without breaking or counting
- Returns the final streak count as an integer
- This function must be correct: a habit scheduled Monday and Wednesday with check-ins on the last three Mon/Wed dates has a streak of 3; a missed Monday breaks the streak to 0
getHabitWithStreak(habitId: string, userId: string, userTimezone: string): Promise<HabitWithStreak>
HabitWithStreak: the Habit type intersected with { streak: number }
- Calls
getHabitById then calculateStreak; returns the merged result
getHabitsWithStreaks(userId: string, userTimezone: string): Promise<HabitWithStreak[]>
- Calls
getHabitsForUser, then calls calculateStreak for each result
- Returns the full array of
HabitWithStreak
Export the HabitWithStreak type — it is consumed by the social service in Prompt 6.
server/habits/routes.ts
Exports an Express Router. All routes require requireAuth. Mount this router in server/router.ts at /habits.
GET /habits — calls getHabitsWithStreaks(req.user.id, req.user.timezone); responds with { habits: HabitWithStreak[] }
POST /habits — validates with createHabitSchema; calls createHabit; responds with status 201 and { habit: Habit }
GET /habits/:habitId — calls getHabitWithStreak(habitId, req.user.id, req.user.timezone); responds with { habit: HabitWithStreak }
PATCH /habits/:habitId — validates with updateHabitSchema; calls updateHabit; responds with { habit: Habit }
DELETE /habits/:habitId — calls archiveHabit; responds with { success: true }
POST /habits/:habitId/checkins — validates with createCheckInSchema; calls createCheckIn; responds with status 201 and { checkIn: CheckIn }
GET /habits/:habitId/checkins — calls getCheckInsForHabit; responds with { checkIns: CheckIn[] }
Validation errors from Zod must be caught at the route layer and thrown as ValidationError before they reach the error handler.
If a Drizzle query throws due to a connection failure or unexpected database error, the Express error handler (from Prompt 2) catches it, logs it with the route path and user ID, and returns a generic server error response. This applies to all database operations across this prompt and all future prompts — later prompts inherit this without restatement.
Do not build yet: reminder routes, social routes, the scheduler, user settings routes, or any frontend files.
Check before continuing (do this yourself, do not paste it):
POST /habits with { name: "Exercise", is_public: true, recurrence_days: [1, 3], category: "health" } should create a habit and return it. Check that name_normalized in the response is "exercise" (lowercase).
GET /habits should return the habit with a streak field (0 at this point since there are no check-ins).
POST /habits/:habitId/checkins should record a check-in and return it.
DELETE /habits/:habitId should return { success: true }; a subsequent GET /habits should no longer include that habit.
POST /habits/:habitId/checkins for a habit belonging to a different user should return 404.
- If something is wrong: paste the error back to the agent and ask it to fix before continuing.
Prompt 5
Run this after Prompt 4 is complete.
You'll end up with reminder configuration endpoints — users can set one or more times per day for each habit to receive an email reminder. The scheduler in Prompt 7 will read these configurations to decide when to send emails.
Paste everything below into your AI coding tool:
We are building a Strata on Replit. Prompts 1–4 established the schema, server, auth, and habits. This prompt adds reminder configuration: the endpoints to create, read, update, and delete reminder times for habits, plus the data-fetching function the scheduler will use.
server/reminders/validation.ts
Exports Zod schemas:
createReminderSchema — object: remind_at_time (string matching the pattern for 24-hour time HH:MM, e.g. "14:30")
updateReminderSchema — same shape as createReminderSchema
server/reminders/service.ts
Exports async service functions. All throw AppError subclasses on failure.
createReminder(habitId: string, userId: string, remindAtTime: string): Promise<Reminder>
- Verifies the habit exists and belongs to
userId by calling getHabitById from server/habits/service.ts; throws NotFoundError if not
- Inserts a new
Reminder row with habit_id, user_id, and remind_at_time
- Returns the inserted
Reminder
getRemindersForHabit(habitId: string, userId: string): Promise<Reminder[]>
- Verifies ownership via
getHabitById
- Returns all reminders for the habit
updateReminder(reminderId: string, userId: string, remindAtTime: string): Promise<Reminder>
- Looks up the reminder by
reminderId; throws NotFoundError if it doesn't exist or if its user_id does not match userId
- Updates
remind_at_time to the new value; returns the updated Reminder
deleteReminder(reminderId: string, userId: string): Promise<void>
- Looks up the reminder by
reminderId; throws NotFoundError if it doesn't exist or if its user_id does not match userId
- Deletes the row
getDueReminders(currentMinuteUtc: Date): Promise<DueReminder[]>
- Used exclusively by the scheduler in Prompt 7. This function does not filter by time — it returns all active reminders so the scheduler can apply its own time comparison logic.
- Queries the
reminders table joined to users (for email and timezone) and joined to habits (for name and archived_at)
- Excludes reminders where the associated habit has
archived_at IS NOT NULL
- Returns all results as
DueReminder[]
Export the DueReminder type:
type DueReminder = {
reminderId: string;
userId: string;
habitId: string;
habitName: string;
userEmail: string;
userTimezone: string;
remindAtTime: string; // "HH:MM" in the user's local timezone
}
server/reminders/routes.ts
Exports an Express Router. All routes require requireAuth. Mount this router in server/router.ts at /habits (nested under habits so the full paths are /habits/:habitId/reminders/...).
GET /habits/:habitId/reminders — calls getRemindersForHabit(habitId, req.user.id); responds with { reminders: Reminder[] }
POST /habits/:habitId/reminders — validates with createReminderSchema; calls createReminder(habitId, req.user.id, remindAtTime); responds with status 201 and { reminder: Reminder }
PATCH /habits/:habitId/reminders/:reminderId — validates with updateReminderSchema; calls updateReminder(reminderId, req.user.id, remindAtTime); responds with { reminder: Reminder }
DELETE /habits/:habitId/reminders/:reminderId — calls deleteReminder(reminderId, req.user.id); responds with { success: true }
Validation errors from Zod must be caught at the route layer and thrown as ValidationError.
Do not build yet: social routes, the scheduler, user settings routes, or any frontend files.
Check before continuing (do this yourself, do not paste it):
POST /habits/:habitId/reminders with { "remind_at_time": "08:00" } should create a reminder and return it.
GET /habits/:habitId/reminders should return the reminder you just created.
DELETE /habits/:habitId/reminders/:reminderId from a user who does not own the reminder should return 404.
- If something is wrong: paste the error back to the agent and ask it to fix before continuing.
Prompt 6
Run this after Prompt 5 is complete.
You'll end up with the social layer: users can invite friends as accountability partners, accept or reject invitations, view a partner's public habits and streaks, discover how many other users are tracking the same habit, and browse a leaderboard. Invitation emails are sent via Resend — if the email fails, no database record is created.
Paste everything below into your AI coding tool:
We are building a Strata on Replit. Prompts 1–5 established the schema, server, auth, habits, and reminders. This prompt adds the social layer: accountability partner invitations (including email delivery), public habit discovery, and leaderboards. It also creates the shared email client used here and by the scheduler in Prompt 7.
server/email/client.ts
The single Resend wrapper for the entire application. Both this prompt's invitation flow and the Prompt 7 scheduler import from here.
Export:
sendEmail(params: EmailParams): Promise<void>
EmailParams: { to: string, subject: string, html: string }
- Reads
EMAIL_FROM from the environment and uses it as the sender address
- Uses the Resend Node.js SDK to send the email
- On success, returns
undefined
- On any failure — network error, API error, or rejected recipient — catches the error, constructs an
ExternalServiceError with a human-readable message (never leaking raw SDK error details), and throws it
- This is the sole point where Resend SDK errors are handled; all callers receive
ExternalServiceError and decide what to do next
server/email/templates.ts
Exports functions that return HTML strings. No external template engine.
invitationEmailTemplate(params: { inviterEmail: string, appUrl: string }): string
- Returns an HTML email body for the accountability partner invitation, telling the recipient who invited them and providing a link to the app
reminderEmailTemplate(params: { habitName: string, appUrl: string }): string
- Returns an HTML email body for a habit reminder, naming the habit and linking to the app
- This template is imported by the scheduler in Prompt 7
server/social/service.ts
Exports async service functions. All throw AppError subclasses on failure.
sendPartnerInvite(requesterId: string, addresseeEmail: string): Promise<AccountabilityPartner>
- Looks up the addressee by email (case-insensitive); throws
NotFoundError with the message 'No account found with that email address' if not found
- Checks for any existing
accountability_partners row where (requester_id, addressee_id) matches in either direction; throws ConflictError with the message 'You already have a pending or active partnership with this user' if found
- Calls
sendEmail with the invitation template (using invitationEmailTemplate); if sendEmail throws ExternalServiceError, re-throws it immediately — no database record is written
- If the email succeeds, inserts the
AccountabilityPartner row with status: 'pending'
- Returns the newly created row
respondToInvite(partnershipId: string, addresseeId: string, response: 'accepted' | 'rejected'): Promise<AccountabilityPartner>
- Looks up the partnership by
partnershipId; throws NotFoundError if not found or if addressee_id !== addresseeId (the requester cannot respond to their own invite)
- Throws
ConflictError with the message 'This invitation has already been responded to' if the current status is not 'pending'
- Updates
status to response; returns the updated row
getPartnerships(userId: string): Promise<PartnershipWithUser[]>
- Returns all
accountability_partners rows where status = 'accepted' and the user is either requester_id or addressee_id
- Joins to
users to include the partner's email and timezone
PartnershipWithUser: AccountabilityPartner & { partnerEmail: string, partnerTimezone: string }
getPartnerHabits(viewerUserId: string, partnerUserId: string): Promise<HabitWithStreak[]>
- Verifies an accepted partnership exists between
viewerUserId and partnerUserId (in either direction); throws NotFoundError if no accepted partnership exists
- Returns only the partner's habits where
is_public = true and archived_at IS NULL
- Calculates streak for each habit using the partner's timezone, by calling
calculateStreak from server/habits/service.ts
- Returns
HabitWithStreak[]
getPublicHabitCount(nameNormalized: string, category: string | null): Promise<number>
- Counts the number of distinct users who have a public (
is_public = true), non-archived habit with the given name_normalized
- If
category is not null, additionally filters by category
- Returns the count as a number
getLeaderboard(category: string | null, limit: number): Promise<LeaderboardEntry[]>
LeaderboardEntry: { userId: string, email: string, habitName: string, habitId: string, streak: number }
- Fetches all public (
is_public = true), non-archived habits, filtered by category if provided
- For each habit, looks up the owner's timezone and calls
calculateStreak
- Sorts results by
streak descending
- Returns the top
limit entries; limit must be capped at 100 regardless of what the caller passes
server/social/routes.ts
Exports an Express Router. All routes require requireAuth. Mount this router in server/router.ts at /social.
POST /social/partners/invite — body: { email: string } (validate with Zod inline — email must be a valid email string); calls sendPartnerInvite(req.user.id, email); responds with status 201 and { partnership: AccountabilityPartner }
PATCH /social/partners/:partnershipId — body: { response: 'accepted' | 'rejected' } (validate with Zod inline); calls respondToInvite(partnershipId, req.user.id, response); responds with { partnership: AccountabilityPartner }
GET /social/partners — calls getPartnerships(req.user.id); responds with { partnerships: PartnershipWithUser[] }
GET /social/partners/:partnerUserId/habits — calls getPartnerHabits(req.user.id, partnerUserId); responds with { habits: HabitWithStreak[] }
GET /social/discover — query params: name (required string), category (optional string); calls getPublicHabitCount(nameNormalized, category ?? null) where nameNormalized is the result of normalizeHabitName(name) from server/habits/service.ts; responds with { count: number }
GET /social/leaderboard — query params: category (optional string), limit (optional integer, default 20, maximum 100); calls getLeaderboard(category ?? null, limit); responds with { leaderboard: LeaderboardEntry[] }
Validation errors from Zod must be caught at the route layer and thrown as ValidationError.
Do not build yet: the scheduler, user settings routes, or any frontend files.
Check before continuing (do this yourself, do not paste it):
POST /social/partners/invite with a valid email address belonging to another account should return a 201 with a partnership record, and an invitation email should appear in the Resend dashboard's sent log.
- Calling
POST /social/partners/invite a second time for the same pair should return 409.
PATCH /social/partners/:partnershipId with { "response": "accepted" } from the addressee's session should update the status to accepted; the same call from the requester's session should return 404.
GET /social/leaderboard should return an array (possibly empty if no public habits with check-ins exist yet).
- If something is wrong: paste the error back to the agent and ask it to fix before continuing.
Prompt 7
Run this after Prompt 6 is complete.
You'll end up with an automated email reminder system. Every minute, the app checks who has a reminder due and hasn't completed their habit yet, then sends them an email. You'll be able to confirm it works by checking the Resend sent log after the scheduled time passes.
Paste everything below into your AI coding tool:
We are building a Strata on Replit. Prompts 1–6 established the schema, server, auth, habits, reminders, social features, and the email client. This prompt wires the node-cron background scheduler into the Express process. The scheduler fires every minute, checks which reminders are due, and sends emails for habits that haven't been completed today.
server/scheduler/index.ts
Exports:
Update server/index.ts
Import startScheduler from server/scheduler/index.ts and call it once after the server begins listening. Do not call it before the server is listening — the scheduler tick may attempt database queries, and the database connection should be confirmed alive before scheduling begins.
Do not build yet: user settings routes or any frontend files.
Check before continuing (do this yourself, do not paste it):
- Restart the server (run
npm run dev in the Shell). The Shell output should include a log line containing scheduler_started.
- Set a reminder for the current time (or one minute from now) on a habit that is scheduled for today and has no check-in yet. Wait for the next minute to tick over. The Resend dashboard's sent log should show the email was sent.
- Log a check-in for that habit, then wait for the same reminder time to fire again (or set a new reminder for the next minute). The email should not be sent a second time, and the Shell log should not contain
reminder_sent for that habit.
- If something is wrong: paste the Shell log output back to the agent and ask it to fix the scheduler logic before continuing.
Prompt 8
Run this after Prompt 7 is complete.
You'll end up with user settings endpoints — users can update their timezone or email address, and permanently delete their account. Deleting an account removes all associated habits, check-ins, reminders, and partnerships automatically via the database's cascade rules set up in Prompt 1.
Paste everything below into your AI coding tool:
We are building a Strata on Replit. Prompts 1–7 established the schema, server, auth, habits, reminders, social features, email, and the scheduler. This prompt adds user settings: updating timezone and email, and deleting the account.
server/users/validation.ts
Exports Zod schemas. These files must not import any Node.js-only modules so they can be bundled by Vite for the frontend in Prompt 9.
Export:
updateUserSchema — object with timezone (string, optional) and email (Zod email string, optional); refined so that at least one of the two fields must be present (use .refine to enforce this, with the message 'At least one field must be provided')
deleteAccountSchema — object with confirmEmail (string, required)
server/users/service.ts
Exports async service functions. The PublicUser shape used here is { id: string, email: string, timezone: string } — the same shape returned by auth routes.
updateUser(userId: string, data: { timezone?: string, email?: string }): Promise<PublicUser>
- If
email is provided, queries the users table to check whether any other user already owns that email (case-insensitive); throws ConflictError with the message 'An account with this email already exists' if so
- Updates the matching fields on the user row
- Returns the updated user as
PublicUser
deleteUser(userId: string, confirmEmail: string): Promise<void>
- Looks up the user by
userId; throws NotFoundError if not found
- Compares
confirmEmail to the stored email case-insensitively; throws ValidationError with the message 'Email confirmation does not match your account email' if they do not match
- Deletes the user row; the database cascade constraints from Prompt 1 remove all associated habits, check-ins, reminders, and partnerships automatically
server/users/routes.ts
Exports an Express Router. All routes require requireAuth. Mount this router in server/router.ts at /users.
PATCH /users/me
- Validates the request body with
updateUserSchema; on failure throws ValidationError
- Calls
updateUser(req.user.id, data)
- Responds with
{ user: PublicUser }
DELETE /users/me
- Validates the request body with
deleteAccountSchema; on failure throws ValidationError
- Calls
deleteUser(req.user.id, confirmEmail)
- Destroys the session after successful deletion (calls
req.logout and req.session.destroy)
- Responds with
{ success: true }
Validation errors from Zod must be caught at the route layer and thrown as ValidationError.
Do not build yet: any frontend files.
Check before continuing (do this yourself, do not paste it):
PATCH /users/me with { "timezone": "America/New_York" } should return the updated user with the new timezone.
PATCH /users/me with an email address that already belongs to another account should return 409.
DELETE /users/me with the correct confirmEmail should return { success: true }; a subsequent GET /auth/me with the same session should return 401.
DELETE /users/me with the wrong confirmEmail should return 400 with a message containing 'Email confirmation does not match'.
- If something is wrong: paste the error back to the agent and ask it to fix before continuing.
Prompt 9
Run this after Prompt 8 is complete.
You'll end up with the complete browser interface — every screen the user interacts with, wired to the API you've built. After this prompt, the app is fully functional end-to-end: users can register, create habits, log check-ins, receive email reminders, invite accountability partners, and view leaderboards, all from the browser.
Paste everything below into your AI coding tool:
We are building a Strata on Replit. Prompts 1–8 built the complete backend: database, server, auth, habits, reminders, social features, email, scheduler, and user settings. This prompt builds the complete React frontend. It is compiled by Vite and served statically by Express from the public/ directory.
Vite configuration
Create a vite.config.ts at the project root that:
- Sets the root to
client/
- Sets the build output directory to
../public (relative to the client root), so the compiled bundle lands in the public/ directory that Express already serves statically
- Configures a development proxy so that requests from the frontend to
/auth, /habits, /social, /users, and /health are forwarded to the Express server — this prevents CORS issues during local development
Create client/tsconfig.json configured for React with JSX support, targeting modern browsers.
client/src/lib/api.ts
A typed fetch wrapper. Every component and hook uses this — no component calls fetch directly.
Export:
apiFetch<T>(path: string, options?: RequestInit): Promise<T>
- Ensures the path begins with
/
- Sets
credentials: 'include' on every request so session cookies are sent
- On a response that is not OK: reads the response body, extracts the
error field from { error: string }, and throws an Error using that message
- On a network failure: throws an
Error with a user-friendly message such as 'Unable to connect to the server'
- On success: parses and returns the response body as
T
client/src/lib/auth.tsx
Exports:
AuthProvider — a React context provider that fetches GET /auth/me on mount to hydrate the current user; renders its children immediately (does not block render while loading)
useAuth(): { user: PublicUser | null, isLoading: boolean, setUser: (u: PublicUser | null) => void }
PublicUser: { id: string, email: string, timezone: string } — same shape as the backend's PublicUser
isLoading is true until the initial /auth/me fetch resolves
client/src/components/ProtectedRoute.tsx
- Wraps routes that require authentication
- If
isLoading is true, renders a loading indicator
- If
user is null (and not loading), redirects to /login
- Otherwise renders the child route
Component and page files
Create the following files. Each file's responsibilities are described below. Do not create any files beyond this list.
client/src/
pages/
LoginPage.tsx
RegisterPage.tsx
DashboardPage.tsx
HabitDetailPage.tsx
HabitFormPage.tsx
SocialPage.tsx
SettingsPage.tsx
components/
ProtectedRoute.tsx
HabitCard.tsx
CheckInButton.tsx
StreakBadge.tsx
PartnerCard.tsx
LeaderboardTable.tsx
ReminderForm.tsx
lib/
api.ts
auth.tsx
Page responsibilities:
LoginPage.tsx — email/password login form (validates with loginSchema imported from server/auth/validation.ts) and a Google OAuth button that navigates to GET /auth/google. On successful login, updates the auth context and redirects to /.
RegisterPage.tsx — registration form (validates with registerSchema from server/auth/validation.ts). On success, updates the auth context and redirects to /.
DashboardPage.tsx — fetches GET /habits and displays each habit using HabitCard. Shows a button to navigate to /habits/new. Shows today's completion status for each habit.
HabitDetailPage.tsx — fetches GET /habits/:habitId and GET /habits/:habitId/checkins. Displays the habit's details, check-in history with notes, a CheckInButton, a StreakBadge, and a ReminderForm for managing reminders.
HabitFormPage.tsx — used for both creating (/habits/new) and editing (/habits/:habitId/edit) a habit. Validates with createHabitSchema (from server/habits/validation.ts) for create and updateHabitSchema for edit. On save, navigates to the dashboard.
SocialPage.tsx — displays: a form to invite a partner by email (calls POST /social/partners/invite); a list of existing partnerships using PartnerCard; a public habit discovery search input that calls GET /social/discover; a LeaderboardTable populated from GET /social/leaderboard.
SettingsPage.tsx — displays a form to update timezone and email (validates with updateUserSchema from server/users/validation.ts), and a delete-account section with a confirmation email input (validates with deleteAccountSchema). After account deletion, redirects to /login.
Component responsibilities:
HabitCard.tsx — displays a habit's name, category, and StreakBadge. Includes a CheckInButton for logging today's completion. Accepts habit: HabitWithStreak as a prop and an onCheckIn callback.
CheckInButton.tsx — a button that opens an optional note input and calls POST /habits/:habitId/checkins on submit. Accepts habitId: string and onSuccess: () => void as props.
StreakBadge.tsx — displays the current streak count with a visual indicator. Accepts streak: number as a prop.
PartnerCard.tsx — displays a partner's email and a link to view their public habits at /social/partners/:partnerUserId/habits. Accepts partnership: PartnershipWithUser as a prop.
LeaderboardTable.tsx — renders leaderboard entries in ranked order. Accepts entries: LeaderboardEntry[] as a prop.
ReminderForm.tsx — lists existing reminders for a habit, provides an input to add a new one (time picker or text input for HH:MM), and delete buttons for each existing reminder. Accepts habitId: string as a prop and manages its own reminder state by calling the reminders API.
Form validation
Create a top-level shared/ directory with a shared/validation.ts file. Move all Zod schemas (registerSchema, loginSchema, createHabitSchema, updateHabitSchema, updateUserSchema, deleteAccountSchema) into this file. Forms in LoginPage, RegisterPage, HabitFormPage, and SettingsPage must import and use the Zod schemas directly from this shared/ directory. Both the server/ and client/ directories must import the Zod schemas from shared/validation.ts to prevent Vite and TypeScript module resolution panics. Do not duplicate any schema.
client/src/main.tsx
The React entry point. Sets up AuthProvider and React Router with the following routes:
| Path |
Component |
Protected |
/login |
LoginPage |
No |
/register |
RegisterPage |
No |
/ |
DashboardPage |
Yes |
/habits/new |
HabitFormPage |
Yes |
/habits/:habitId |
HabitDetailPage |
Yes |
/habits/:habitId/edit |
HabitFormPage |
Yes |
/social |
SocialPage |
Yes |
/settings |
SettingsPage |
Yes |
* |
Redirect to / |
— |
Build
After creating all files, run vite build to compile the frontend bundle into public/. Confirm the build succeeds before considering this prompt done.
Do not build: any new backend files. All server-side code is complete from earlier prompts.
Check before continuing (do this yourself, do not paste it):
- Run
npm run build in the Shell. It should complete without TypeScript or Vite errors and place files in public/.
- Open your Repl's public URL. Without being logged in, you should be redirected to
/login.
- Register a new account — you should land on the Dashboard with zero habits.
- Create a habit with at least one recurrence day. The Dashboard should display it with a streak of 0.
- Click the check-in button for the habit. The streak should increment to 1.
- Navigate to Settings, change your timezone, and return to the Dashboard — the streak display should remain consistent.
- If something is wrong: paste the browser console error or the build error back to the agent and ask it to fix before considering the build complete.