diff --git a/README.md b/README.md index ce12765..d5cde05 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,6 @@ npx tsx --env-file=.env.local scripts/benchmark-sample.ts # live pipeline + to |----------|--------|-----| | **Share link** (+ password / expiry) | Shipped | Read-only stakeholder access without org membership or custom email domains | | **Export PDF / CSV** | Shipped | Offline handoff to execs and clients | -| **Team workspaces** | Schema + API only | Multi-tenant path exists in Prisma; UI deferred to keep the portfolio demo focused | --- @@ -309,7 +308,7 @@ npx tsx scripts/benchmark-sample.ts --estimate-only # Case study metrics (offl 1. **Problem** — Unstructured review text doesn't scale; manual theming breaks past dozens of rows. 2. **Approach** — Embeddings + k-means + LLM summarization with atomic job claiming and share-gated read-only reports. -3. **Tradeoff** — Built org/tenant models but shipped **share-link collaboration** instead of email invites (no custom domain on Vercel free tier). +3. **Tradeoff** — Chose **share-link collaboration** over team workspaces and email invites (no custom domain on Vercel free tier; removed half-built org schema in RL-013). 4. **Reliability** — Inngest + `waitUntil` fallback, Upstash rate limits, `/api/health`, **93 unit tests**, Playwright e2e, GitHub Actions CI. 5. **Outcome** — CSV → themed report in **< 60s**, **~$0.0004** per 12-review sample run, PDF/CSV export, password-protected links for stakeholders. diff --git a/app/api/analysis/[id]/export/route.ts b/app/api/analysis/[id]/export/route.ts index 4bfa043..27cc72e 100644 --- a/app/api/analysis/[id]/export/route.ts +++ b/app/api/analysis/[id]/export/route.ts @@ -7,7 +7,7 @@ import { shareAccessCookieName, verifyShareAccessToken, } from "@/lib/share-access"; -import { canViewSession, isSessionCreator } from "@/lib/org/access"; +import { isSessionCreator } from "@/lib/session-access"; interface RouteContext { params: { id: string }; @@ -33,7 +33,6 @@ export async function GET(_req: Request, { params }: RouteContext) { select: { id: true, userId: true, - organizationId: true, fileName: true, sharePasswordHash: true, shareExpiresAt: true, @@ -61,9 +60,8 @@ export async function GET(_req: Request, { params }: RouteContext) { const authUser = await auth(); const userId = authUser?.user?.id; const isCreator = isSessionCreator(userId, session); - const hasTeamAccess = await canViewSession(userId, session); - if (!isCreator && !hasTeamAccess) { + if (!isCreator) { if (isShareExpired(session.shareExpiresAt)) { return NextResponse.json( { success: false as const, error: "This link has expired." }, diff --git a/app/api/analysis/[id]/status/route.test.ts b/app/api/analysis/[id]/status/route.test.ts index 166bd3f..86f289e 100644 --- a/app/api/analysis/[id]/status/route.test.ts +++ b/app/api/analysis/[id]/status/route.test.ts @@ -8,7 +8,6 @@ import { const mockFindUnique = vi.fn(); const mockAuth = vi.fn(); const mockCookiesGet = vi.fn(); -const mockCanViewSession = vi.fn(); vi.mock("@/lib/prisma", () => ({ prisma: { @@ -28,12 +27,6 @@ vi.mock("next/headers", () => ({ }), })); -vi.mock("@/lib/org/access", () => ({ - isSessionCreator: (userId: string | undefined, session: { userId: string | null }) => - Boolean(userId && session.userId === userId), - canViewSession: (...args: unknown[]) => mockCanViewSession(...args), -})); - import { GET } from "./route"; const SLUG = "test-slug-abc"; @@ -52,7 +45,6 @@ function makeSession(overrides: Record = {}) { return { id: SESSION_ID, userId: OWNER_ID, - organizationId: null, status: "COMPLETED", totalReviews: 42, updatedAt: new Date(), @@ -72,7 +64,6 @@ function callGet(slug = SLUG) { beforeEach(() => { vi.clearAllMocks(); mockAuth.mockResolvedValue(null); - mockCanViewSession.mockResolvedValue(false); mockCookiesGet.mockReturnValue(undefined); }); diff --git a/app/api/analysis/[id]/status/route.ts b/app/api/analysis/[id]/status/route.ts index 754ef82..498ddef 100644 --- a/app/api/analysis/[id]/status/route.ts +++ b/app/api/analysis/[id]/status/route.ts @@ -7,7 +7,7 @@ import { checkShareAccess, shareAccessCookieName, } from "@/lib/share-access"; -import { canViewSession, isSessionCreator } from "@/lib/org/access"; +import { isSessionCreator } from "@/lib/session-access"; import type { ApiResponse, SentimentBreakdown } from "@/types"; import type { ThemeAnalysis, StoredAnalysisResult } from "@/features/analysis/types"; @@ -34,7 +34,6 @@ export async function GET( select: { id: true, userId: true, - organizationId: true, status: true, totalReviews: true, updatedAt: true, @@ -62,9 +61,8 @@ export async function GET( const authUser = await auth(); const userId = authUser?.user?.id; const isCreator = isSessionCreator(userId, session); - const hasTeamAccess = await canViewSession(userId, session); - if (!isCreator && !hasTeamAccess) { + if (!isCreator) { const cookieToken = cookies().get( shareAccessCookieName(session.id) )?.value; diff --git a/app/api/analysis/route.ts b/app/api/analysis/route.ts index 1d17316..455bea5 100644 --- a/app/api/analysis/route.ts +++ b/app/api/analysis/route.ts @@ -15,7 +15,6 @@ import { unauthorizedResponse, } from "@/lib/auth-helpers"; import { mapCreateAnalysisError } from "@/lib/create-analysis-errors"; -import { getMembership } from "@/lib/org/access"; import { createLogger } from "@/lib/logger"; import type { ApiResponse } from "@/types"; @@ -77,22 +76,7 @@ export async function POST( ); } - const { reviews, sourceType, sourceUrl, fileName, organizationId } = - parsed.data; - - if (organizationId) { - const membership = await getMembership(authUser.userId, organizationId); - if (!membership) { - return NextResponse.json( - { - success: false as const, - error: "You are not a member of that workspace", - code: "FORBIDDEN", - }, - { status: 403 } - ); - } - } + const { reviews, sourceType, sourceUrl, fileName } = parsed.data; const prismaSourceType = sourceType === "csv" @@ -111,7 +95,6 @@ export async function POST( data: { shareableSlug: generateShareableSlug(), userId: authUser.userId, - organizationId: organizationId ?? null, sourceType: prismaSourceType, sourceUrl: sourceUrl ?? null, fileName: fileName ?? null, diff --git a/app/api/orgs/[slug]/route.ts b/app/api/orgs/[slug]/route.ts deleted file mode 100644 index 14783a6..0000000 --- a/app/api/orgs/[slug]/route.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { randomUUID } from "crypto"; -import { NextResponse } from "next/server"; -import { Prisma } from "@prisma/client"; -import { z } from "zod"; -import { prisma } from "@/lib/prisma"; -import { env } from "@/lib/env"; -import { - requireAuthUser, - unauthorizedResponse, -} from "@/lib/auth-helpers"; -import { createLogger } from "@/lib/logger"; -import { getMembership, requireOrgRole } from "@/lib/org/access"; -import { getAppUrl, sendTeamInviteEmail } from "@/lib/email/team-invite"; -import type { ApiResponse } from "@/types"; - -interface RouteContext { - params: { slug: string }; -} - -export async function GET( - _request: Request, - { params }: RouteContext -): Promise>> { - const authUser = await requireAuthUser(); - if (!authUser) return unauthorizedResponse(); - - const org = await prisma.organization.findUnique({ - where: { slug: params.slug }, - include: { - members: { - include: { - user: { select: { id: true, name: true, email: true, image: true } }, - }, - orderBy: { joinedAt: "asc" }, - }, - invites: { - where: { expiresAt: { gt: new Date() } }, - orderBy: { createdAt: "desc" }, - }, - _count: { select: { sessions: true } }, - }, - }); - - if (!org) { - return NextResponse.json( - { success: false as const, error: "Workspace not found" }, - { status: 404 } - ); - } - - const membership = await getMembership(authUser.userId, org.id); - if (!membership) { - return NextResponse.json( - { success: false as const, error: "Forbidden" }, - { status: 403 } - ); - } - - return NextResponse.json({ - success: true as const, - data: { - org: { - id: org.id, - name: org.name, - slug: org.slug, - plan: org.plan, - role: membership.role, - sessionCount: org._count.sessions, - members: org.members.map((m) => ({ - id: m.id, - role: m.role, - joinedAt: m.joinedAt.toISOString(), - user: m.user, - })), - pendingInvites: org.invites.map((i) => ({ - id: i.id, - email: i.email, - role: i.role, - expiresAt: i.expiresAt.toISOString(), - })), - }, - }, - }); -} - -const inviteSchema = z.object({ - email: z.string().email(), - role: z.enum(["ADMIN", "MEMBER"]).default("MEMBER"), -}); - -export async function POST( - request: Request, - { params }: RouteContext -): Promise< - NextResponse< - ApiResponse<{ - inviteUrl: string; - emailSent: boolean; - emailError?: string; - }> - > -> { - const authUser = await requireAuthUser(); - if (!authUser) return unauthorizedResponse(); - - const log = createLogger({ - userId: authUser.userId, - component: "org-invite", - }); - - const org = await prisma.organization.findUnique({ - where: { slug: params.slug }, - select: { id: true }, - }); - if (!org) { - return NextResponse.json( - { success: false as const, error: "Workspace not found" }, - { status: 404 } - ); - } - - const membership = await requireOrgRole(authUser.userId, org.id, "ADMIN"); - if (!membership) { - return NextResponse.json( - { success: false as const, error: "Forbidden" }, - { status: 403 } - ); - } - - try { - const body: unknown = await request.json(); - const parsed = inviteSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { success: false as const, error: "Invalid email" }, - { status: 400 } - ); - } - - const email = parsed.data.email.toLowerCase(); - const existingMember = await prisma.organizationMember.findFirst({ - where: { - organizationId: org.id, - user: { email: { equals: email, mode: "insensitive" } }, - }, - }); - if (existingMember) { - return NextResponse.json( - { success: false as const, error: "That user is already a member" }, - { status: 400 } - ); - } - - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1_000); - let invite; - try { - invite = await prisma.organizationInvite.upsert({ - where: { - organizationId_email: { organizationId: org.id, email }, - }, - create: { - organizationId: org.id, - email, - role: parsed.data.role, - expiresAt, - invitedById: authUser.userId, - }, - update: { - role: parsed.data.role, - expiresAt, - invitedById: authUser.userId, - token: randomUUID(), - }, - }); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - (error.code === "P2021" || error.code === "P2022") - ) { - log.error("Invite table missing", { error: error.message }); - return NextResponse.json( - { - success: false as const, - error: - "Team invites are not set up on this database. Run prisma migrate deploy.", - }, - { status: 503 } - ); - } - throw error; - } - - const inviteUrl = `${getAppUrl()}/team/invite/${invite.token}`; - - const orgMeta = await prisma.organization.findUnique({ - where: { id: org.id }, - select: { name: true }, - }); - - const emailResult = await sendTeamInviteEmail({ - to: email, - inviteUrl, - orgName: orgMeta?.name ?? "your team", - inviterName: authUser.session.user?.name ?? authUser.session.user?.email, - }); - - log.info("Invite created", { - orgId: org.id, - email, - emailSent: emailResult.emailSent, - emailError: emailResult.emailError, - }); - - return NextResponse.json({ - success: true as const, - data: { - inviteUrl, - emailSent: emailResult.emailSent, - ...(emailResult.emailError - ? { emailError: emailResult.emailError } - : {}), - }, - }); - } catch (error) { - log.error("Invite failed", { error: String(error) }); - const detail = - env.NODE_ENV === "development" && error instanceof Error - ? error.message - : undefined; - return NextResponse.json( - { - success: false as const, - error: detail ?? "Failed to create invite", - }, - { status: 500 } - ); - } -} diff --git a/app/api/orgs/invites/accept/route.ts b/app/api/orgs/invites/accept/route.ts deleted file mode 100644 index 77b61a6..0000000 --- a/app/api/orgs/invites/accept/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { prisma } from "@/lib/prisma"; -import { - requireAuthUser, - unauthorizedResponse, -} from "@/lib/auth-helpers"; -import { createLogger } from "@/lib/logger"; - -const acceptSchema = z.object({ - token: z.string().min(1), -}); - -export async function POST(request: Request) { - const authUser = await requireAuthUser(); - if (!authUser) return unauthorizedResponse(); - - const log = createLogger({ - userId: authUser.userId, - component: "accept-org-invite", - }); - - try { - const body: unknown = await request.json(); - const parsed = acceptSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { success: false as const, error: "Invalid invite" }, - { status: 400 } - ); - } - - const invite = await prisma.organizationInvite.findUnique({ - where: { token: parsed.data.token }, - include: { organization: { select: { id: true, name: true, slug: true } } }, - }); - - if (!invite || invite.expiresAt < new Date()) { - return NextResponse.json( - { success: false as const, error: "Invite expired or not found" }, - { status: 404 } - ); - } - - const userEmail = authUser.session.user?.email?.toLowerCase(); - if (!userEmail || userEmail !== invite.email.toLowerCase()) { - return NextResponse.json( - { - success: false as const, - error: "Sign in with the invited email address to accept", - }, - { status: 403 } - ); - } - - await prisma.$transaction([ - prisma.organizationMember.upsert({ - where: { - organizationId_userId: { - organizationId: invite.organizationId, - userId: authUser.userId, - }, - }, - create: { - organizationId: invite.organizationId, - userId: authUser.userId, - role: invite.role, - }, - update: {}, - }), - prisma.organizationInvite.delete({ where: { id: invite.id } }), - ]); - - log.info("Invite accepted", { - orgId: invite.organizationId, - userId: authUser.userId, - }); - - return NextResponse.json({ - success: true as const, - data: { - slug: invite.organization.slug, - name: invite.organization.name, - }, - }); - } catch (error) { - log.error("Accept invite failed", { error: String(error) }); - return NextResponse.json( - { success: false as const, error: "Failed to accept invite" }, - { status: 500 } - ); - } -} diff --git a/app/api/orgs/route.ts b/app/api/orgs/route.ts deleted file mode 100644 index 2825d7c..0000000 --- a/app/api/orgs/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { prisma } from "@/lib/prisma"; -import { - requireAuthUser, - unauthorizedResponse, -} from "@/lib/auth-helpers"; -import { createLogger } from "@/lib/logger"; -import { - generateUniqueOrgSlug, - listUserOrganizations, -} from "@/lib/org/access"; -import type { ApiResponse } from "@/types"; - -export interface OrgSummary { - id: string; - name: string; - slug: string; - plan: string; - role: string; - memberCount: number; - sessionCount: number; -} - -const createOrgSchema = z.object({ - name: z.string().trim().min(2).max(80), -}); - -export async function GET(): Promise>> { - const authUser = await requireAuthUser(); - if (!authUser) return unauthorizedResponse(); - - const memberships = await listUserOrganizations(authUser.userId); - const orgs: OrgSummary[] = memberships.map((m) => ({ - id: m.organization.id, - name: m.organization.name, - slug: m.organization.slug, - plan: m.organization.plan, - role: m.role, - memberCount: m.organization._count.members, - sessionCount: m.organization._count.sessions, - })); - - return NextResponse.json({ success: true as const, data: { orgs } }); -} - -export async function POST( - request: Request -): Promise>> { - const authUser = await requireAuthUser(); - if (!authUser) return unauthorizedResponse(); - - const log = createLogger({ - userId: authUser.userId, - component: "create-org", - }); - - try { - const body: unknown = await request.json(); - const parsed = createOrgSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { success: false as const, error: "Invalid workspace name" }, - { status: 400 } - ); - } - - const slug = await generateUniqueOrgSlug(parsed.data.name); - - const org = await prisma.organization.create({ - data: { - name: parsed.data.name, - slug, - members: { - create: { - userId: authUser.userId, - role: "OWNER", - }, - }, - }, - include: { - _count: { select: { members: true, sessions: true } }, - }, - }); - - log.info("Organization created", { orgId: org.id, slug }); - - return NextResponse.json({ - success: true as const, - data: { - org: { - id: org.id, - name: org.name, - slug: org.slug, - plan: org.plan, - role: "OWNER", - memberCount: org._count.members, - sessionCount: org._count.sessions, - }, - }, - }); - } catch (error) { - log.error("Failed to create organization", { error: String(error) }); - return NextResponse.json( - { success: false as const, error: "Failed to create workspace" }, - { status: 500 } - ); - } -} diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index 9d4b91d..a95449d 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -5,7 +5,6 @@ import { unauthorizedResponse, } from "@/lib/auth-helpers"; import { createLogger } from "@/lib/logger"; -import { getMembership } from "@/lib/org/access"; import type { ApiResponse } from "@/types"; import type { SessionCardData } from "@/features/sessions"; @@ -15,7 +14,7 @@ export interface SessionsListResponse { const MAX_SESSIONS = 100; -export async function GET(request: Request): Promise< +export async function GET(): Promise< NextResponse> > { const authUser = await requireAuthUser(); @@ -23,43 +22,14 @@ export async function GET(request: Request): Promise< return unauthorizedResponse(); } - const { searchParams } = new URL(request.url); - const scope = searchParams.get("scope") ?? "personal"; - const log = createLogger({ userId: authUser.userId, component: "sessions-api", }); try { - let where: { userId?: string; organizationId?: string | null } = { - userId: authUser.userId, - organizationId: null, - }; - - if (scope !== "personal") { - const org = await prisma.organization.findUnique({ - where: { slug: scope }, - select: { id: true }, - }); - if (!org) { - return NextResponse.json( - { success: false as const, error: "Workspace not found" }, - { status: 404 } - ); - } - const membership = await getMembership(authUser.userId, org.id); - if (!membership) { - return NextResponse.json( - { success: false as const, error: "Forbidden" }, - { status: 403 } - ); - } - where = { organizationId: org.id }; - } - const raw = await prisma.analysisSession.findMany({ - where, + where: { userId: authUser.userId }, orderBy: { createdAt: "desc" }, take: MAX_SESSIONS, select: { diff --git a/app/compare/page.tsx b/app/compare/page.tsx index 8afa5df..693bb4e 100644 --- a/app/compare/page.tsx +++ b/app/compare/page.tsx @@ -24,17 +24,11 @@ async function loadSnapshot( userId: string, slug: string ): Promise { - const memberships = await prisma.organizationMember.findMany({ - where: { userId }, - select: { organizationId: true }, - }); - const orgIds = memberships.map((m) => m.organizationId); - const session = await prisma.analysisSession.findFirst({ where: { shareableSlug: slug, status: "COMPLETED", - OR: [{ userId }, { organizationId: { in: orgIds } }], + userId, }, select: { shareableSlug: true, @@ -72,16 +66,10 @@ export default async function ComparePage({ searchParams }: Props) { } const userId = authUser.user.id; - const memberships = await prisma.organizationMember.findMany({ - where: { userId }, - select: { organizationId: true }, - }); - const orgIds = memberships.map((m) => m.organizationId); - const analyses = await prisma.analysisSession.findMany({ where: { status: "COMPLETED", - OR: [{ userId }, { organizationId: { in: orgIds } }], + userId, }, orderBy: { createdAt: "desc" }, select: { diff --git a/app/dashboard/[id]/page.tsx b/app/dashboard/[id]/page.tsx index 5daa213..7c6d004 100644 --- a/app/dashboard/[id]/page.tsx +++ b/app/dashboard/[id]/page.tsx @@ -13,9 +13,8 @@ import { ShareGate, ShareExpired } from "./share-gate"; import type { StoredAnalysisResult, ThemeAnalysis } from "@/features/analysis/types"; import type { SentimentBreakdown } from "@/types"; import { - canViewSession, isSessionCreator, -} from "@/lib/org/access"; +} from "@/lib/session-access"; interface Props { params: { id: string }; @@ -60,7 +59,6 @@ export default async function DashboardPage({ params }: Props) { select: { id: true, userId: true, - organizationId: true, status: true, totalReviews: true, fileName: true, @@ -85,10 +83,9 @@ export default async function DashboardPage({ params }: Props) { const authUser = await auth(); const userId = authUser?.user?.id; const isCreator = isSessionCreator(userId, session); - const hasTeamAccess = await canViewSession(userId, session); const isOwner = isCreator; - if (!isCreator && !hasTeamAccess) { + if (!isCreator) { if (isShareExpired(session.shareExpiresAt)) { return ; } diff --git a/app/team/invite/[token]/page.tsx b/app/team/invite/[token]/page.tsx deleted file mode 100644 index d749112..0000000 --- a/app/team/invite/[token]/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; -import { Loader2, Users } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Navbar } from "@/components/layout/navbar"; -import { apiPost } from "@/lib/api"; - -interface Props { - params: { token: string }; -} - -export default function AcceptInvitePage({ params }: Props) { - const router = useRouter(); - const [status, setStatus] = useState<"loading" | "ready" | "done" | "error">( - "loading" - ); - const [orgName, setOrgName] = useState(null); - - useEffect(() => { - setStatus("ready"); - }, []); - - async function accept() { - setStatus("loading"); - try { - const data = await apiPost<{ slug: string; name: string }>( - "/api/orgs/invites/accept", - { token: params.token } - ); - setOrgName(data.name); - setStatus("done"); - toast.success(`Joined ${data.name}`); - router.push("/team"); - } catch (err) { - setStatus("error"); - toast.error( - err instanceof Error ? err.message : "Could not accept invite" - ); - } - } - - return ( -
- -
-
-
- -
-

