From de7db55e51e773573e827712865e7202862839c2 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 23 May 2026 12:46:23 +0300 Subject: [PATCH 1/9] docs(plans): add week 2 AC 5 data sync plan --- plans/week2/AC5-data-sync.md | 198 +++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 plans/week2/AC5-data-sync.md diff --git a/plans/week2/AC5-data-sync.md b/plans/week2/AC5-data-sync.md new file mode 100644 index 0000000..156ccdb --- /dev/null +++ b/plans/week2/AC5-data-sync.md @@ -0,0 +1,198 @@ +# Plan: Week 2 AC 5 — Data Sync + +**Branch:** `feature/extension-data-sync` +**Closes:** Week 2 AC 5 ([docs/Task.md L78](../../docs/Task.md#L78)) +**Status:** plan, awaiting review + +--- + +## Scope + +**AC 5 — Передача даних:** зібрані події надсилаються батчами на `POST /api/v1/events` з Bearer token. Є retry-логіка при помилці мережі. + +Закриває також **Gap 2, 3, 4** з [AC2-AC3-content-sessions.md](./AC2-AC3-content-sessions.md#L144) — без них AC 5 не запрацює, бо в БД `Event.sessionId` — FK на CUID, локальний UUID не пройде валідацію. + +--- + +## Commits (ordered) + +``` +1. docs(plans): add week 2 AC 5 data sync plan +2. feat(dashboard): add requireUser JWT helper and apply to /api/v1/events +3. feat(dashboard): add POST and PATCH /api/v1/sessions endpoints +4. feat(extension): create DB session on SESSION_START with retry (Gap 2) +5. feat(extension): normalize pending event payload shape (Gaps 3, 4) +6. feat(extension): add batched event sync with alarm and retry (AC 5) +7. feat(extension): show sync errors and last-synced state in popup +``` + +--- + +## Files + +| File | Action | Commit | +|---|---|---| +| `plans/week2/AC5-data-sync.md` | **new** | 1 | +| `dashboard/server/jwt.ts` | **new** — `requireUser(req): { id, email }` | 2 | +| `dashboard/app/api/v1/events/route.ts` | modify — call `requireUser`, restrict body to current user's sessions | 2 | +| `dashboard/server/schemas/sessions.ts` | **new** | 3 | +| `dashboard/server/sessions.ts` | **new** — `createSession`, `endSession` | 3 | +| `dashboard/app/api/v1/sessions/route.ts` | **new** — POST | 3 | +| `dashboard/app/api/v1/sessions/[id]/route.ts` | **new** — PATCH | 3 | +| `extension/src/types/session.ts` | modify — add `dbSessionId: string \| null` | 4 | +| `extension/src/background/session.ts` | modify — `startSession` triggers DB-session create with retry | 4 | +| `extension/src/types/pending.ts` | **new** — shared shape for queued events | 5 | +| `extension/src/background/index.ts` | modify — normalize payloads at enqueue time | 5 | +| `extension/src/background/sync.ts` | **new** — batching, alarms, retry | 6 | +| `extension/src/background/index.ts` | modify — wire sync, flush on `SESSION_STOP` | 6 | +| `extension/src/manifest.ts` | modify — add `"alarms"` permission | 6 | +| `extension/src/types/sync.ts` | **new** — `SYNC_GET_STATUS` message | 7 | +| `extension/src/popup/popup.ts` | modify — show last sync ts / error | 7 | +| `extension/src/popup/index.html` + `.css` | modify — sync status row | 7 | + +--- + +## Design decisions + +### 1. JWT helper — shared between sessions and events + +Окремий модуль `dashboard/server/jwt.ts`: + +```ts +export function requireUser(req: NextRequest): { id: string; email: string } +``` + +Кидає `UnauthorizedError` (новий клас) при відсутньому/невалідному токені. Route handlers ловлять і повертають 401. Сам verify через `jsonwebtoken.verify(token, JWT_SECRET)` — той самий secret, що в `server/auth.ts:issueJWT`. + +**Чому окремий файл, а не middleware:** Next.js middleware на edge runtime, а нам тут потрібно перевірити `userId` проти БД у деяких флоу (наприклад: `POST /api/v1/events` повинен впевнитись, що `sessionId` належить юзеру). Реальний middleware — Week 3 AC 1 для `/dashboard/*` сторінок. + +**Чому застосовуємо до `/api/v1/events` теж:** без цього будь-хто з `chrome-extension://*` Origin може писати в чужі сесії. CORS дозволяє origin, але не авторизує користувача. Краще зробити правильно зараз, ніж лишити дірку до Week 3. + +**Перевірка ownership при POST /api/v1/events:** окрім JWT, переконуємось, що `event.sessionId` належить `userId` з токена. Без цього JWT-helper лише наполовину закриває проблему. + +### 2. Sessions API + +``` +POST /api/v1/sessions → { id: string } створює Session(userId from JWT, startedAt=now) +PATCH /api/v1/sessions/:id → { id, endedAt } перевіряє ownership, пише endedAt=now +``` + +Обидва — `withCors`, обидва — через `requireUser`. Zod схеми мінімальні (POST — порожнє body, PATCH — без body). + +### 3. Local Session → DB Session mapping + +Розширення типу: +```ts +interface Session { + id: string; // local UUID — для popup/storage + dbSessionId: string | null; // CUID з БД, null поки не дійшло + startedAt: number; + totalActiveMs: number; + pausedAt: number | null; +} +``` + +**`SESSION_START` flow:** +1. створити local session (як зараз) +2. одразу зробити `POST /api/v1/sessions`. На успіх — записати `dbSessionId` у storage. +3. на помилку мережі — лишити `dbSessionId: null` і покластись на sync alarm, який спробує знову (див. §6). + +**Без `dbSessionId` нічого не sync-иться.** Sync пропускає batch, якщо сесія ще не зареєстрована в БД. Це послідовно і безпечно — батч-запит або повністю успішний, або повністю відкладений. + +**`SESSION_STOP` flow:** локальна частина — як зараз. Якщо є `dbSessionId` — `PATCH /api/v1/sessions/:id` для `endedAt`. Якщо немає (офлайн весь час) — не пробуємо. Втрата `endedAt` — допустима втрата. + +### 4. Pending event shape — нормалізуємо при enqueue + +Зараз у `pendingEvents` потрапляють два різні shape'и: +- з `PAGE_METADATA` — повний `PageMetadata` payload (`{ url, title, metaDescription, headings, timestamp }`) +- з `NOTE_ADD` — `{ url: "", title: "Note", content, tags, timestamp }` + +Обидва _не співпадають_ з `Event` моделлю (`headings` нема, `metaDescription` нема, `url: ""` фейлить `z.url()`). + +**Нормалізуємо одразу при enqueue.** Новий тип: +```ts +interface PendingEvent { + url: string; // valid URL (для нотатки — worktrace://note/) + title: string; + content: string | null; + tags: string[]; + timestamp: string; // ISO +} +``` + +Перетворення: +- **PAGE_METADATA** → `content = [metaDescription, ...headings].filter(Boolean).join(" | ") || null`, `tags = []`. +- **NOTE_ADD** → `url = "worktrace://note/" + crypto.randomUUID()`, `title = text.slice(0, 80) || "Note"`, `content = text`, `tags = ["note", ...userTags]`. + +`worktrace://` — кастомна URL-схема, Zod `z.url()` приймає її (URL parser спецификації не вимагає http/https). + +### 5. Validation: `CreateEventInput` лишається без змін + +Сторона dashboard'у не вимагає змін схеми — `url: z.url()`, `title: z.string().min(1)`, `sessionId: CUID` всі задовольняються нормалізованим payload. + +Єдина зміна route handler'а — додати `requireUser` + перевірку `session.userId === user.id` через `prisma.session.findFirst({ where: { id, userId } })`. + +### 6. Batching & retry — `chrome.alarms` кожні 30 секунд + +``` +chrome.alarms.create("sync", { periodInMinutes: 0.5 }); +chrome.alarms.onAlarm.addListener(name => name === "sync" && flush()); +``` + +**`flush()`:** +1. читаємо `activeSession` — якщо `dbSessionId == null`, спершу пробуємо `POST /api/v1/sessions`. Не вдалось — exit (наступний alarm спробує знову). +2. читаємо `pendingEvents`. Порожньо — exit. +3. беремо до `BATCH_SIZE = 50` подій (FIFO). +4. шлемо `POST /api/v1/events` _по одній_ (route handler приймає одну, не масив — поточний контракт). Кожна успішна → видаляється з черги. Помилка мережі → стоп, лишити решту в черзі. +5. при `401` → не ретраїти, очистити jwt і черга чекає (юзер залогіниться, наступний flush спрацює). +6. при `4xx` (валідація) → подія відкидається з логом — інакше "отруйна" подія заблокує всю чергу. + +**Backoff per-alarm:** в межах одного `flush` — без додаткових ретраїв. Наступний alarm через 30s — це і є backoff. Простіше і достатньо для AC 5 (не CI з мільйонами реквестів). + +**Flush на `SESSION_STOP`:** одразу викликаємо `flush()` (поза alarm), щоб закрити сесію з мінімальною кількістю pending events. + +**Чому окремий файл `background/sync.ts`:** `index.ts` уже 300 рядків. Sync — самостійна одиниця з власним станом (lastSyncedAt, lastError). Окремий модуль = легше тестувати локально (можна замокати `apiFetch`). + +### 7. Sync status у popup + +Новий тип повідомлення: +```ts +{ type: "SYNC_GET_STATUS" } + → { lastSyncedAt: number | null; lastError: string | null; queueSize: number } +``` + +`queueSize` — те саме що зараз показує popup (`pendingEvents.length`), просто переносимо в один RPC замість прямого читання storage. Це чистіше — popup не залежить від ключів storage. + +UI: під поточним `sync-indicator` додаємо одне поле "last sync: 2 min ago" / "error: ...". Без перебудови верстки. + +### 8. Чого НЕ робимо тут (out of scope) + +- **JWT validation у Next.js middleware** — Week 3 AC 1. Тут тільки server-side helper для конкретних route handlers. +- **Track endpoint** — `Track` модель ще не використовується (Week 2 AC 6 бонус). +- **Forgot password / email-password auth** — окремий backlog, явно "after Week 3" ([plans/backlog/email-password-auth.md:131](../backlog/email-password-auth.md#L131)). +- **Bulk events endpoint** (`POST /api/v1/events` приймає масив) — поточний контракт приймає одну подію, міняти його — окремий рефактор. Послідовний POST з batch-обмеженням 50 достатньо для MVP. +- **Note як окрема Prisma модель** — синтетичний URL покриває потреби AC 5 без міграції. + +--- + +## Verification checklist + +### Dashboard +- [ ] `cd dashboard && npx tsc --noEmit` без помилок +- [ ] `POST /api/v1/sessions` без `Authorization` → 401 +- [ ] `POST /api/v1/sessions` з валідним JWT → 201 `{ id }`, рядок у БД +- [ ] `PATCH /api/v1/sessions/` → 200, `endedAt` записано +- [ ] `PATCH /api/v1/sessions/` → 404 (не 403 — не зливаємо існування) +- [ ] `POST /api/v1/events` для чужої сесії → 404 +- [ ] `POST /api/v1/events` для своєї сесії з валідним body → 201 + +### Extension +- [ ] `cd extension && npm run build` без помилок +- [ ] `SESSION_START` → `activeSession.dbSessionId` з'являється протягом ~1 alarm +- [ ] Офлайн + `SESSION_START` → `dbSessionId: null`, після відновлення мережі → заповнюється +- [ ] `pendingEvents` після PAGE_METADATA має формат `{ url, title, content, tags, timestamp }` +- [ ] Нотатка з'являється з `url: worktrace://note/...`, `tags: ["note", ...]` +- [ ] Alarm кожні 30s робить POST. Успіх → подія зникає з `pendingEvents`. +- [ ] 401 від API → черга лишається, після повторного login → flush очищає її +- [ ] `SESSION_STOP` → одразу POST до events для решти черги + PATCH session.endedAt +- [ ] Popup показує `last sync: ...` і `error: ...` коли актуально From e8df322e60678fa6d3104a6d99339738df158d77 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 23 May 2026 12:49:21 +0300 Subject: [PATCH 2/9] feat(dashboard): add requireUser JWT helper and protect /api/v1/events Verifies Bearer token via JWT_SECRET, checks session ownership on POST. --- dashboard/app/api/v1/events/route.ts | 24 ++++++++++++++-- dashboard/server/events.ts | 18 +++++++++++- dashboard/server/jwt.ts | 42 ++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 dashboard/server/jwt.ts 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/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 }; +} From 41c111e9a05e9c3e5c1d9a9239e1b1b33dd516df Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 23 May 2026 12:50:40 +0300 Subject: [PATCH 3/9] feat(dashboard): add POST and PATCH /api/v1/sessions endpoints POST creates a session for the authenticated user. PATCH /:id sets endedAt, scoped to the user's own sessions. --- dashboard/app/api/v1/sessions/[id]/route.ts | 44 +++++++++++++++++++++ dashboard/app/api/v1/sessions/route.ts | 23 +++++++++++ dashboard/server/schemas/sessions.ts | 7 ++++ dashboard/server/sessions.ts | 28 +++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 dashboard/app/api/v1/sessions/[id]/route.ts create mode 100644 dashboard/app/api/v1/sessions/route.ts create mode 100644 dashboard/server/schemas/sessions.ts create mode 100644 dashboard/server/sessions.ts 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/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 }, + }); +} From 943f30fb6673f7db967bdd526c99e49495515dca Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 23 May 2026 12:52:39 +0300 Subject: [PATCH 4/9] feat(extension): create DB session on SESSION_START with best-effort retry Adds dbSessionId to local Session, fire-and-forget POST /api/v1/sessions on start and PATCH on stop. Failures left for sync alarm to retry. --- extension/src/background/index.ts | 40 ++++++++++++++++++++++++----- extension/src/background/session.ts | 9 +++++++ extension/src/types/session.ts | 1 + 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 6fa8bd4..df27664 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -8,6 +8,7 @@ import { pauseSession, resumeSession, getElapsedMs, + attachDbSessionId, } from "./session"; const DASHBOARD_URL = import.meta.env.VITE_DASHBOARD_URL as string; @@ -110,6 +111,30 @@ export async function apiFetch( }); } +// ─── DB session lifecycle ────────────────────────────────────────────────────── + +// Best-effort: attempts to create a DB Session and store its CUID on the local +// session. Failures are swallowed — the sync alarm (AC 5) retries via +// ensureDbSession() before each flush. +export async function tryCreateDbSession(): Promise { + try { + const res = await apiFetch("/api/v1/sessions", { method: "POST" }); + if (!res.ok) return; + const { id } = await res.json() as { id: string }; + await attachDbSessionId(id); + } catch { + // Network/auth failure — leave dbSessionId null, sync will retry + } +} + +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 + } +} + // ─── Message listener ────────────────────────────────────────────────────────── type IncomingMessage = AuthMessage | SessionMessage | ContentMessage; @@ -172,12 +197,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 tryCreateDbSession(); + }) .catch((err: unknown) => sendResponse({ success: false, @@ -189,12 +216,13 @@ chrome.runtime.onMessage.addListener( if (message.type === "SESSION_STOP") { stopSession() - .then((session) => + .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, 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/types/session.ts b/extension/src/types/session.ts index 93597b3..a1791c9 100644 --- a/extension/src/types/session.ts +++ b/extension/src/types/session.ts @@ -1,5 +1,6 @@ export interface Session { id: string; + dbSessionId: string | null; // CUID returned by POST /api/v1/sessions, null until first sync startedAt: number; // Unix ms totalActiveMs: number; // accumulated active time pausedAt: number | null; // null = active, number = paused since From f1886b41e79963282278ce62adb8dbe484639510 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 23 May 2026 12:53:41 +0300 Subject: [PATCH 5/9] feat(extension): normalize pending event payload shape PageMetadata flattens to content via metaDescription + headings. Notes get worktrace://note/ URL so z.url() passes without schema changes. --- extension/src/background/index.ts | 45 +++++++++++++++++++------------ extension/src/types/pending.ts | 9 +++++++ 2 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 extension/src/types/pending.ts diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index df27664..00943c2 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -1,5 +1,6 @@ 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 { getSession, @@ -135,6 +136,15 @@ async function tryEndDbSession(dbSessionId: string): Promise { } } +// ─── 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; @@ -292,17 +302,14 @@ 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; } @@ -313,12 +320,16 @@ chrome.runtime.onMessage.addListener( // 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 } diff --git a/extension/src/types/pending.ts b/extension/src/types/pending.ts new file mode 100644 index 0000000..91559b9 --- /dev/null +++ b/extension/src/types/pending.ts @@ -0,0 +1,9 @@ +// Wire shape for queued events — matches dashboard's CreateEventInput +// (minus sessionId, which is attached at flush time from local Session.dbSessionId) +export interface PendingEvent { + url: string; // must satisfy z.url() (worktrace://note/... for notes) + title: string; // min 1 char + content: string | null; + tags: string[]; + timestamp: string; // ISO 8601 +} From 0f9c01a6d005c7920d5531be628a52b63a930df4 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 23 May 2026 13:30:21 +0300 Subject: [PATCH 6/9] feat(extension): add batched event sync with alarm and retry Every 30s a chrome.alarms tick flushes up to 50 queued events. Final flush also fires on SESSION_STOP. 4xx drops poison events, 401 stops until re-login, network and 5xx keep the queue for the next tick. --- extension/src/background/index.ts | 31 +++++--- extension/src/background/sync.ts | 114 ++++++++++++++++++++++++++++++ extension/src/manifest.ts | 2 +- 3 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 extension/src/background/sync.ts diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 00943c2..1fa17e8 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -11,6 +11,7 @@ import { getElapsedMs, attachDbSessionId, } from "./session"; +import { initSync, flush } 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; @@ -114,17 +115,22 @@ export async function apiFetch( // ─── DB session lifecycle ────────────────────────────────────────────────────── -// Best-effort: attempts to create a DB Session and store its CUID on the local -// session. Failures are swallowed — the sync alarm (AC 5) retries via -// ensureDbSession() before each flush. -export async function tryCreateDbSession(): Promise { +// 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; + if (!res.ok) return null; const { id } = await res.json() as { id: string }; - await attachDbSessionId(id); + const updated = await attachDbSessionId(id); + return updated?.dbSessionId ?? id; } catch { - // Network/auth failure — leave dbSessionId null, sync will retry + return null; } } @@ -213,7 +219,7 @@ chrome.runtime.onMessage.addListener( session: { ...session, elapsedMs: getElapsedMs(session) }, }); // Fire-and-forget DB session creation — retried by sync alarm if it fails - if (!session.dbSessionId) void tryCreateDbSession(); + if (!session.dbSessionId) void ensureDbSession(); }) .catch((err: unknown) => sendResponse({ @@ -225,7 +231,11 @@ chrome.runtime.onMessage.addListener( } if (message.type === "SESSION_STOP") { - stopSession() + (async () => { + // Drain before clearing the session — once cleared, flush would exit early + await flush(); + return stopSession(); + })() .then((session) => { sendResponse({ success: true, @@ -343,3 +353,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/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/*", From 1659e3003e10e9d73f8a1216553f71f1540d9f03 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 23 May 2026 13:32:44 +0300 Subject: [PATCH 7/9] feat(extension): show sync status in popup Adds a SYNC_GET_STATUS message that returns lastSyncedAt, lastError and queueSize. Popup renders a second line under the queue count showing time since last sync or the error message. --- extension/src/background/index.ts | 25 ++++++++++++++++--- extension/src/popup/index.html | 7 ++++-- extension/src/popup/popup.css | 20 ++++++++++++++-- extension/src/popup/popup.ts | 40 +++++++++++++++++++++++++++---- extension/src/types/sync.ts | 10 ++++++++ 5 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 extension/src/types/sync.ts diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 1fa17e8..d8f7564 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -2,6 +2,7 @@ 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, @@ -11,7 +12,7 @@ import { getElapsedMs, attachDbSessionId, } from "./session"; -import { initSync, flush } from "./sync"; +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; @@ -153,10 +154,14 @@ async function enqueuePending(event: PendingEvent): Promise { // ─── 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) @@ -324,6 +329,20 @@ chrome.runtime.onMessage.addListener( 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") { diff --git a/extension/src/popup/index.html b/extension/src/popup/index.html index dafcacf..768dfab 100644 --- a/extension/src/popup/index.html +++ b/extension/src/popup/index.html @@ -41,8 +41,11 @@ diff --git a/extension/src/popup/popup.css b/extension/src/popup/popup.css index 7dbd01e..d83c452 100644 --- a/extension/src/popup/popup.css +++ b/extension/src/popup/popup.css @@ -154,8 +154,8 @@ body { /* ── popup__sync ───────────────────────────────────────────────────────────── */ .popup__sync { display: flex; - align-items: center; - gap: 6px; + flex-direction: column; + gap: 4px; background: var(--c-surface); border: 1px solid var(--c-border); border-radius: 4px; @@ -165,8 +165,24 @@ body { transition: border-color 0.3s, color 0.3s; } +.popup__sync-row { + display: flex; + align-items: center; + gap: 6px; +} + .popup__sync-icon { color: var(--c-yellow); } +.popup__sync-meta { + font-size: 10px; + color: var(--c-muted); + padding-left: 18px; +} + +.popup__sync-meta--error { + color: var(--c-pink); +} + .popup__sync--has-events { border-color: color-mix(in srgb, var(--c-yellow) 25%, transparent); color: var(--c-yellow); diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 60d381b..bf28240 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -1,5 +1,6 @@ import type { AuthMessage, AuthResponse } from "../types/auth"; import type { SessionMessage, SessionResponse, SessionState } from "../types/session"; +import type { SyncMessage, SyncResponse } from "../types/sync"; // ─── Messaging helpers ───────────────────────────────────────────────────────── @@ -23,6 +24,16 @@ function sendSession(msg: SessionMessage): Promise { ); } +function sendSync(msg: SyncMessage): Promise { + return new Promise((resolve, reject) => + chrome.runtime.sendMessage(msg, (res: SyncResponse | undefined) => { + if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message)); + if (!res) return reject(new Error("No response from background")); + resolve(res); + }), + ); +} + // ─── DOM refs ────────────────────────────────────────────────────────────────── const statusDot = document.getElementById("status-dot") as HTMLSpanElement; @@ -37,6 +48,7 @@ const btnPause = document.getElementById("btn-pause") as HTMLButtonEle const btnStop = document.getElementById("btn-stop") as HTMLButtonElement; const syncEl = document.getElementById("sync-indicator") as HTMLDivElement; const syncLabel = document.getElementById("sync-label") as HTMLSpanElement; +const syncMeta = document.getElementById("sync-meta") as HTMLDivElement; const noteInput = document.getElementById("note-input") as HTMLInputElement; const tagsInput = document.getElementById("tags-input") as HTMLInputElement; const btnNote = document.getElementById("btn-note") as HTMLButtonElement; @@ -84,11 +96,31 @@ function showUnauthenticated(): void { applyState("idle"); } +function formatAgo(ts: number): string { + const sec = Math.floor((Date.now() - ts) / 1000); + if (sec < 5) return "just now"; + if (sec < 60) return `${String(sec)}s ago`; + if (sec < 3600) return `${String(Math.floor(sec / 60))}m ago`; + return `${String(Math.floor(sec / 3600))}h ago`; +} + async function refreshSyncIndicator(): Promise { - const r = await chrome.storage.local.get("pendingEvents"); - const count = ((r["pendingEvents"] as unknown[]) ?? []).length; - syncLabel.textContent = `${count} event${count !== 1 ? "s" : ""} queued`; - syncEl.classList.toggle("popup__sync--has-events", count > 0); + const res = await sendSync({ type: "SYNC_GET_STATUS" }).catch(() => null); + if (!res || !res.success) return; + + syncLabel.textContent = `${String(res.queueSize)} event${res.queueSize !== 1 ? "s" : ""} queued`; + syncEl.classList.toggle("popup__sync--has-events", res.queueSize > 0); + + if (res.lastError) { + syncMeta.textContent = `error: ${res.lastError}`; + syncMeta.classList.add("popup__sync-meta--error"); + } else if (res.lastSyncedAt) { + syncMeta.textContent = `synced ${formatAgo(res.lastSyncedAt)}`; + syncMeta.classList.remove("popup__sync-meta--error"); + } else { + syncMeta.textContent = "not synced yet"; + syncMeta.classList.remove("popup__sync-meta--error"); + } } // ─── Session polling ─────────────────────────────────────────────────────────── diff --git a/extension/src/types/sync.ts b/extension/src/types/sync.ts new file mode 100644 index 0000000..c156c4e --- /dev/null +++ b/extension/src/types/sync.ts @@ -0,0 +1,10 @@ +export type SyncMessage = { type: "SYNC_GET_STATUS" }; + +export type SyncResponse = + | { + success: true; + lastSyncedAt: number | null; + lastError: string | null; + queueSize: number; + } + | { success: false; error: string }; From df64ee46ef49a4bb244fa951f7732be682f44696 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 23 May 2026 13:33:03 +0300 Subject: [PATCH 8/9] chore(dashboard): pin turbopack root to dashboard directory Two lockfiles (root husky/lint-staged and dashboard) made Turbopack infer the repo root as workspace root, watching extension and unrelated paths and slowing dev. Setting turbopack.root scopes the watcher. --- dashboard/next.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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; From 6efb387be5ffc7891a5ae2d1f55ffd53b360eb84 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Sat, 23 May 2026 13:37:14 +0300 Subject: [PATCH 9/9] feat(extension): show logged-in user email in popup header AUTH_GET_STATUS now returns the email decoded from the JWT payload. Popup renders it under the logo while authenticated, hides on logout. --- extension/src/background/index.ts | 21 +++++++++++++++++---- extension/src/popup/index.html | 5 ++++- extension/src/popup/popup.css | 16 ++++++++++++++++ extension/src/popup/popup.ts | 13 +++++++++++-- extension/src/types/auth.ts | 2 +- 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index d8f7564..f765d5b 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -39,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 { @@ -199,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, diff --git a/extension/src/popup/index.html b/extension/src/popup/index.html index 768dfab..15e9bcb 100644 --- a/extension/src/popup/index.html +++ b/extension/src/popup/index.html @@ -13,7 +13,10 @@