Skip to content
This repository was archived by the owner on Sep 25, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5c945cb
Phase 1: scaffold Next.js + Tailwind v3 + shadcn/ui (hand-written, no…
tommyqhoang Jul 29, 2026
317343e
Phase 1: Prisma schema (Business, User, Membership, Auth.js adapter t…
tommyqhoang Jul 29, 2026
de03a19
Phase 1: password hashing utility (argon2)
tommyqhoang Jul 29, 2026
8c53696
Phase 1: centralized Zod validation pattern for auth
tommyqhoang Jul 29, 2026
f1c4acc
Phase 1: Vitest + React Testing Library harness
tommyqhoang Jul 29, 2026
8a97ac4
Phase 1: pino logging + Sentry error capture
tommyqhoang Jul 29, 2026
792570b
Phase 1: CSRF same-origin check helper
tommyqhoang Jul 30, 2026
f13e7b5
Phase 1: gitignore local skill-tooling artifacts
tommyqhoang Jul 30, 2026
a8bb697
Phase 1: Resend client for auth emails (verify, reset)
tommyqhoang Jul 30, 2026
242e507
Phase 1: Auth.js adapter config + getSessionContext tenant-scoping he…
tommyqhoang Jul 30, 2026
1183d38
Phase 1: registration flow + email verification
tommyqhoang Jul 30, 2026
bd66c40
Phase 1: don't fail registration on post-commit verification-email se…
tommyqhoang Jul 30, 2026
048b994
Phase 1: login flow with direct DB session creation + logout route
tommyqhoang Jul 30, 2026
699b935
Phase 1: password reset flow with single-use tokens and session inval…
tommyqhoang Jul 30, 2026
bac7581
Phase 1: UI shell (public + authed route groups, verify-email banner)
tommyqhoang Jul 30, 2026
00e207f
Phase 1: fix missing Suspense boundary around useSearchParams in rese…
tommyqhoang Jul 30, 2026
dde2814
Phase 1: Redis + BullMQ wiring with smoke-test job
tommyqhoang Jul 30, 2026
2b0e6cc
Phase 1: close QueueEvents connection in smoke-test cleanup
tommyqhoang Jul 30, 2026
43634eb
Phase 1: /health endpoint (DB + Redis, fail-loud 503)
tommyqhoang Jul 30, 2026
22b0f82
Phase 1: component tests for auth forms
tommyqhoang Jul 30, 2026
f0beb3d
Phase 1: Playwright E2E setup + auth flow coverage
tommyqhoang Jul 30, 2026
a431ed3
Phase 1: wire CI to run real lint/typecheck/tests against Postgres+Re…
tommyqhoang Jul 30, 2026
f459004
Phase 1: exclude generated files from lint, relax no-explicit-any for…
tommyqhoang Jul 30, 2026
f69ef47
Phase 1: Foundation — Next.js + auth + multi-tenant data model + Redi…
tommyqhoang Aug 1, 2026
6dc6e35
Phase 1: ignore .claude/worktrees and nested .next build output in li…
tommyqhoang Aug 1, 2026
5197351
Phase 1: mark Foundation tasks done in todo.md + railway.toml config
tommyqhoang Aug 1, 2026
a7b928d
Phase 1: fix BullMQ smoke test race that timed out CI on fresh Redis
tommyqhoang Aug 2, 2026
ea670ae
Update .gitignore
tommyqhoang Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,7 @@ MICROSOFT_CLIENT_ID=""
MICROSOFT_CLIENT_SECRET=""

# Observability
SENTRY_DSN=""
SENTRY_DSN=""
SENTRY_ORG=""
SENTRY_PROJECT=""
LOG_LEVEL="info"
62 changes: 57 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,38 @@ on:
push:
branches: [main]

# Minimal for Phase 0: install + lint + typecheck + unit tests.
# Phase 1 replaces the placeholder package.json scripts with real
# ESLint / tsc / Vitest invocations and adds Node/Prisma/Playwright setup.
jobs:
ci:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: customereta
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/customereta?schema=public
REDIS_URL: redis://localhost:6379
AUTH_SECRET: ci-test-secret-not-for-production-use-only
APP_URL: http://localhost:3000
RESEND_API_KEY: re_ci_test_placeholder
RESEND_FROM_EMAIL: no-reply@customereta.com
steps:
- uses: actions/checkout@v4

Expand All @@ -28,11 +54,37 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate Prisma client
# NOTE: `pnpm exec` (not `pnpm dlx`) — dlx fetches the latest published
# Prisma CLI from the registry (currently 7.x), which rejects this
# project's Prisma-6-style `datasource { url = env(...) }` schema
# syntax (pinned devDependency is prisma@6.19.3). Verified locally:
# `pnpm dlx prisma generate` fails with P1012 schema validation error;
# `pnpm exec prisma generate` succeeds using the pinned version.
run: pnpm exec prisma generate

- name: Lint
run: pnpm lint

- name: Typecheck
run: pnpm typecheck

- name: Unit tests
run: pnpm test
- name: Run migrations
# NOTE: intentionally `pnpm exec prisma migrate deploy` directly,
# NOT the `db:migrate:deploy` package.json script — that script
# wraps the command with `dotenv -e .env.local --`, and
# .env.local does not exist in CI (gitignored, local-only).
run: pnpm exec prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/customereta?schema=public

- name: Unit + component tests
run: pnpm test

# Note: test:e2e (Playwright) is intentionally NOT run in this job — it needs
# a built app server and Chromium browser binaries, which is a heavier,
# separate concern (Resend is no longer a blocker since E2E runs with
# MOCK_EMAIL_SEND=1). Add E2E to CI as a follow-up if the team wants E2E
# gating PRs; for Phase 1 it's run locally/manually per the plan (todo.md
# doesn't require E2E in CI for Phase 1, only "Add Playwright; add a first
# unit test to prove the harness runs in CI").
12 changes: 11 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,14 @@ playwright/.cache/
.idea/
.vscode/*
!.vscode/extensions.json
*.swp
*.swp
/lib/generated/prisma

# Local AI tooling artifacts (Claude Code, Windsurf, etc.)
.agents/
.claude/skills/
.claude/worktrees/
.windsurf/skills/
skills-lock.json
# Added by code-review-graph
.code-review-graph/
8 changes: 8 additions & 0 deletions app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function DashboardPage() {
return (
<div>
<h1 className="text-2xl font-semibold">Dashboard</h1>
<p className="text-muted-foreground">Nothing here yet — Phase 2 adds customers and appointments.</p>
</div>
);
}
25 changes: 25 additions & 0 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { getSessionContext } from "@/lib/session";
import { prisma } from "@/lib/prisma";
import { VerifyEmailBanner } from "@/components/verify-email-banner";

export default async function AppLayout({ children }: { children: React.ReactNode }) {
const ctx = await getSessionContext();
if (!ctx) {
redirect("/login");
}

const user = await prisma.user.findUnique({ where: { id: ctx.userId } });

return (
<div className="min-h-screen">
<VerifyEmailBanner verified={!!user?.emailVerified} />
<div className="flex">
<aside className="w-56 border-r p-4">
<p className="font-semibold">CustomerETA</p>
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}
16 changes: 16 additions & 0 deletions app/(public)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Link from "next/link";

export default function PublicLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<header className="flex items-center justify-between border-b p-4">
<Link href="/" className="font-semibold">CustomerETA</Link>
<nav className="space-x-4 text-sm">
<Link href="/login">Log in</Link>
<Link href="/signup">Sign up</Link>
</nav>
</header>
{children}
</div>
);
}
66 changes: 66 additions & 0 deletions app/(public)/login/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use server";

import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
import { parseBody } from "@/lib/validation/parse";
import { loginSchema } from "@/lib/validation/auth.schema";
import { createDatabaseSession, getSessionCookieName } from "@/lib/session";
import { captureServerError } from "@/lib/sentry";
import logger from "@/lib/logger";

const GENERIC_ERROR = "Invalid email or password";

export async function loginAction(formData: FormData): Promise<{ error?: string }> {
const parsed = parseBody(loginSchema, {
email: formData.get("email"),
password: formData.get("password"),
});

if (!parsed.success) {
return { error: GENERIC_ERROR };
}

const { email, password } = parsed.data;

try {
const user = await prisma.user.findUnique({
where: { email },
include: { memberships: true },
});

if (!user) {
return { error: GENERIC_ERROR };
}

const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return { error: GENERIC_ERROR };
}

const firstMembership = user.memberships[0];
if (!firstMembership) {
return { error: "No business is associated with this account" };
}

const { sessionToken, expires } = await createDatabaseSession(
user.id,
firstMembership.businessId
);

const cookieStore = await cookies();
cookieStore.set(getSessionCookieName(), sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires,
path: "/",
});

logger.info({ userId: user.id }, "user logged in");
return {};
} catch (err) {
captureServerError(err, { where: "loginAction", email });
return { error: "Something went wrong. Please try again." };
}
}
44 changes: 44 additions & 0 deletions app/(public)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { loginAction } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);

async function handleSubmit(formData: FormData) {
setPending(true);
setError(null);
const result = await loginAction(formData);
setPending(false);
if (result.error) {
setError(result.error);
return;
}
router.push("/dashboard");
}

return (
<form action={handleSubmit} className="mx-auto max-w-sm space-y-4 py-16">
<h1 className="text-2xl font-semibold">Log in</h1>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required />
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Logging in…" : "Log in"}
</Button>
</form>
);
}
7 changes: 7 additions & 0 deletions app/(public)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function HomePage() {
return (
<div className="mx-auto max-w-2xl px-4 py-24 text-center">
<h1 className="text-4xl font-bold">Connect your schedule and automatically keep customers informed through text and email.</h1>
</div>
);
}
80 changes: 80 additions & 0 deletions app/(public)/reset-password/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use server";

import { createHash, randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/password";
import { parseBody } from "@/lib/validation/parse";
import { requestPasswordResetSchema, resetPasswordSchema } from "@/lib/validation/auth.schema";
import { sendPasswordResetEmail } from "@/lib/resend";
import { captureServerError } from "@/lib/sentry";
import logger from "@/lib/logger";

const GENERIC_MESSAGE = "If an account exists for that email, a reset link has been sent.";

export async function requestPasswordResetAction(
formData: FormData
): Promise<{ message: string }> {
const parsed = parseBody(requestPasswordResetSchema, { email: formData.get("email") });
if (!parsed.success) {
return { message: GENERIC_MESSAGE };
}

const user = await prisma.user.findUnique({ where: { email: parsed.data.email } });
if (!user) {
return { message: GENERIC_MESSAGE };
}

const rawToken = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(rawToken).digest("hex");

try {
await prisma.passwordResetToken.create({
data: {
userId: user.id,
tokenHash,
expires: new Date(Date.now() + 60 * 60 * 1000),
},
});
await sendPasswordResetEmail(user.email, rawToken);
} catch (err) {
captureServerError(err, { where: "requestPasswordResetAction", userId: user.id });
}

return { message: GENERIC_MESSAGE };
}

export async function resetPasswordAction(formData: FormData): Promise<{ error?: string }> {
const parsed = parseBody(resetPasswordSchema, {
token: formData.get("token"),
password: formData.get("password"),
});
if (!parsed.success) {
return { error: parsed.issues[0]?.message ?? "Invalid input" };
}

const { token, password } = parsed.data;
const tokenHash = createHash("sha256").update(token).digest("hex");

const record = await prisma.passwordResetToken.findUnique({ where: { tokenHash } });
if (!record || record.usedAt || record.expires < new Date()) {
return { error: "This reset link is invalid or has expired." };
}

const passwordHash = await hashPassword(password);

try {
await prisma.$transaction([
prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }),
prisma.passwordResetToken.update({
where: { tokenHash },
data: { usedAt: new Date() },
}),
prisma.session.deleteMany({ where: { userId: record.userId } }),
]);
logger.info({ userId: record.userId }, "password reset completed");
return {};
} catch (err) {
captureServerError(err, { where: "resetPasswordAction", userId: record.userId });
return { error: "Something went wrong. Please try again." };
}
}
Loading
Loading