diff --git a/dashboard/app/api/v1/events/route.ts b/dashboard/app/api/v1/events/route.ts index cd3cde8..78eeb8d 100644 --- a/dashboard/app/api/v1/events/route.ts +++ b/dashboard/app/api/v1/events/route.ts @@ -1,18 +1,36 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { createEvent } from "@/server/events"; +import { createEventForUser, SessionNotFoundError } from "@/server/events"; import { CreateEventInput } from "@/server/schemas/events"; import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; export const POST = withCors(async (req) => { + let user; + try { + user = requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: err.message }, { status: 401 }); + } + throw err; + } + const body = await req.json(); const parsed = CreateEventInput.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: z.treeifyError(parsed.error) }, { status: 400 }); } - const event = await createEvent(parsed.data); - return NextResponse.json(event, { status: 201 }); + try { + const event = await createEventForUser(user.id, parsed.data); + return NextResponse.json(event, { status: 201 }); + } catch (err) { + if (err instanceof SessionNotFoundError) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + throw err; + } }); export function OPTIONS(req: NextRequest) { diff --git a/dashboard/app/api/v1/sessions/[id]/route.ts b/dashboard/app/api/v1/sessions/[id]/route.ts new file mode 100644 index 0000000..1c90544 --- /dev/null +++ b/dashboard/app/api/v1/sessions/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { endSessionForUser, SessionNotFoundError } from "@/server/sessions"; +import { SessionIdParam } from "@/server/schemas/sessions"; +import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +export function PATCH(req: NextRequest, ctx: RouteContext): Promise { + return withCors(async (r) => { + let user; + try { + user = requireUser(r); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: err.message }, { status: 401 }); + } + throw err; + } + + const params = await ctx.params; + const parsed = SessionIdParam.safeParse(params); + if (!parsed.success) { + return NextResponse.json({ error: z.treeifyError(parsed.error) }, { status: 400 }); + } + + try { + const session = await endSessionForUser(user.id, parsed.data.id); + return NextResponse.json(session, { status: 200 }); + } catch (err) { + if (err instanceof SessionNotFoundError) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + throw err; + } + })(req); +} + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/sessions/route.ts b/dashboard/app/api/v1/sessions/route.ts new file mode 100644 index 0000000..b6efa75 --- /dev/null +++ b/dashboard/app/api/v1/sessions/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSessionForUser } from "@/server/sessions"; +import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; + +export const POST = withCors(async (req) => { + let user; + try { + user = requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: err.message }, { status: 401 }); + } + throw err; + } + + const session = await createSessionForUser(user.id); + return NextResponse.json(session, { status: 201 }); +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/next.config.ts b/dashboard/next.config.ts index e9ffa30..c475b2a 100644 --- a/dashboard/next.config.ts +++ b/dashboard/next.config.ts @@ -1,7 +1,10 @@ +import path from "node:path"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + turbopack: { + root: path.resolve(import.meta.dirname), + }, }; export default nextConfig; diff --git a/dashboard/server/events.ts b/dashboard/server/events.ts index e0084f8..cc0d046 100644 --- a/dashboard/server/events.ts +++ b/dashboard/server/events.ts @@ -1,6 +1,22 @@ import type { CreateEventInput } from "./schemas/events"; import { prisma } from "./db"; -export function createEvent(input: CreateEventInput) { +export class SessionNotFoundError extends Error { + constructor() { + super("Session not found"); + this.name = "SessionNotFoundError"; + } +} + +export async function createEventForUser( + userId: string, + input: CreateEventInput, +) { + const session = await prisma.session.findFirst({ + where: { id: input.sessionId, userId }, + select: { id: true }, + }); + if (!session) throw new SessionNotFoundError(); + return prisma.event.create({ data: input }); } diff --git a/dashboard/server/jwt.ts b/dashboard/server/jwt.ts new file mode 100644 index 0000000..ae01485 --- /dev/null +++ b/dashboard/server/jwt.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from "next/server"; +import jwt from "jsonwebtoken"; + +export class UnauthorizedError extends Error { + constructor(message = "Unauthorized") { + super(message); + this.name = "UnauthorizedError"; + } +} + +interface JwtPayload { + sub: string; + email: string; +} + +function isPayload(v: unknown): v is JwtPayload { + if (typeof v !== "object" || v === null) return false; + const r = v as Record; + return typeof r["sub"] === "string" && typeof r["email"] === "string"; +} + +export function requireUser(req: NextRequest): { id: string; email: string } { + const header = req.headers.get("authorization"); + if (!header?.startsWith("Bearer ")) { + throw new UnauthorizedError("Missing or malformed Authorization header"); + } + const token = header.slice("Bearer ".length).trim(); + if (!token) throw new UnauthorizedError("Empty bearer token"); + + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error("JWT_SECRET is not set"); + + let decoded: unknown; + try { + decoded = jwt.verify(token, secret); + } catch { + throw new UnauthorizedError("Invalid or expired token"); + } + if (!isPayload(decoded)) throw new UnauthorizedError("Invalid token payload"); + + return { id: decoded.sub, email: decoded.email }; +} diff --git a/dashboard/server/schemas/sessions.ts b/dashboard/server/schemas/sessions.ts new file mode 100644 index 0000000..4183852 --- /dev/null +++ b/dashboard/server/schemas/sessions.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const SessionIdParam = z.object({ + id: z.string().regex(/^c[a-z0-9]{24}$/, "must be a CUID"), +}); + +export type SessionIdParam = z.infer; diff --git a/dashboard/server/sessions.ts b/dashboard/server/sessions.ts new file mode 100644 index 0000000..5aea255 --- /dev/null +++ b/dashboard/server/sessions.ts @@ -0,0 +1,28 @@ +import { prisma } from "./db"; + +export class SessionNotFoundError extends Error { + constructor() { + super("Session not found"); + this.name = "SessionNotFoundError"; + } +} + +export function createSessionForUser(userId: string) { + return prisma.session.create({ + data: { userId }, + select: { id: true, startedAt: true }, + }); +} + +export async function endSessionForUser(userId: string, sessionId: string) { + const result = await prisma.session.updateMany({ + where: { id: sessionId, userId, endedAt: null }, + data: { endedAt: new Date() }, + }); + if (result.count === 0) throw new SessionNotFoundError(); + + return prisma.session.findUniqueOrThrow({ + where: { id: sessionId }, + select: { id: true, endedAt: true }, + }); +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 6fa8bd4..f765d5b 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -1,6 +1,8 @@ import type { AuthMessage, AuthResponse, StoredAuth } from "../types/auth"; import type { ContentMessage } from "../types/content"; +import type { PendingEvent } from "../types/pending"; import type { SessionMessage, SessionResponse } from "../types/session"; +import type { SyncMessage, SyncResponse } from "../types/sync"; import { getSession, startSession, @@ -8,7 +10,9 @@ import { pauseSession, resumeSession, getElapsedMs, + attachDbSessionId, } from "./session"; +import { initSync, flush, getStatus } from "./sync"; const DASHBOARD_URL = import.meta.env.VITE_DASHBOARD_URL as string; const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string; @@ -35,6 +39,17 @@ function isExpired(expiresAt: number): boolean { return Date.now() >= expiresAt - EXPIRY_BUFFER_MS; } +function decodeEmail(jwt: string): string | null { + try { + const payloadB64 = jwt.split(".")[1]; + if (!payloadB64) return null; + const payload = JSON.parse(atob(payloadB64)) as { email?: string }; + return payload.email ?? null; + } catch { + return null; + } +} + // ─── Google OAuth ────────────────────────────────────────────────────────────── async function launchGoogleOAuth(): Promise { @@ -110,12 +125,54 @@ export async function apiFetch( }); } +// ─── DB session lifecycle ────────────────────────────────────────────────────── + +// Best-effort: ensures the active local session has a DB-backed CUID. +// Called on SESSION_START and again by the sync alarm before each flush. +// Returns the dbSessionId on success, null otherwise. +export async function ensureDbSession(): Promise { + const session = await getSession(); + if (!session) return null; + if (session.dbSessionId) return session.dbSessionId; + + try { + const res = await apiFetch("/api/v1/sessions", { method: "POST" }); + if (!res.ok) return null; + const { id } = await res.json() as { id: string }; + const updated = await attachDbSessionId(id); + return updated?.dbSessionId ?? id; + } catch { + return null; + } +} + +async function tryEndDbSession(dbSessionId: string): Promise { + try { + await apiFetch(`/api/v1/sessions/${dbSessionId}`, { method: "PATCH" }); + } catch { + // Best-effort — losing endedAt is acceptable per AC 5 plan + } +} + +// ─── Pending event queue ─────────────────────────────────────────────────────── + +async function enqueuePending(event: PendingEvent): Promise { + const r = await chrome.storage.local.get("pendingEvents"); + const pending = (r["pendingEvents"] as PendingEvent[] | undefined) ?? []; + pending.push(event); + await chrome.storage.local.set({ pendingEvents: pending }); +} + // ─── Message listener ────────────────────────────────────────────────────────── -type IncomingMessage = AuthMessage | SessionMessage | ContentMessage; +type IncomingMessage = AuthMessage | SessionMessage | ContentMessage | SyncMessage; chrome.runtime.onMessage.addListener( - (message: IncomingMessage, _sender, sendResponse: (r: AuthResponse | SessionResponse) => void) => { + ( + message: IncomingMessage, + _sender, + sendResponse: (r: AuthResponse | SessionResponse | SyncResponse) => void, + ) => { if (message.type === "AUTH_LOGIN") { if (DEV_MODE) { storeAuth("dev.fake.jwt", Date.now() + 7 * 24 * 60 * 60 * 1000) @@ -153,12 +210,14 @@ chrome.runtime.onMessage.addListener( if (message.type === "AUTH_GET_STATUS") { getStoredAuth() - .then((stored) => + .then((stored) => { + const authed = stored !== null && !isExpired(stored.jwtExpiresAt); sendResponse({ success: true, - isAuthenticated: stored !== null && !isExpired(stored.jwtExpiresAt), - }), - ) + isAuthenticated: authed, + email: authed && stored ? decodeEmail(stored.jwt) : null, + }); + }) .catch((err: unknown) => sendResponse({ success: false, @@ -172,12 +231,14 @@ chrome.runtime.onMessage.addListener( if (message.type === "SESSION_START") { startSession() - .then((session) => + .then((session) => { sendResponse({ success: true, session: { ...session, elapsedMs: getElapsedMs(session) }, - }), - ) + }); + // Fire-and-forget DB session creation — retried by sync alarm if it fails + if (!session.dbSessionId) void ensureDbSession(); + }) .catch((err: unknown) => sendResponse({ success: false, @@ -188,13 +249,18 @@ chrome.runtime.onMessage.addListener( } if (message.type === "SESSION_STOP") { - stopSession() - .then((session) => + (async () => { + // Drain before clearing the session — once cleared, flush would exit early + await flush(); + return stopSession(); + })() + .then((session) => { sendResponse({ success: true, session: session ? { ...session, elapsedMs: getElapsedMs(session) } : null, - }), - ) + }); + if (session?.dbSessionId) void tryEndDbSession(session.dbSessionId); + }) .catch((err: unknown) => sendResponse({ success: false, @@ -264,33 +330,48 @@ chrome.runtime.onMessage.addListener( if (message.type === "NOTE_ADD") { getSession().then((session) => { if (!session || session.pausedAt !== null) return; - chrome.storage.local.get("pendingEvents").then((r) => { - const pending = (r["pendingEvents"] as unknown[]) ?? []; - pending.push({ - url: "", - title: "Note", - content: message.text, - tags: ["note", ...message.tags], - timestamp: new Date().toISOString(), - }); - chrome.storage.local.set({ pendingEvents: pending }); - }); + const event: PendingEvent = { + url: `worktrace://note/${crypto.randomUUID()}`, + title: message.text.slice(0, 80) || "Note", + content: message.text, + tags: ["note", ...message.tags], + timestamp: new Date().toISOString(), + }; + void enqueuePending(event); }); return false; } + // ─── Sync messages ─────────────────────────────────────────────────────── + + if (message.type === "SYNC_GET_STATUS") { + getStatus() + .then((status) => sendResponse({ success: true, ...status })) + .catch((err: unknown) => + sendResponse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + ); + return true; + } + // ─── Content script messages ───────────────────────────────────────────── if (message.type === "PAGE_METADATA") { // Store metadata only when a session is active (AC 5 will batch-send it) getSession().then((session) => { if (!session) return; - // Stored for AC 5 batch sync — no response needed - chrome.storage.local.get("pendingEvents").then((r) => { - const pending = (r["pendingEvents"] as unknown[]) ?? []; - pending.push({ ...message.payload, timestamp: new Date().toISOString() }); - chrome.storage.local.set({ pendingEvents: pending }); - }); + const { url, title, metaDescription, headings } = message.payload; + const content = [metaDescription, ...headings].filter(Boolean).join(" | ") || null; + const event: PendingEvent = { + url, + title, + content, + tags: [], + timestamp: new Date().toISOString(), + }; + void enqueuePending(event); }); return false; // no async response needed } @@ -304,3 +385,6 @@ chrome.runtime.onMessage.addListener( chrome.runtime.onInstalled.addListener((details) => { console.log("[worktrace] service worker installed:", details.reason); }); + +// Boot sync alarm — runs every service-worker startup, idempotent under chrome.alarms +initSync({ apiFetch, ensureDbSession }); diff --git a/extension/src/background/session.ts b/extension/src/background/session.ts index fa27fc3..ee81208 100644 --- a/extension/src/background/session.ts +++ b/extension/src/background/session.ts @@ -33,6 +33,7 @@ export async function startSession(): Promise { const session: Session = { id: crypto.randomUUID(), + dbSessionId: null, startedAt: Date.now(), totalActiveMs: 0, pausedAt: null, @@ -41,6 +42,14 @@ export async function startSession(): Promise { return session; } +export async function attachDbSessionId(dbSessionId: string): Promise { + const session = await getSession(); + if (!session || session.dbSessionId) return session; + const updated: Session = { ...session, dbSessionId }; + await saveSession(updated); + return updated; +} + export async function stopSession(): Promise { const session = await getSession(); if (!session) return null; diff --git a/extension/src/background/sync.ts b/extension/src/background/sync.ts new file mode 100644 index 0000000..1618af9 --- /dev/null +++ b/extension/src/background/sync.ts @@ -0,0 +1,114 @@ +import type { PendingEvent } from "../types/pending"; +import { getSession } from "./session"; + +const ALARM_NAME = "worktrace-sync"; +const PERIOD_MIN = 0.5; // 30 seconds +const BATCH_SIZE = 50; +const STATUS_KEY = "syncStatus"; +const PENDING_KEY = "pendingEvents"; + +export interface SyncStatus { + lastSyncedAt: number | null; + lastError: string | null; +} + +// Functions injected at startup to avoid circular import with background/index.ts +type ApiFetch = (path: string, init?: RequestInit) => Promise; +type EnsureDbSession = () => Promise; + +let apiFetchFn: ApiFetch | null = null; +let ensureDbSessionFn: EnsureDbSession | null = null; + +export function initSync(deps: { apiFetch: ApiFetch; ensureDbSession: EnsureDbSession }): void { + apiFetchFn = deps.apiFetch; + ensureDbSessionFn = deps.ensureDbSession; + + chrome.alarms.create(ALARM_NAME, { periodInMinutes: PERIOD_MIN }); + chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === ALARM_NAME) void flush(); + }); +} + +// ─── Status ──────────────────────────────────────────────────────────────────── + +export async function getStatus(): Promise { + const r = await chrome.storage.local.get([STATUS_KEY, PENDING_KEY]); + const status = (r[STATUS_KEY] as SyncStatus | undefined) ?? { lastSyncedAt: null, lastError: null }; + const pending = (r[PENDING_KEY] as PendingEvent[] | undefined) ?? []; + return { ...status, queueSize: pending.length }; +} + +async function setStatus(patch: Partial): Promise { + const r = await chrome.storage.local.get(STATUS_KEY); + const current = (r[STATUS_KEY] as SyncStatus | undefined) ?? { lastSyncedAt: null, lastError: null }; + await chrome.storage.local.set({ [STATUS_KEY]: { ...current, ...patch } }); +} + +// ─── Flush ───────────────────────────────────────────────────────────────────── + +export async function flush(): Promise { + if (!apiFetchFn || !ensureDbSessionFn) return; + + const session = await getSession(); + if (!session) return; + + const dbSessionId = session.dbSessionId ?? (await ensureDbSessionFn()); + if (!dbSessionId) { + await setStatus({ lastError: "DB session not available" }); + return; + } + + const r = await chrome.storage.local.get(PENDING_KEY); + const queue = (r[PENDING_KEY] as PendingEvent[] | undefined) ?? []; + if (queue.length === 0) return; + + const batch = queue.slice(0, BATCH_SIZE); + let sentCount = 0; + let lastError: string | null = null; + + for (let i = 0; i < batch.length; i++) { + const event = batch[i]!; + let res: Response; + try { + res = await apiFetchFn("/api/v1/events", { + method: "POST", + body: JSON.stringify({ ...event, sessionId: dbSessionId }), + }); + } catch (err) { + // Network error — keep this event and everything after it for the next flush + lastError = err instanceof Error ? err.message : "Network error"; + break; + } + + if (res.ok) { + sentCount++; + continue; + } + + if (res.status === 401) { + // Token rotated/expired — stop, queue waits for next login + lastError = "Unauthorized — please sign in again"; + break; + } + + if (res.status >= 400 && res.status < 500) { + // Poisonous payload — drop it, keep going so it can't block the queue + sentCount++; + lastError = `Dropped invalid event (HTTP ${String(res.status)})`; + continue; + } + + // 5xx — retry next alarm + lastError = `Server error (HTTP ${String(res.status)})`; + break; + } + + if (sentCount > 0) { + const remaining = queue.slice(sentCount); + await chrome.storage.local.set({ [PENDING_KEY]: remaining }); + } + await setStatus({ + lastSyncedAt: sentCount > 0 ? Date.now() : (await getStatus()).lastSyncedAt, + lastError, + }); +} diff --git a/extension/src/manifest.ts b/extension/src/manifest.ts index 6330a4e..024b576 100644 --- a/extension/src/manifest.ts +++ b/extension/src/manifest.ts @@ -27,7 +27,7 @@ export default defineManifest({ run_at: "document_idle", }, ], - permissions: ["storage", "activeTab", "tabs", "identity"], + permissions: ["storage", "activeTab", "tabs", "identity", "alarms"], host_permissions: [ "http://localhost:3000/*", "https://worktrace-ecru.vercel.app/*", diff --git a/extension/src/popup/index.html b/extension/src/popup/index.html index dafcacf..15e9bcb 100644 --- a/extension/src/popup/index.html +++ b/extension/src/popup/index.html @@ -13,7 +13,10 @@