Join workspace

-

- {status === "done" && orgName - ? `You're now a member of ${orgName}.` - : "You've been invited to collaborate on shared analyses in ReviewLens."} -

- {status !== "done" && ( - - )} -
-
-
- ); -} diff --git a/app/team/page.tsx b/app/team/page.tsx deleted file mode 100644 index c3761b0..0000000 --- a/app/team/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from "next/navigation"; - -/** Team workspaces are deferred; collaboration is share-link first. */ -export default function TeamPage() { - redirect("/sessions"); -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6feab95..2b08169 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -139,21 +139,19 @@ flowchart LR ## 4. Architecture Decision Records -### ADR-001: Share-first vs team workspaces +### ADR-001: Share-first collaboration (no team workspaces) -**Status:** Accepted (share-first primary; workspaces partial) +**Status:** Accepted (share-first only; workspace schema removed in RL-013) -**Context:** Recruiters and PMs need a single link to an analysis report. Some users asked for team lists and org billing. +**Context:** Recruiters and PMs need a single link to an analysis report. A half-built `Organization` / invite model added scope-creep risk without shipping a real team UX. -**Decision:** Optimize for **shareable slug URLs** with optional password + expiry. Dashboard, status, and export use the same share gate. Magic-link auth identifies the owner. - -**Workspaces:** `Organization` / `OrganizationMember` schema exists for future team features but is not the primary UX. Org-scoped sessions are supported in the data model; UI is minimal. +**Decision:** Optimize for **shareable slug URLs** with optional password + expiry. Dashboard, status, and export use the same share gate. Magic-link auth identifies the owner. Analyses are scoped to `User.id` only. **Consequences** - (+) Demo and interview story stay simple: upload → link → share. - (+) Security model is one coherent path (owner vs share viewer). -- (−) Half-built workspace UI is scope-creep risk — see backlog RL-013 (remove or ship). +- (+) No dead schema or API surface for interviewers to probe. --- @@ -290,6 +288,5 @@ See `README.md` § Testing & CI for commands. ## 8. Related backlog -- **RL-013:** Remove or ship workspace feature (ADR-001 consequence). - **RL-012:** Embedding persistence + cost tracking. - **RL-016–020:** Redis status cache, Sentry stages, structured errors, indexes, per-user process rate limits. diff --git a/lib/actions/sessions.ts b/lib/actions/sessions.ts index aed0dd4..c6b275f 100644 --- a/lib/actions/sessions.ts +++ b/lib/actions/sessions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { requireAuthUser } from "@/lib/auth-helpers"; -import { canDeleteSession } from "@/lib/org/access"; +import { canDeleteSession } from "@/lib/session-access"; import { createLogger } from "@/lib/logger"; export async function deleteSession( @@ -23,14 +23,14 @@ export async function deleteSession( try { const session = await prisma.analysisSession.findUnique({ where: { id: sessionId }, - select: { userId: true, organizationId: true }, + select: { userId: true }, }); if (!session) { return { error: "Session not found" }; } - const allowed = await canDeleteSession(authUser.userId, session); + const allowed = canDeleteSession(authUser.userId, session); if (!allowed) { log.warn("Forbidden delete attempt"); return { error: "Forbidden" }; diff --git a/lib/email/team-invite.ts b/lib/email/team-invite.ts deleted file mode 100644 index 14f7aa9..0000000 --- a/lib/email/team-invite.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { env, getEmailFrom } from "@/lib/env"; -import { sendResendEmail } from "@/lib/email/resend"; - -export function getAppUrl(): string { - return env.NEXT_PUBLIC_APP_URL.replace(/\/$/, ""); -} - -export interface SendTeamInviteEmailInput { - to: string; - inviteUrl: string; - orgName: string; - inviterName?: string | null; -} - -export interface SendTeamInviteEmailResult { - emailSent: boolean; - /** Set when email wasn't sent. Invite still exists. */ - emailError?: string; - /** When email wasn't sent (no Resend key), log this for dev. */ - devFallbackUrl?: string; -} - -export async function sendTeamInviteEmail( - input: SendTeamInviteEmailInput -): Promise { - const { to, inviteUrl, orgName, inviterName } = input; - - if (!env.RESEND_API_KEY) { - console.log( - `\n[ReviewLens] Team invite for ${to} (set RESEND_API_KEY to send email):\n${inviteUrl}\n` - ); - return { emailSent: false, devFallbackUrl: inviteUrl }; - } - - const inviterLine = inviterName - ? `${escapeHtml(inviterName)} invited you to join ` - : "You've been invited to join "; - - const result = await sendResendEmail({ - to, - subject: `Join ${orgName} on ReviewLens`, - html: ` -
-

Join ${escapeHtml(orgName)}

-

- ${inviterLine}${escapeHtml(orgName)} on ReviewLens — shared review analyses for your team. -

- - Accept invite - -

- This link expires in 7 days. Sign in with ${escapeHtml(to)} to accept. -

-
- `, - }); - - if (result.emailSent) { - return { emailSent: true }; - } - - console.log( - `\n[ReviewLens] Team invite for ${to} (email not sent — share manually):\n${inviteUrl}\nFrom: ${getEmailFrom()}\nReason: ${result.emailError ?? "unknown"}\n` - ); - - return { - emailSent: false, - emailError: result.emailError, - devFallbackUrl: inviteUrl, - }; -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} diff --git a/lib/org/access.ts b/lib/org/access.ts deleted file mode 100644 index b518b7c..0000000 --- a/lib/org/access.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { OrgRole } from "@prisma/client"; -import { randomBytes } from "crypto"; -import { prisma } from "@/lib/prisma"; - -const ROLE_RANK: Record = { - MEMBER: 1, - ADMIN: 2, - OWNER: 3, -}; - -export function slugifyOrgName(name: string): string { - const base = name - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 40); - return base || "team"; -} - -export async function generateUniqueOrgSlug(name: string): Promise { - const base = slugifyOrgName(name); - for (let i = 0; i < 5; i++) { - const suffix = - i === 0 ? "" : `-${randomBytes(3).toString("base64url")}`; - const slug = `${base}${suffix}`; - const existing = await prisma.organization.findUnique({ where: { slug } }); - if (!existing) return slug; - } - return `${base}-${Date.now().toString(36)}`; -} - -export async function getMembership(userId: string, organizationId: string) { - return prisma.organizationMember.findUnique({ - where: { - organizationId_userId: { organizationId, userId }, - }, - }); -} - -export async function requireOrgRole( - userId: string, - organizationId: string, - minRole: OrgRole -) { - const membership = await getMembership(userId, organizationId); - if (!membership) return null; - if (ROLE_RANK[membership.role] < ROLE_RANK[minRole]) return null; - return membership; -} - -export async function canViewSession( - userId: string | undefined, - session: { userId: string | null; organizationId: string | null } -): Promise { - if (!userId) return false; - if (session.userId === userId) return true; - if (!session.organizationId) return false; - const membership = await getMembership(userId, session.organizationId); - return Boolean(membership); -} - -export function isSessionCreator( - userId: string | undefined, - session: { userId: string | null } -): boolean { - return Boolean(userId && session.userId === userId); -} - -export async function canDeleteSession( - userId: string, - session: { userId: string | null; organizationId: string | null } -): Promise { - if (session.userId === userId) return true; - if (!session.organizationId) return false; - const membership = await requireOrgRole(userId, session.organizationId, "ADMIN"); - return Boolean(membership); -} - -export async function listUserOrganizations(userId: string) { - return prisma.organizationMember.findMany({ - where: { userId }, - include: { - organization: { - select: { - id: true, - name: true, - slug: true, - plan: true, - _count: { select: { members: true, sessions: true } }, - }, - }, - }, - orderBy: { joinedAt: "asc" }, - }); -} diff --git a/lib/session-access.ts b/lib/session-access.ts new file mode 100644 index 0000000..cfda834 --- /dev/null +++ b/lib/session-access.ts @@ -0,0 +1,15 @@ +/** Owner-only session access helpers (share viewers use share-access.ts). */ + +export function isSessionCreator( + userId: string | undefined, + session: { userId: string | null } +): boolean { + return Boolean(userId && session.userId === userId); +} + +export function canDeleteSession( + userId: string, + session: { userId: string | null } +): boolean { + return session.userId === userId; +} diff --git a/lib/validations/review.ts b/lib/validations/review.ts index a081c7c..6081fcc 100644 --- a/lib/validations/review.ts +++ b/lib/validations/review.ts @@ -83,7 +83,6 @@ export const analyzeRequestSchema = z.object({ sourceType: z.enum(["csv", "url", "paste"]), sourceUrl: z.string().url().optional(), fileName: z.string().optional(), - organizationId: z.string().cuid().optional(), }); // ── File upload (client-side pre-check) ─────────────────────────────────────── diff --git a/middleware.ts b/middleware.ts index 0791d4c..77ff223 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,7 +1,7 @@ import { auth } from "@/auth.edge"; import { NextResponse } from "next/server"; -const PROTECTED_PAGE_PREFIXES = ["/analyze", "/sessions", "/compare", "/team"] as const; +const PROTECTED_PAGE_PREFIXES = ["/analyze", "/sessions", "/compare"] as const; export default auth((req) => { const { nextUrl, auth: session } = req; diff --git a/prisma/migrations/20260712000000_drop_workspaces/migration.sql b/prisma/migrations/20260712000000_drop_workspaces/migration.sql new file mode 100644 index 0000000..254aa63 --- /dev/null +++ b/prisma/migrations/20260712000000_drop_workspaces/migration.sql @@ -0,0 +1,17 @@ +-- DropForeignKey +ALTER TABLE "AnalysisSession" DROP CONSTRAINT "AnalysisSession_organizationId_fkey"; + +-- DropIndex +DROP INDEX "AnalysisSession_organizationId_idx"; + +-- AlterTable +ALTER TABLE "AnalysisSession" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "OrganizationInvite"; +DROP TABLE "OrganizationMember"; +DROP TABLE "Organization"; + +-- DropEnum +DROP TYPE "OrgRole"; +DROP TYPE "OrgPlan"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9cfa616..9c45d9a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,8 +20,6 @@ model User { image String? accounts Account[] sessions Session[] - orgMemberships OrganizationMember[] - orgInvitesSent OrganizationInvite[] @relation("OrgInvitedBy") analyses AnalysisSession[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -71,7 +69,6 @@ model AnalysisSession { id String @id @default(cuid()) shareableSlug String @unique userId String? - organizationId String? sourceType SourceType sourceUrl String? fileName String? @@ -82,14 +79,12 @@ model AnalysisSession { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) - result AnalysisResult? - reviews Review[] + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + result AnalysisResult? + reviews Review[] @@index([shareableSlug]) @@index([userId]) - @@index([organizationId]) @@index([createdAt]) } @@ -135,62 +130,3 @@ enum AnalysisStatus { COMPLETED FAILED } - -// ───────────────────────────────────────────────────────────────────────────── -// Team workspaces -// ───────────────────────────────────────────────────────────────────────────── - -model Organization { - id String @id @default(cuid()) - name String - slug String @unique - plan OrgPlan @default(FREE) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - members OrganizationMember[] - invites OrganizationInvite[] - sessions AnalysisSession[] -} - -model OrganizationMember { - id String @id @default(cuid()) - organizationId String - userId String - role OrgRole @default(MEMBER) - joinedAt DateTime @default(now()) - - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([organizationId, userId]) - @@index([userId]) -} - -model OrganizationInvite { - id String @id @default(cuid()) - organizationId String - email String - role OrgRole @default(MEMBER) - token String @unique @default(cuid()) - expiresAt DateTime - invitedById String - createdAt DateTime @default(now()) - - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - invitedBy User @relation("OrgInvitedBy", fields: [invitedById], references: [id], onDelete: Cascade) - - @@unique([organizationId, email]) - @@index([token]) -} - -enum OrgPlan { - FREE - PRO -} - -enum OrgRole { - OWNER - ADMIN - MEMBER -}