diff --git a/apps/journal/app/components/ClientDate.tsx b/apps/journal/app/components/ClientDate.tsx index af210ae..300b376 100644 --- a/apps/journal/app/components/ClientDate.tsx +++ b/apps/journal/app/components/ClientDate.tsx @@ -3,10 +3,14 @@ import { useLocale } from "./LocaleContext"; /** * Renders a date formatted with the server-detected locale, * ensuring SSR and client output match (no hydration flicker). + * Pass `withTime` for surfaces where the hour/minute matter + * (e.g. notifications), keeping plain dates as the default. */ -export function ClientDate({ iso }: { iso: string }) { +export function ClientDate({ iso, withTime = false }: { iso: string; withTime?: boolean }) { const locale = useLocale(); - return ( - {new Date(iso).toLocaleDateString(locale)} - ); + const d = new Date(iso); + const text = withTime + ? d.toLocaleString(locale, { dateStyle: "short", timeStyle: "short" }) + : d.toLocaleDateString(locale); + return {text}; } diff --git a/apps/journal/app/hooks/useUnreadNotifications.ts b/apps/journal/app/hooks/useUnreadNotifications.ts new file mode 100644 index 0000000..7286262 --- /dev/null +++ b/apps/journal/app/hooks/useUnreadNotifications.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; + +/** + * Subscribes to /api/events for live `notifications.unread` updates. + * Returns the live unread count, seeded with the loader-provided + * baseline so the badge renders correctly before the SSE handshake + * completes. Native EventSource handles reconnects with the + * server-suggested `retry:` interval. + */ +export function useUnreadNotifications(initialCount: number, signedIn: boolean): number { + const [count, setCount] = useState(initialCount); + + // Keep the in-component count in sync if the loader-provided baseline + // changes (e.g., on navigation). + useEffect(() => { + setCount(initialCount); + }, [initialCount]); + + useEffect(() => { + if (!signedIn) return; + if (typeof EventSource === "undefined") return; + const es = new EventSource("/api/events"); + const onUnread = (e: MessageEvent) => { + try { + const parsed = JSON.parse(e.data) as { count: number }; + if (typeof parsed.count === "number") setCount(parsed.count); + } catch { + // Malformed payload — ignore. + } + }; + es.addEventListener("notifications.unread", onUnread as EventListener); + return () => { + es.removeEventListener("notifications.unread", onUnread as EventListener); + es.close(); + }; + }, [signedIn]); + + return count; +} diff --git a/apps/journal/app/jobs/notifications-fanout.integration.test.ts b/apps/journal/app/jobs/notifications-fanout.integration.test.ts new file mode 100644 index 0000000..84a60c1 --- /dev/null +++ b/apps/journal/app/jobs/notifications-fanout.integration.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "../lib/db.ts"; +import { activities, follows, users } from "@trails-cool/db/schema/journal"; +import { fanout } from "./notifications-fanout.ts"; +import { listForUser } from "../lib/notifications.server.ts"; + +// Same opt-in flag as the rest of the notifications integration tests. +const runIntegration = process.env.NOTIFICATIONS_INTEGRATION === "1"; + +async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${opts.username}@example.test`, + username: opts.username, + domain: "test.local", + profileVisibility: opts.profileVisibility ?? "public", + }); + return id; +} + +async function makeFollow(followerId: string, followedId: string, opts: { accepted: boolean } = { accepted: true }) { + const db = getDb(); + const followedUsername = (await db.select({ u: users.username }).from(users).where(eq(users.id, followedId)))[0]!.u; + await db.insert(follows).values({ + id: randomUUID(), + followerId, + followedActorIri: `https://test.local/users/${followedUsername}`, + followedUserId: followedId, + acceptedAt: opts.accepted ? new Date() : null, + }); +} + +async function makeActivity(ownerId: string, visibility: "public" | "private" | "unlisted" = "public", name = "Walk") { + const db = getDb(); + const id = randomUUID(); + await db.insert(activities).values({ + id, + ownerId, + name, + visibility, + }); + return id; +} + +async function wipe() { + const db = getDb(); + await db.execute(sql`DELETE FROM journal.notifications WHERE recipient_user_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.activities WHERE owner_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`); +} + +describe.skipIf(!runIntegration)("notifications-fanout integration", () => { + beforeAll(async () => { + const db = getDb(); + await db.execute(sql`SELECT 1 FROM journal.notifications LIMIT 0`); + }); + afterEach(wipe); + + it("inserts exactly one row per accepted follower; pending followers are skipped", async () => { + const owner = await makeUser({ username: `nf_o_${Date.now()}` }); + const a1 = await makeUser({ username: `nf_a1_${Date.now()}` }); + const a2 = await makeUser({ username: `nf_a2_${Date.now()}` }); + const p1 = await makeUser({ username: `nf_p1_${Date.now()}` }); + const p2 = await makeUser({ username: `nf_p2_${Date.now()}` }); + await makeFollow(a1, owner, { accepted: true }); + await makeFollow(a2, owner, { accepted: true }); + await makeFollow(p1, owner, { accepted: false }); + await makeFollow(p2, owner, { accepted: false }); + + const activityId = await makeActivity(owner, "public", "Public Walk"); + await fanout(activityId); + + expect((await listForUser(a1)).length).toBe(1); + expect((await listForUser(a2)).length).toBe(1); + expect((await listForUser(p1)).length).toBe(0); + expect((await listForUser(p2)).length).toBe(0); + + const a1Rows = await listForUser(a1); + expect(a1Rows[0]?.type).toBe("activity_published"); + expect((a1Rows[0]?.payload as { activityName?: string })?.activityName).toBe("Public Walk"); + }); + + it("skips fan-out for non-public activities (defense in depth)", async () => { + const owner = await makeUser({ username: `nf_np_o_${Date.now()}` }); + const f = await makeUser({ username: `nf_np_f_${Date.now()}` }); + await makeFollow(f, owner, { accepted: true }); + + const privateAct = await makeActivity(owner, "private"); + const unlistedAct = await makeActivity(owner, "unlisted"); + await fanout(privateAct); + await fanout(unlistedAct); + + expect((await listForUser(f)).length).toBe(0); + }); + + it("is idempotent under retry — second fanout doesn't double-insert", async () => { + const owner = await makeUser({ username: `nf_id_o_${Date.now()}` }); + const f = await makeUser({ username: `nf_id_f_${Date.now()}` }); + await makeFollow(f, owner, { accepted: true }); + + const activityId = await makeActivity(owner, "public"); + await fanout(activityId); + await fanout(activityId); + + expect((await listForUser(f)).length).toBe(1); + }); +}); diff --git a/apps/journal/app/jobs/notifications-fanout.ts b/apps/journal/app/jobs/notifications-fanout.ts new file mode 100644 index 0000000..8d9581e --- /dev/null +++ b/apps/journal/app/jobs/notifications-fanout.ts @@ -0,0 +1,98 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { and, eq, isNotNull } from "drizzle-orm"; +import { getDb } from "../lib/db.ts"; +import { activities, follows, users } from "@trails-cool/db/schema/journal"; +import { createNotification } from "../lib/notifications.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +interface FanoutData { + activityId: string; +} + +/** + * Fan out an `activity_published` notification to every accepted + * follower of the activity's owner. Idempotent at the DB level via the + * `(recipient_user_id, type, subject_id)` unique partial index — a + * retry after partial failure won't double-insert. + */ +export const notificationsFanoutJob: JobDefinition = { + name: "notifications-fanout", + retryLimit: 3, + expireInSeconds: 300, + async handler(job) { + // pg-boss v12: `job` may be an array (batch) per its docs; we + // process whichever shape we get. + const batch = Array.isArray(job) ? job : [job]; + for (const item of batch) { + const data = item.data as FanoutData; + await fanout(data.activityId); + } + }, +}; + +export async function fanout(activityId: string): Promise { + const db = getDb(); + + // Load the activity + owner info needed for the payload snapshot. + const [row] = await db + .select({ + id: activities.id, + name: activities.name, + visibility: activities.visibility, + ownerId: activities.ownerId, + ownerUsername: users.username, + ownerDisplayName: users.displayName, + }) + .from(activities) + .innerJoin(users, eq(activities.ownerId, users.id)) + .where(eq(activities.id, activityId)); + + if (!row) { + logger.warn({ activityId }, "fanout: activity not found, skipping"); + return; + } + // Defense in depth — the create-side guard already filters this, but + // recheck here so the job doesn't mistakenly fan out a row that was + // later flipped to private/unlisted before the job ran. + if (row.visibility !== "public") { + logger.info({ activityId, visibility: row.visibility }, "fanout: skipping non-public activity"); + return; + } + + // Find every accepted follower of the owner. + const recipients = await db + .select({ followerId: follows.followerId }) + .from(follows) + .where( + and( + eq(follows.followedUserId, row.ownerId), + isNotNull(follows.acceptedAt), + ), + ); + + let inserted = 0; + for (const r of recipients) { + // Don't notify the owner about their own activity if they happen + // to follow themselves (shouldn't happen — followUser refuses + // self-follow — but defense in depth). + if (r.followerId === row.ownerId) continue; + const created = await createNotification({ + type: "activity_published", + recipientUserId: r.followerId, + actorUserId: row.ownerId, + subjectId: row.id, + payload: { + activityId: row.id, + activityName: row.name, + ownerUsername: row.ownerUsername, + ownerDisplayName: row.ownerDisplayName, + }, + }); + if (created) inserted += 1; + } + + logger.info( + { activityId, recipients: recipients.length, inserted }, + "notifications-fanout completed", + ); +} diff --git a/apps/journal/app/jobs/notifications-purge.ts b/apps/journal/app/jobs/notifications-purge.ts new file mode 100644 index 0000000..cf87fa9 --- /dev/null +++ b/apps/journal/app/jobs/notifications-purge.ts @@ -0,0 +1,21 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { purgeReadOlderThan } from "../lib/notifications.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Daily retention pass. Drops notifications whose `read_at` is older + * than 90 days; unread rows are kept indefinitely so users never miss + * an event. + */ +export const notificationsPurgeJob: JobDefinition = { + name: "notifications-purge", + cron: "30 3 * * *", // daily at 03:30 UTC (offset from demo-bot-prune to spread load) + retryLimit: 1, + expireInSeconds: 60, + async handler() { + const days = 90; + const purged = await purgeReadOlderThan(days); + logger.info({ days, purged }, "notifications-purge"); + return { days, purged }; + }, +}; diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index 5b3562a..0dfd7f2 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -28,7 +28,18 @@ export async function updateActivityVisibility( .set({ visibility }) .where(and(eq(activities.id, id), eq(activities.ownerId, ownerId))) .returning({ id: activities.id }); - return result.length > 0; + if (result.length === 0) return false; + + // Notify followers when an activity becomes public. The unique + // (recipient, type, subject_id) partial index makes the fan-out + // idempotent, so toggling private→public→private→public won't spam + // followers (only the first transition per activity emits). + if (visibility === "public") { + const { enqueueOptional } = await import("./boss.server.ts"); + await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" }); + } + + return true; } export async function createActivity(ownerId: string, input: ActivityInput) { @@ -67,12 +78,21 @@ export async function createActivity(ownerId: string, input: ActivityInput) { elevationGain, elevationLoss, startedAt, + ...(input.visibility ? { visibility: input.visibility } : {}), }); if (input.gpx) { await setGeomFromGpx(id, "activities", input.gpx); } + // Public activities at creation also fan out (matches the + // updateActivityVisibility path for the case where visibility is set + // up-front rather than flipped later). + if (input.visibility === "public") { + const { enqueueOptional } = await import("./boss.server.ts"); + await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" }); + } + return id; } diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index cc006bc..2fbcbf5 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -154,7 +154,7 @@ export async function registerWithMagicLink( email: string, username: string, termsVersion: string, -): Promise { +): Promise<{ token: string; code: string }> { const db = getDb(); const [existingEmail] = await db.select().from(users).where(eq(users.email, email)); @@ -175,18 +175,21 @@ export async function registerWithMagicLink( termsVersion, }); - // Create magic token for verification + // Same shape as login's createMagicToken — token for the click-through + // link, 6-digit code for paste-from-email/SMS flows (mobile). const token = randomBytes(32).toString("base64url"); + const code = generateLoginCode(); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); await db.insert(magicTokens).values({ id: randomUUID(), email, token, + code, expiresAt, }); - return token; + return { token, code }; } // --- Passkey Login --- diff --git a/apps/journal/app/lib/boss.server.ts b/apps/journal/app/lib/boss.server.ts new file mode 100644 index 0000000..7e4b654 --- /dev/null +++ b/apps/journal/app/lib/boss.server.ts @@ -0,0 +1,51 @@ +// Module-level pg-boss singleton. server.ts initializes the boss + starts +// the worker; feature code (e.g., activities.server.ts) calls `getBoss()` +// to enqueue jobs against the same instance. The singleton's lifecycle +// is bound to the Node process — startWorker calls boss.start(); the +// SIGTERM handler stops it. + +// Structurally typed (we only need `send`) so we don't have to pull +// pg-boss into the journal app's dep graph just for the typedef. +interface BossLike { + send(queueName: string, data: unknown): Promise; +} + +let _boss: BossLike | null = null; + +/** Set by server.ts once the boss is created + started. */ +export function setBoss(boss: BossLike): void { + _boss = boss; +} + +/** + * Get the started pg-boss instance. Throws if called before + * server.ts has initialized it (i.e., outside a running Journal + * server context). + */ +export function getBoss(): BossLike { + if (!_boss) { + throw new Error("pg-boss not initialized — getBoss called before server bootstrap"); + } + return _boss; +} + +/** + * Best-effort enqueue: log + swallow errors so a downstream queue + * outage doesn't fail the user-visible request that triggered the + * fan-out. Use this for "fire and forget" notifications work. + */ +export async function enqueueOptional( + queue: string, + data: unknown, + ctx: Record = {}, +): Promise { + try { + const boss = getBoss(); + await boss.send(queue, data); + } catch (err) { + // Lazy import to avoid cycles in test environments where logger.server + // pulls in env-dependent setup. + const { logger } = await import("./logger.server.ts"); + logger.warn({ err, queue, ...ctx }, "boss.send failed; continuing"); + } +} diff --git a/apps/journal/app/lib/events.server.ts b/apps/journal/app/lib/events.server.ts new file mode 100644 index 0000000..4f649da --- /dev/null +++ b/apps/journal/app/lib/events.server.ts @@ -0,0 +1,48 @@ +// In-process Server-Sent Events broker. Generation hooks call +// `emitTo(userId, event, data)` after they commit; any open SSE +// connection for that user gets the event written to its stream. +// +// Single-process today. When the Journal goes multi-process, swap the +// in-memory `Map` for a Redis pub/sub adapter behind the same +// emitTo/register interface — no caller changes needed. + +interface Connection { + send: (event: string, data: unknown) => void; + close: () => void; +} + +const connections = new Map>(); + +export function register(userId: string, conn: Connection): () => void { + let set = connections.get(userId); + if (!set) { + set = new Set(); + connections.set(userId, set); + } + set.add(conn); + return () => { + const s = connections.get(userId); + if (!s) return; + s.delete(conn); + if (s.size === 0) connections.delete(userId); + }; +} + +export function emitTo(userId: string, event: string, data: unknown): void { + const set = connections.get(userId); + if (!set) return; + for (const conn of set) { + try { + conn.send(event, data); + } catch { + // Broken pipe — connection will get cleaned up on its own + // teardown path; defensively close here too. + try { conn.close(); } catch { /* ignore */ } + } + } +} + +/** Test/diagnostic helper: how many connections does `userId` have? */ +export function connectionCount(userId: string): number { + return connections.get(userId)?.size ?? 0; +} diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts index 5165e2c..48351d2 100644 --- a/apps/journal/app/lib/follow.server.ts +++ b/apps/journal/app/lib/follow.server.ts @@ -3,6 +3,8 @@ import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm"; import { getDb } from "./db.ts"; import { users, follows } from "@trails-cool/db/schema/journal"; import { localActorIri } from "./actor-iri.ts"; +import { createNotification } from "./notifications.server.ts"; +import { logger } from "./logger.server.ts"; export class FollowError extends Error { readonly code: "self_follow" | "user_not_found" | "not_found" | "forbidden"; @@ -52,18 +54,57 @@ export async function followUser(followerId: string, targetUsername: string): Pr .from(follows) .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri))); if (existing) { + // Idempotent re-follow: do NOT emit a notification (the recipient + // was notified when the row was first created). return { following: existing.acceptedAt !== null, pending: existing.acceptedAt === null }; } const acceptedAt = target.profileVisibility === "public" ? new Date() : null; + const followId = randomUUID(); await db.insert(follows).values({ - id: randomUUID(), + id: followId, followerId, followedActorIri, followedUserId: target.id, acceptedAt, }); + // Notify the target. follow_received for auto-accepted public follows; + // follow_request_received for Pending follows against private profiles. + // Errors are logged but don't fail the follow — the user-visible + // follow already succeeded. + try { + const [follower] = await db + .select({ username: users.username, displayName: users.displayName }) + .from(users) + .where(eq(users.id, followerId)); + if (follower) { + const payload = { + followerUsername: follower.username, + followerDisplayName: follower.displayName, + }; + await createNotification( + acceptedAt !== null + ? { + type: "follow_received", + recipientUserId: target.id, + actorUserId: followerId, + subjectId: followId, + payload, + } + : { + type: "follow_request_received", + recipientUserId: target.id, + actorUserId: followerId, + subjectId: followId, + payload, + }, + ); + } + } catch (err) { + logger.warn({ err, followId }, "followUser: notification emit failed"); + } + return { following: acceptedAt !== null, pending: acceptedAt === null, @@ -183,8 +224,36 @@ export async function approveFollowRequest(ownerId: string, followId: string): P isNull(follows.acceptedAt), ), ) - .returning({ id: follows.id }); - return result.length > 0; + .returning({ id: follows.id, followerId: follows.followerId }); + + if (result.length === 0) return false; + + // Notify the requester that their request landed. Lookup target's + // display info for the payload. Errors logged but don't undo the + // approve — the follow row is already accepted. + try { + const followerId = result[0]!.followerId; + const [target] = await db + .select({ username: users.username, displayName: users.displayName }) + .from(users) + .where(eq(users.id, ownerId)); + if (target) { + await createNotification({ + type: "follow_request_approved", + recipientUserId: followerId, + actorUserId: ownerId, + subjectId: followId, + payload: { + targetUsername: target.username, + targetDisplayName: target.displayName, + }, + }); + } + } catch (err) { + logger.warn({ err, followId }, "approveFollowRequest: notification emit failed"); + } + + return true; } /** diff --git a/apps/journal/app/lib/notifications.integration.test.ts b/apps/journal/app/lib/notifications.integration.test.ts new file mode 100644 index 0000000..62229fc --- /dev/null +++ b/apps/journal/app/lib/notifications.integration.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, notifications } from "@trails-cool/db/schema/journal"; +import { + createNotification, + listForUser, + countUnread, + markRead, + markAllRead, + purgeReadOlderThan, +} from "./notifications.server.ts"; +import { + followUser, + approveFollowRequest, + listPendingFollowRequests, +} from "./follow.server.ts"; + +// Opt-in: hits real Postgres. Same convention as demo-bot / follow integration. +const runIntegration = process.env.NOTIFICATIONS_INTEGRATION === "1"; + +async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${opts.username}@example.test`, + username: opts.username, + domain: "test.local", + profileVisibility: opts.profileVisibility ?? "public", + }); + return id; +} + +async function wipe() { + const db = getDb(); + await db.execute(sql`DELETE FROM journal.notifications WHERE recipient_user_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`); +} + +describe.skipIf(!runIntegration)("notifications.server integration", () => { + beforeAll(async () => { + const db = getDb(); + await db.execute(sql`SELECT 1 FROM journal.notifications LIMIT 0`); + }); + afterEach(wipe); + + it("createNotification persists payload + version and is idempotent on (recipient,type,subject)", async () => { + const a = await makeUser({ username: `n_a_${Date.now()}` }); + const b = await makeUser({ username: `n_b_${Date.now()}` }); + const subject = randomUUID(); + + const first = await createNotification({ + type: "activity_published", + recipientUserId: a, + actorUserId: b, + subjectId: subject, + payload: { activityId: subject, activityName: "Walk", ownerUsername: "b", ownerDisplayName: null }, + }); + expect(first).toBe(true); + + // Re-insert with same (recipient, type, subject_id) is a no-op. + const second = await createNotification({ + type: "activity_published", + recipientUserId: a, + actorUserId: b, + subjectId: subject, + payload: { activityId: subject, activityName: "Walk", ownerUsername: "b", ownerDisplayName: null }, + }); + expect(second).toBe(false); + + const rows = await listForUser(a); + expect(rows.length).toBe(1); + expect(rows[0]?.payloadVersion).toBe(1); + expect(rows[0]?.payload).toMatchObject({ activityId: subject }); + }); + + it("countUnread excludes read; markRead is owner-bound and idempotent", async () => { + const a = await makeUser({ username: `n_mr_a_${Date.now()}` }); + const c = await makeUser({ username: `n_mr_c_${Date.now()}` }); + + await createNotification({ + type: "follow_received", + recipientUserId: a, + actorUserId: null, + subjectId: null, + payload: { followerUsername: "x", followerDisplayName: null }, + }); + expect(await countUnread(a)).toBe(1); + + const all = await listForUser(a); + const id = all[0]!.id; + + // Foreign user can't mark a's notification read. + expect(await markRead(c, id)).toBe(false); + expect(await countUnread(a)).toBe(1); + + // Owner can. + expect(await markRead(a, id)).toBe(true); + expect(await countUnread(a)).toBe(0); + + // Idempotent — already read, but row still belongs to a, so returns true. + expect(await markRead(a, id)).toBe(true); + }); + + it("markAllRead clears every unread row of the caller and only the caller", async () => { + const a = await makeUser({ username: `n_mar_a_${Date.now()}` }); + const b = await makeUser({ username: `n_mar_b_${Date.now()}` }); + + await createNotification({ + type: "follow_received", + recipientUserId: a, + actorUserId: null, + subjectId: null, + payload: { followerUsername: "x", followerDisplayName: null }, + }); + await createNotification({ + type: "follow_received", + recipientUserId: a, + actorUserId: null, + subjectId: null, + payload: { followerUsername: "y", followerDisplayName: null }, + }); + await createNotification({ + type: "follow_received", + recipientUserId: b, + actorUserId: null, + subjectId: null, + payload: { followerUsername: "z", followerDisplayName: null }, + }); + + const updated = await markAllRead(a); + expect(updated).toBe(2); + expect(await countUnread(a)).toBe(0); + expect(await countUnread(b)).toBe(1); + }); + + it("purgeReadOlderThan removes only read rows past the window", async () => { + const a = await makeUser({ username: `n_pr_a_${Date.now()}` }); + + const db = getDb(); + // Old read (should be purged), old unread (kept), recent read (kept). + await db.insert(notifications).values([ + { + id: randomUUID(), + recipientUserId: a, + type: "follow_received", + actorUserId: null, + subjectId: null, + payload: { followerUsername: "old-read" }, + payloadVersion: 1, + readAt: new Date(Date.now() - 100 * 24 * 60 * 60 * 1000), + createdAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), + }, + { + id: randomUUID(), + recipientUserId: a, + type: "follow_received", + actorUserId: null, + subjectId: null, + payload: { followerUsername: "old-unread" }, + payloadVersion: 1, + readAt: null, + createdAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), + }, + { + id: randomUUID(), + recipientUserId: a, + type: "follow_received", + actorUserId: null, + subjectId: null, + payload: { followerUsername: "recent-read" }, + payloadVersion: 1, + readAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), + createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), + }, + ]); + + const purged = await purgeReadOlderThan(90); + expect(purged).toBe(1); + + const survivors = await db + .select({ payload: notifications.payload }) + .from(notifications) + .where(eq(notifications.recipientUserId, a)); + const names = survivors + .map((s) => (s.payload as { followerUsername?: string } | null)?.followerUsername) + .sort(); + expect(names).toEqual(["old-unread", "recent-read"]); + }); + + it("followUser → follow_received emitted for public-target auto-accept; idempotent re-follow does NOT double-emit", async () => { + const a = await makeUser({ username: `n_fr_a_${Date.now()}` }); + const b = await makeUser({ username: `n_fr_b_${Date.now()}` }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + + await followUser(a, bRow.username); + let bRows = await listForUser(b); + expect(bRows.length).toBe(1); + expect(bRows[0]?.type).toBe("follow_received"); + + // Re-follow is idempotent at the follow layer — and must NOT emit again. + await followUser(a, bRow.username); + bRows = await listForUser(b); + expect(bRows.length).toBe(1); + }); + + it("followUser private-target → follow_request_received; approveFollowRequest → follow_request_approved", async () => { + const a = await makeUser({ username: `n_fp_a_${Date.now()}` }); + const b = await makeUser({ username: `n_fp_b_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + + await followUser(a, bRow.username); + const bRows = await listForUser(b); + expect(bRows.length).toBe(1); + expect(bRows[0]?.type).toBe("follow_request_received"); + + const reqs = await listPendingFollowRequests(b); + expect(reqs.length).toBe(1); + + await approveFollowRequest(b, reqs[0]!.id); + const aRows = await listForUser(a); + expect(aRows.length).toBe(1); + expect(aRows[0]?.type).toBe("follow_request_approved"); + + // Idempotent re-approval must not double-emit. + await approveFollowRequest(b, reqs[0]!.id); + const aRowsAfter = await listForUser(a); + expect(aRowsAfter.length).toBe(1); + }); +}); diff --git a/apps/journal/app/lib/notifications.server.ts b/apps/journal/app/lib/notifications.server.ts new file mode 100644 index 0000000..63e1fef --- /dev/null +++ b/apps/journal/app/lib/notifications.server.ts @@ -0,0 +1,190 @@ +import { randomUUID } from "node:crypto"; +import { and, count, desc, eq, isNull, lt, sql } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { notifications } from "@trails-cool/db/schema/journal"; +import type { NotificationType } from "@trails-cool/db/schema/journal"; +import { emitTo } from "./events.server.ts"; +import { + PAYLOAD_VERSION, + type ActivityPayloadV1, + type ApprovalPayloadV1, + type FollowPayloadV1, +} from "./notifications/payload.ts"; + +/** + * Push the current unread count to all of `userId`'s open SSE + * connections. Called after any state change that could affect the + * count: createNotification (new row), markRead (one less unread), + * markAllRead (zero unread). + */ +async function emitUnreadCount(userId: string): Promise { + const c = await countUnread(userId); + emitTo(userId, "notifications.unread", { count: c }); +} + +// Discriminated union of (type, payload) so callers can't pass the +// wrong payload shape for a given event type. PAYLOAD_VERSION is set +// internally; callers don't pick a version. +type CreateInput = + | { type: "follow_request_received"; recipientUserId: string; actorUserId: string | null; subjectId: string | null; payload: FollowPayloadV1 } + | { type: "follow_received"; recipientUserId: string; actorUserId: string | null; subjectId: string | null; payload: FollowPayloadV1 } + | { type: "follow_request_approved"; recipientUserId: string; actorUserId: string | null; subjectId: string | null; payload: ApprovalPayloadV1 } + | { type: "activity_published"; recipientUserId: string; actorUserId: string | null; subjectId: string | null; payload: ActivityPayloadV1 }; + +/** + * Create a notification. When `subjectId` is set, idempotent against + * the partial unique index `(recipient_user_id, type, subject_id) WHERE + * subject_id IS NOT NULL` so fan-out job retries can't double-insert. + * When `subjectId` is null (1:1 follow events) idempotency is enforced + * upstream by the follow hooks (they only emit on a *new* row), so a + * plain insert is safe and avoids ON CONFLICT inference against a + * partial index, which Postgres rejects. + */ +export async function createNotification(input: CreateInput): Promise { + const db = getDb(); + const values = { + id: randomUUID(), + recipientUserId: input.recipientUserId, + type: input.type, + actorUserId: input.actorUserId, + subjectId: input.subjectId, + payload: input.payload as unknown as Record, + payloadVersion: PAYLOAD_VERSION, + }; + + const result = input.subjectId + ? await db + .insert(notifications) + .values(values) + .onConflictDoNothing({ + target: [notifications.recipientUserId, notifications.type, notifications.subjectId], + // Match the partial unique index's predicate so Postgres can + // infer the arbiter index (`WHERE subject_id IS NOT NULL`). + // `onConflictDoNothing` only exposes `where` (no targetWhere). + where: sql`${notifications.subjectId} IS NOT NULL`, + }) + .returning({ id: notifications.id }) + : await db.insert(notifications).values(values).returning({ id: notifications.id }); + + if (result.length > 0) { + // Push refreshed count to any open SSE connections for this + // recipient. Emit happens after the row commits so the count + // reflects reality. + await emitUnreadCount(input.recipientUserId); + return true; + } + return false; +} + +export interface NotificationRow { + id: string; + type: NotificationType; + actorUserId: string | null; + subjectId: string | null; + payload: Record | null; + payloadVersion: number; + readAt: Date | null; + createdAt: Date; +} + +const PAGE_SIZE = 50; + +export async function listForUser( + userId: string, + opts: { page?: number } = {}, +): Promise { + const db = getDb(); + const page = Math.max(1, opts.page ?? 1); + const rows = await db + .select({ + id: notifications.id, + type: notifications.type, + actorUserId: notifications.actorUserId, + subjectId: notifications.subjectId, + payload: notifications.payload, + payloadVersion: notifications.payloadVersion, + readAt: notifications.readAt, + createdAt: notifications.createdAt, + }) + .from(notifications) + .where(eq(notifications.recipientUserId, userId)) + .orderBy(desc(notifications.createdAt)) + .limit(PAGE_SIZE) + .offset((page - 1) * PAGE_SIZE); + return rows; +} + +export async function countUnread(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(notifications) + .where( + and( + eq(notifications.recipientUserId, userId), + isNull(notifications.readAt), + ), + ); + return row?.n ?? 0; +} + +/** + * Mark a single notification read. Owner-bound: only the recipient can + * mark their own. Returns true on success, false if the row doesn't + * exist or doesn't belong to the caller. Idempotent for already-read. + */ +export async function markRead(ownerId: string, id: string): Promise { + const db = getDb(); + const result = await db + .update(notifications) + .set({ readAt: new Date() }) + .where( + and( + eq(notifications.id, id), + eq(notifications.recipientUserId, ownerId), + ), + ) + .returning({ id: notifications.id }); + if (result.length === 0) return false; + await emitUnreadCount(ownerId); + return true; +} + +/** + * Mark all of `ownerId`'s unread notifications read. Returns the + * number of rows touched. + */ +export async function markAllRead(ownerId: string): Promise { + const db = getDb(); + const result = await db + .update(notifications) + .set({ readAt: new Date() }) + .where( + and( + eq(notifications.recipientUserId, ownerId), + isNull(notifications.readAt), + ), + ) + .returning({ id: notifications.id }); + if (result.length > 0) await emitUnreadCount(ownerId); + return result.length; +} + +/** + * Retention: drop read notifications older than `days`. Unread rows + * survive indefinitely. Called by the daily pg-boss job. + */ +export async function purgeReadOlderThan(days: number): Promise { + const db = getDb(); + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const result = await db + .delete(notifications) + .where( + and( + sql`${notifications.readAt} IS NOT NULL`, + lt(notifications.readAt, cutoff), + ), + ) + .returning({ id: notifications.id }); + return result.length; +} diff --git a/apps/journal/app/lib/notifications/link-for.test.ts b/apps/journal/app/lib/notifications/link-for.test.ts new file mode 100644 index 0000000..0b501ce --- /dev/null +++ b/apps/journal/app/lib/notifications/link-for.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { linkFor } from "./link-for.ts"; + +describe("linkFor", () => { + it("follow_received uses payload.followerUsername", () => { + const link = linkFor({ + type: "follow_received", + subjectId: null, + payloadVersion: 1, + payload: { followerUsername: "alice", followerDisplayName: null }, + }); + expect(link.web).toBe("/users/alice"); + expect(link.mobile).toBe("trails:/users/alice"); + expect(link.email).toMatch(/^https?:\/\//); + expect(link.email).toMatch(/\/users\/alice$/); + }); + + it("follow_received falls back to / when payload is missing", () => { + const link = linkFor({ + type: "follow_received", + subjectId: null, + payloadVersion: 1, + payload: null, + }); + expect(link.web).toBe("/"); + }); + + it("follow_request_received always points at the requests page", () => { + const link = linkFor({ + type: "follow_request_received", + subjectId: null, + payloadVersion: 1, + payload: { followerUsername: "bob", followerDisplayName: null }, + }); + expect(link.web).toBe("/follows/requests"); + }); + + it("follow_request_approved uses payload.targetUsername", () => { + const link = linkFor({ + type: "follow_request_approved", + subjectId: null, + payloadVersion: 1, + payload: { targetUsername: "carol", targetDisplayName: null }, + }); + expect(link.web).toBe("/users/carol"); + }); + + it("activity_published prefers subjectId, falls back to payload.activityId", () => { + const fromSubject = linkFor({ + type: "activity_published", + subjectId: "sub-id-1", + payloadVersion: 1, + payload: { activityId: "payload-id", activityName: "x", ownerUsername: "o", ownerDisplayName: null }, + }); + expect(fromSubject.web).toBe("/activities/sub-id-1"); + + const fromPayload = linkFor({ + type: "activity_published", + subjectId: null, + payloadVersion: 1, + payload: { activityId: "payload-id", activityName: "x", ownerUsername: "o", ownerDisplayName: null }, + }); + expect(fromPayload.web).toBe("/activities/payload-id"); + }); + + it("activity_published falls back to / when both subjectId and payload are missing", () => { + const link = linkFor({ + type: "activity_published", + subjectId: null, + payloadVersion: 1, + payload: null, + }); + expect(link.web).toBe("/"); + }); +}); diff --git a/apps/journal/app/lib/notifications/link-for.ts b/apps/journal/app/lib/notifications/link-for.ts new file mode 100644 index 0000000..e551854 --- /dev/null +++ b/apps/journal/app/lib/notifications/link-for.ts @@ -0,0 +1,65 @@ +// Single source of truth for "what URL does this notification link to?" +// Every renderer (web loader, future mobile push formatter, future email +// formatter) calls linkFor; the type→URL mapping lives here, in one +// place, in three flavors so the same logical destination renders +// correctly per platform. + +import type { NotificationType } from "@trails-cool/db/schema/journal"; +import { readPayload } from "./payload.ts"; + +export interface LinkBundle { + web: string; // app-relative path for in-app navigation + mobile?: string; // trails:// scheme for native deep linking + email?: string; // absolute https:// URL for email click-through +} + +export interface NotificationForLink { + type: NotificationType; + subjectId: string | null; + payload: Record | null; + payloadVersion: number; +} + +export function linkFor(n: NotificationForLink): LinkBundle { + const origin = process.env.ORIGIN ?? "https://trails.cool"; + const p = readPayload(n.type, n.payloadVersion, n.payload); + + switch (n.type) { + case "follow_received": + case "follow_request_received": { + // The actionable surface for a request lives at /follows/requests + // (where Approve/Reject is); a received auto-accept notification + // links to the new follower's profile. Both fall back to the + // payload's followerUsername if the actor account is gone. + const username = + p && "followerUsername" in p ? p.followerUsername : null; + const path = + n.type === "follow_request_received" + ? "/follows/requests" + : username + ? `/users/${username}` + : "/"; + return buildBundle(origin, path); + } + case "follow_request_approved": { + const username = + p && "targetUsername" in p ? p.targetUsername : null; + const path = username ? `/users/${username}` : "/"; + return buildBundle(origin, path); + } + case "activity_published": { + const activityId = + n.subjectId ?? (p && "activityId" in p ? p.activityId : null); + const path = activityId ? `/activities/${activityId}` : "/"; + return buildBundle(origin, path); + } + } +} + +function buildBundle(origin: string, path: string): LinkBundle { + return { + web: path, + mobile: `trails:/${path.startsWith("/") ? "" : "/"}${path.replace(/^\//, "")}`, + email: `${origin.replace(/\/$/, "")}${path}`, + }; +} diff --git a/apps/journal/app/lib/notifications/payload.ts b/apps/journal/app/lib/notifications/payload.ts new file mode 100644 index 0000000..f7e4742 --- /dev/null +++ b/apps/journal/app/lib/notifications/payload.ts @@ -0,0 +1,70 @@ +// Per-type payload snapshots stored at notification creation time. +// Lets future mobile push / email / RSS renderers display the +// notification without a fresh DB lookup, and lets the web renderer +// fall back to the snapshot when the live record is gone (subject +// deleted, gone private, etc.). +// +// Versioning: every payload type carries an associated `payloadVersion` +// (stored in a separate column on the row, not embedded in the JSON). +// To evolve a payload schema, bump the version and update renderers to +// handle both shapes. Retention naturally ages out old versions. + +import type { NotificationType } from "@trails-cool/db/schema/journal"; + +export const PAYLOAD_VERSION = 1; + +export interface FollowPayloadV1 { + followerUsername: string; + followerDisplayName: string | null; +} + +export interface ApprovalPayloadV1 { + targetUsername: string; + targetDisplayName: string | null; +} + +export interface ActivityPayloadV1 { + activityId: string; + activityName: string; + ownerUsername: string; + ownerDisplayName: string | null; +} + +export type NotificationPayload = + | { type: "follow_request_received"; v: 1; payload: FollowPayloadV1 } + | { type: "follow_received"; v: 1; payload: FollowPayloadV1 } + | { type: "follow_request_approved"; v: 1; payload: ApprovalPayloadV1 } + | { type: "activity_published"; v: 1; payload: ActivityPayloadV1 }; + +// Narrow the `payload` JSONB to the matching shape based on `type` + `v`. +// Renderers should call this rather than indexing into the loose Record. +export function readPayload( + type: NotificationType, + version: number, + payload: Record | null, +): NotificationPayload["payload"] | null { + if (!payload || version !== 1) return null; + switch (type) { + case "follow_request_received": + case "follow_received": + return { + followerUsername: String(payload.followerUsername ?? ""), + followerDisplayName: + (payload.followerDisplayName as string | null | undefined) ?? null, + }; + case "follow_request_approved": + return { + targetUsername: String(payload.targetUsername ?? ""), + targetDisplayName: + (payload.targetDisplayName as string | null | undefined) ?? null, + }; + case "activity_published": + return { + activityId: String(payload.activityId ?? ""), + activityName: String(payload.activityName ?? ""), + ownerUsername: String(payload.ownerUsername ?? ""), + ownerDisplayName: + (payload.ownerDisplayName as string | null | undefined) ?? null, + }; + } +} diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index 3b52242..6101cad 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -8,6 +8,7 @@ import { detectLocale } from "@trails-cool/i18n"; import { getSessionUser } from "~/lib/auth.server"; import { LocaleProvider } from "~/components/LocaleContext"; import { AlphaBanner } from "~/components/AlphaBanner"; +import { useUnreadNotifications } from "~/hooks/useUnreadNotifications"; import { Footer } from "~/components/Footer"; import { initSentryClient, stopSentryClient } from "~/lib/sentry.client"; import { TERMS_VERSION } from "~/lib/legal"; @@ -67,27 +68,38 @@ export async function loader({ request }: Route.LoaderArgs) { // users. Hidden behind a dynamic import so the root layout doesn't // pull in the follow module on anonymous renders. let pendingFollowRequests = 0; + let unreadNotifications = 0; if (user) { const { countPendingFollowRequests } = await import("./lib/follow.server.ts"); - pendingFollowRequests = await countPendingFollowRequests(user.id); + const { countUnread } = await import("./lib/notifications.server.ts"); + [pendingFollowRequests, unreadNotifications] = await Promise.all([ + countPendingFollowRequests(user.id), + countUnread(user.id), + ]); } return { user: user ? { id: user.id, username: user.username } : null, locale, pendingFollowRequests, + unreadNotifications, }; } function NavBar({ user, pendingFollowRequests, + unreadNotifications, }: { user: { id: string; username: string } | null; pendingFollowRequests: number; + unreadNotifications: number; }) { const { t } = useTranslation("journal"); const location = useLocation(); + // Live-updating unread count for the navbar badge. Loader value is + // the SSR baseline; SSE pushes overrides after first event. + const liveUnread = useUnreadNotifications(unreadNotifications, user !== null); const isActive = (path: string) => location.pathname === path || location.pathname.startsWith(path + "/"); @@ -123,6 +135,33 @@ function NavBar({ {user ? ( <> + + + + + {liveUnread > 0 && ( + + {liveUnread} + + )} + { if (user) { initSentryClient(); @@ -189,7 +229,11 @@ export default function App({ loaderData }: Route.ComponentProps) { return ( - + diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 1bf83a3..0247118 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -31,6 +31,10 @@ export default [ route("api/follows/:id/approve", "routes/api.follows.$id.approve.ts"), route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"), route("feed", "routes/feed.tsx"), + route("api/events", "routes/api.events.ts"), + route("notifications", "routes/notifications.tsx"), + route("api/notifications/:id/read", "routes/api.notifications.$id.read.ts"), + route("api/notifications/read-all", "routes/api.notifications.read-all.ts"), route("settings", "routes/settings.tsx"), route("api/settings/profile", "routes/api.settings.profile.ts"), route("api/settings/email", "routes/api.settings.email.ts"), diff --git a/apps/journal/app/routes/api.auth.register.ts b/apps/journal/app/routes/api.auth.register.ts index 2410a0b..a78d005 100644 --- a/apps/journal/app/routes/api.auth.register.ts +++ b/apps/journal/app/routes/api.auth.register.ts @@ -35,12 +35,15 @@ export async function action({ request }: Route.ActionArgs) { } if (step === "register-magic-link") { - const token = await registerWithMagicLink(email, username, termsVersion); + const { token, code } = await registerWithMagicLink(email, username, termsVersion); const link = `${origin}/auth/verify?token=${token}`; if (process.env.NODE_ENV !== "production") { - return data({ step: "magic-link-sent", devLink: link }); + // Mirror the login endpoint so devs can grab either the link or + // the 6-digit code straight from the terminal. + console.log(`[Register Magic Link] ${email}: ${link} (code: ${code})`); + return data({ step: "magic-link-sent", devLink: link, code }); } - await sendMagicLink(email, link); + await sendMagicLink(email, link, code); sendWelcome(email, username).catch((err) => logger.error({ err }, "Failed to send welcome email"), ); diff --git a/apps/journal/app/routes/api.events.ts b/apps/journal/app/routes/api.events.ts new file mode 100644 index 0000000..5c0bbce --- /dev/null +++ b/apps/journal/app/routes/api.events.ts @@ -0,0 +1,92 @@ +import type { Route } from "./+types/api.events"; +import { getSessionUser } from "~/lib/auth.server"; +import { register } from "~/lib/events.server"; +import { countUnread } from "~/lib/notifications.server"; + +const HEARTBEAT_INTERVAL_MS = 25_000; +// Server-suggested EventSource reconnect delay. Browser default is ~3s; +// 5s + small jitter spreads deploy-storm reconnects without making +// transient blips feel sluggish. +const RECONNECT_RETRY_MS = 5_000; + +/** + * GET /api/events — Server-Sent Events stream. Session-bound; anonymous + * visitors get 401. Emits `notifications.unread` events when the + * user's unread count changes (badge live-update). + * + * Initial event on connect carries the current count so the client + * doesn't have to wait for a state change to render. + */ +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + const userId = user.id; + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + let closed = false; + + const writeRaw = (chunk: string) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(chunk)); + } catch { + closed = true; + } + }; + + const send = (event: string, data: unknown) => { + // Per the EventSource spec, multi-line `data:` lines are joined + // with newlines; JSON-stringifying single-line is enough here. + writeRaw(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + + const close = () => { + if (closed) return; + closed = true; + try { controller.close(); } catch { /* ignore */ } + }; + + // Server-suggested reconnect backoff + small jitter so deploy + // storms don't reconnect everyone simultaneously. + const jitter = Math.floor(Math.random() * 2000); + writeRaw(`retry: ${RECONNECT_RETRY_MS + jitter}\n\n`); + + const unregister = register(userId, { send, close }); + + // Initial state: current unread count, so the client renders + // correctly without waiting for the first live event. + const initial = await countUnread(userId); + send("notifications.unread", { count: initial }); + + // Heartbeat keeps intermediate proxies from killing the idle + // connection. SSE comments are ignored by the client but keep + // the bytes flowing. + const heartbeat = setInterval(() => { + writeRaw(`: ping\n\n`); + }, HEARTBEAT_INTERVAL_MS); + + // Client disconnect → request.signal aborts → close + clean up. + request.signal.addEventListener("abort", () => { + clearInterval(heartbeat); + unregister(); + close(); + }); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + // Some intermediate proxies buffer text/event-stream by default + // unless told otherwise; this is the de-facto opt-out hint. + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/apps/journal/app/routes/api.notifications.$id.read.ts b/apps/journal/app/routes/api.notifications.$id.read.ts new file mode 100644 index 0000000..0e39f26 --- /dev/null +++ b/apps/journal/app/routes/api.notifications.$id.read.ts @@ -0,0 +1,19 @@ +import { data } from "react-router"; +import type { Route } from "./+types/api.notifications.$id.read"; +import { getSessionUser } from "~/lib/auth.server"; +import { markRead } from "~/lib/notifications.server"; + +export async function action({ request, params }: Route.ActionArgs) { + if (request.method !== "POST") { + return data({ error: "Method not allowed" }, { status: 405 }); + } + const user = await getSessionUser(request); + if (!user) return data({ error: "Unauthorized" }, { status: 401 }); + + const id = params.id; + if (!id) return data({ error: "id required" }, { status: 400 }); + + const ok = await markRead(user.id, id); + if (!ok) return data({ error: "Not found" }, { status: 404 }); + return data({ ok: true }); +} diff --git a/apps/journal/app/routes/api.notifications.read-all.ts b/apps/journal/app/routes/api.notifications.read-all.ts new file mode 100644 index 0000000..38b4875 --- /dev/null +++ b/apps/journal/app/routes/api.notifications.read-all.ts @@ -0,0 +1,15 @@ +import { data } from "react-router"; +import type { Route } from "./+types/api.notifications.read-all"; +import { getSessionUser } from "~/lib/auth.server"; +import { markAllRead } from "~/lib/notifications.server"; + +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") { + return data({ error: "Method not allowed" }, { status: 405 }); + } + const user = await getSessionUser(request); + if (!user) return data({ error: "Unauthorized" }, { status: 401 }); + + const updated = await markAllRead(user.id); + return data({ ok: true, updated }); +} diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index a209eaa..2a5ae74 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -10,13 +10,17 @@ export default function RegisterPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [supportsPasskey, setSupportsPasskey] = useState(null); + const [mode, setMode] = useState<"passkey" | "magic-link">("passkey"); const [magicLinkSent, setMagicLinkSent] = useState(false); + const [verifyCode, setVerifyCode] = useState(""); const [hostname, setHostname] = useState("…"); useEffect(() => { setHostname(window.location.hostname); import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => { - setSupportsPasskey(browserSupportsWebAuthn()); + const supported = browserSupportsWebAuthn(); + setSupportsPasskey(supported); + if (!supported) setMode("magic-link"); }); }, []); @@ -105,9 +109,11 @@ export default function RegisterPage() { if (result.error) { setError(result.error); - } else if (result.devLink) { - window.location.href = result.devLink; } else { + // In dev the API also returns the link/code; we still show the + // code form so the dev can paste either, matching production + // behavior. Devs can copy `devLink` from the server log if they + // want the click-through path instead. setMagicLinkSent(true); } } catch (err) { @@ -117,17 +123,67 @@ export default function RegisterPage() { } }; + const handleVerifyCode = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + // The token row created by `register-magic-link` has the default + // `purpose: "login"`, so the existing login verify-code endpoint + // accepts it without a separate register-only verify path. + const resp = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "verify-code", email, code: verifyCode }), + }); + const result = await resp.json(); + if (result.error) { + setError(result.error); + } else if (result.step === "done") { + window.location.href = "/?add-passkey=1"; + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + if (magicLinkSent) { return ( {t("auth.register")} - - - {t("auth.checkEmail")} {email}. - - - {t("auth.linkExpires")} - + + + + {t("auth.checkEmail")} {email}. + + + {t("auth.linkExpires")} + + + + + {t("auth.codeHelp")} + setVerifyCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + className="block w-full rounded-md border border-gray-300 px-3 py-3 text-center text-2xl font-mono tracking-[0.3em] shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + + {loading ? t("auth.authenticating") : t("auth.verifyCode")} + + {error && {error}} + ); @@ -140,7 +196,7 @@ export default function RegisterPage() { {t("auth.registerDescription")} - + {t("auth.email")} @@ -200,7 +256,7 @@ export default function RegisterPage() { {error} )} - {supportsPasskey ? ( + {mode === "passkey" && supportsPasskey ? ( )} + {supportsPasskey && ( + + setMode(mode === "passkey" ? "magic-link" : "passkey")} + className="text-sm text-gray-500 hover:text-gray-700" + > + {mode === "passkey" ? t("auth.useMagicLink") : t("auth.backToPasskey")} + + + )} + {supportsPasskey === false && ( {t("auth.passkeyNotSupportedRegister")} diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index c88ca1f..a908d23 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -396,17 +396,31 @@ export default function PrivacyPage() { Profile visibility (public / private, - default public): a separate switch from content - visibility. private 404s your profile page and makes - you unfollowable; you can still post public content - reachable by direct URL. Change anytime in account settings. + default private for new accounts): a separate + switch from content visibility. public means + anyone can view your profile and follows auto-accept. + private is Mastodon-style locked: visitors see + a stub with a Request-to-follow button; only accepted + followers see your public content. Change anytime in account + settings. - Follows: which users on this instance follow which. Visible to - anyone via your /users/<you>/followers and - /users/<you>/following pages, mirroring - Mastodon-style conventions. Set your profile to{" "} - private to be unfollowable. + Follows: which users on this instance follow which. + Accepted relations are visible to anyone via your{" "} + /users/<you>/followers and{" "} + /users/<you>/following pages. Pending + requests against private profiles are visible only to the + requester and the target. + + + Notifications: a per-recipient log of social events on this + instance (someone followed you, your follow request was + approved, a person you follow posted publicly). Includes + small per-event metadata (the actor's username + display + name; for activities, the activity name and id) for offline + renderers like future mobile push or email. Visible only to + the recipient. Read notifications are deleted after 90 days; + unread notifications are kept until you read them. diff --git a/apps/journal/app/routes/notifications.tsx b/apps/journal/app/routes/notifications.tsx new file mode 100644 index 0000000..9013cdc --- /dev/null +++ b/apps/journal/app/routes/notifications.tsx @@ -0,0 +1,182 @@ +import { data, redirect, useFetcher } from "react-router"; +import { useTranslation } from "react-i18next"; +import { inArray, eq, and } from "drizzle-orm"; +import type { Route } from "./+types/notifications"; +import { getDb } from "~/lib/db"; +import { getSessionUser } from "~/lib/auth.server"; +import { listForUser } from "~/lib/notifications.server"; +import { linkFor } from "~/lib/notifications/link-for"; +import { readPayload } from "~/lib/notifications/payload"; +import { ClientDate } from "~/components/ClientDate"; +import { activities } from "@trails-cool/db/schema/journal"; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const rows = await listForUser(user.id, { page: 1 }); + + // Renderer guard: drop activity_published rows whose subject is gone + // or no longer public (visibility flipped from public → private/unlisted). + // Fetch the still-public subject IDs in one query, then filter. + const activitySubjectIds = rows + .filter((r) => r.type === "activity_published" && r.subjectId) + .map((r) => r.subjectId as string); + let publicActivityIds = new Set(); + if (activitySubjectIds.length > 0) { + const db = getDb(); + const visible = await db + .select({ id: activities.id }) + .from(activities) + .where(and(inArray(activities.id, activitySubjectIds), eq(activities.visibility, "public"))); + publicActivityIds = new Set(visible.map((v) => v.id)); + } + + const visibleRows = rows.filter((r) => { + if (r.type !== "activity_published") return true; + if (!r.subjectId) return false; + return publicActivityIds.has(r.subjectId); + }); + + return data({ + notifications: visibleRows.map((r) => { + const link = linkFor({ + type: r.type, + subjectId: r.subjectId, + payload: r.payload, + payloadVersion: r.payloadVersion, + }); + const payload = readPayload(r.type, r.payloadVersion, r.payload); + return { + id: r.id, + type: r.type, + readAt: r.readAt?.toISOString() ?? null, + createdAt: r.createdAt.toISOString(), + link: link.web, + payload, + }; + }), + }); +} + +export function meta(_args: Route.MetaArgs) { + return [{ title: "Notifications — trails.cool" }]; +} + +interface Row { + id: string; + type: string; + readAt: string | null; + createdAt: string; + link: string; + payload: Record | null; +} + +function summary(t: (key: string, opts?: Record) => string, n: Row): string { + const p = n.payload as { followerUsername?: string; followerDisplayName?: string | null; + targetUsername?: string; targetDisplayName?: string | null; + activityName?: string; ownerUsername?: string; ownerDisplayName?: string | null } | null; + const someone = t("notifications.someone"); + switch (n.type) { + case "follow_request_received": { + const name = p?.followerDisplayName ?? p?.followerUsername ?? someone; + return t("notifications.summary.followRequestReceived", { name }); + } + case "follow_received": { + const name = p?.followerDisplayName ?? p?.followerUsername ?? someone; + return t("notifications.summary.followReceived", { name }); + } + case "follow_request_approved": { + const name = p?.targetDisplayName ?? p?.targetUsername ?? someone; + return t("notifications.summary.followRequestApproved", { name }); + } + case "activity_published": { + const owner = p?.ownerDisplayName ?? p?.ownerUsername ?? someone; + const activity = p?.activityName ?? ""; + return t("notifications.summary.activityPublished", { owner, activity }); + } + default: + return n.type; + } +} + +function NotificationItem({ row }: { row: Row }) { + const { t } = useTranslation("journal"); + const fetcher = useFetcher(); + const inFlight = fetcher.state !== "idle"; + + const onClick = (e: React.MouseEvent) => { + if (row.readAt) return; // already read; let the link navigate normally + // Mark read in the background; navigation proceeds via the anchor. + fetcher.submit(null, { + method: "post", + action: `/api/notifications/${row.id}/read`, + }); + // The anchor href takes over the navigation; we don't preventDefault + // unless the request fails — and even if it does, the user can mark + // read manually from the page next time. + void e; + }; + + return ( + + + + + {!row.readAt && ( + + )} + {summary(t, row)} + + + + + + + {inFlight && marking read} + + ); +} + +export default function Notifications({ loaderData }: Route.ComponentProps) { + const { notifications } = loaderData; + const { t } = useTranslation("journal"); + const markAll = useFetcher(); + + const hasUnread = notifications.some((n) => !n.readAt); + + return ( + + + {t("notifications.title")} + {hasUnread && ( + + + {t("notifications.markAllRead")} + + + )} + + + {notifications.length === 0 ? ( + {t("notifications.empty")} + ) : ( + + {notifications.map((n) => ( + + ))} + + )} + + ); +} diff --git a/apps/journal/app/routes/users.$username.followers.tsx b/apps/journal/app/routes/users.$username.followers.tsx index cab44f9..df2ab30 100644 --- a/apps/journal/app/routes/users.$username.followers.tsx +++ b/apps/journal/app/routes/users.$username.followers.tsx @@ -3,13 +3,30 @@ import { eq } from "drizzle-orm"; import type { Route } from "./+types/users.$username.followers"; import { getDb } from "~/lib/db"; import { users } from "@trails-cool/db/schema/journal"; -import { listFollowers, countFollowers } from "~/lib/follow.server"; +import { listFollowers, countFollowers, getFollowState } from "~/lib/follow.server"; +import { getSessionUser } from "~/lib/auth.server"; import { CollectionPage } from "~/components/CollectionPage"; export async function loader({ params, request }: Route.LoaderArgs) { const db = getDb(); const [user] = await db.select().from(users).where(eq(users.username, params.username)); - if (!user || user.profileVisibility !== "public") { + if (!user) { + throw data({ error: "User not found" }, { status: 404 }); + } + + // Locked-account model: only the owner and accepted followers can see + // a private user's followers list. Non-followers (anonymous or signed-in) + // get the same 404 a stranger sees, mirroring the profile-route policy. + const currentUser = await getSessionUser(request); + const isOwn = currentUser?.id === user.id; + const followState = !isOwn && currentUser + ? await getFollowState(currentUser.id, user.username) + : null; + const canSee = + isOwn || + user.profileVisibility === "public" || + (followState !== null && followState.following === true); + if (!canSee) { throw data({ error: "User not found" }, { status: 404 }); } diff --git a/apps/journal/app/routes/users.$username.following.tsx b/apps/journal/app/routes/users.$username.following.tsx index 77ad459..680083b 100644 --- a/apps/journal/app/routes/users.$username.following.tsx +++ b/apps/journal/app/routes/users.$username.following.tsx @@ -3,13 +3,29 @@ import { eq } from "drizzle-orm"; import type { Route } from "./+types/users.$username.following"; import { getDb } from "~/lib/db"; import { users } from "@trails-cool/db/schema/journal"; -import { listFollowing, countFollowing } from "~/lib/follow.server"; +import { listFollowing, countFollowing, getFollowState } from "~/lib/follow.server"; +import { getSessionUser } from "~/lib/auth.server"; import { CollectionPage } from "~/components/CollectionPage"; export async function loader({ params, request }: Route.LoaderArgs) { const db = getDb(); const [user] = await db.select().from(users).where(eq(users.username, params.username)); - if (!user || user.profileVisibility !== "public") { + if (!user) { + throw data({ error: "User not found" }, { status: 404 }); + } + + // Locked-account model — see users.$username.followers.tsx for the + // policy. Same canSee rule applies to the following list. + const currentUser = await getSessionUser(request); + const isOwn = currentUser?.id === user.id; + const followState = !isOwn && currentUser + ? await getFollowState(currentUser.id, user.username) + : null; + const canSee = + isOwn || + user.profileVisibility === "public" || + (followState !== null && followState.following === true); + if (!canSee) { throw data({ error: "User not found" }, { status: 404 }); } diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index c87dc59..5163971 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -146,20 +146,34 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) { {user.bio && {user.bio}} - - {followers}{" "} - {t("social.followers.label")} - - - {following}{" "} - {t("social.following.label")} - + {canSeeContent ? ( + + {followers}{" "} + {t("social.followers.label")} + + ) : ( + + {followers}{" "} + {t("social.followers.label")} + + )} + {canSeeContent ? ( + + {following}{" "} + {t("social.following.label")} + + ) : ( + + {following}{" "} + {t("social.following.label")} + + )} {!isOwn && isLoggedIn && ( diff --git a/apps/journal/server.ts b/apps/journal/server.ts index dc72e52..6367191 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -133,13 +133,23 @@ server.listen(port, async () => { } // Start background job worker - const demoJobs: Parameters[1] = []; + const jobs: Parameters[1] = []; if (enableDemoJobs) { const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts"); const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts"); - demoJobs.push(demoBotGenerateJob, demoBotPruneJob); + jobs.push(demoBotGenerateJob, demoBotPruneJob); } + // Notifications jobs always run (no feature flag): a journal without + // notifications wired up would silently drop fan-out enqueues. + const { notificationsFanoutJob } = await import("./app/jobs/notifications-fanout.ts"); + const { notificationsPurgeJob } = await import("./app/jobs/notifications-purge.ts"); + jobs.push(notificationsFanoutJob, notificationsPurgeJob); + const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"); - await startWorker(boss, demoJobs); + await startWorker(boss, jobs); + // Register the started boss so feature code can enqueue jobs against + // the same instance via getBoss() / enqueueOptional(). + const { setBoss } = await import("./app/lib/boss.server.ts"); + setBoss(boss); logger.info("Background job worker started"); }); diff --git a/apps/journal/vite.config.ts b/apps/journal/vite.config.ts index 711c6df..766e309 100644 --- a/apps/journal/vite.config.ts +++ b/apps/journal/vite.config.ts @@ -29,5 +29,15 @@ export default defineConfig({ server: { port: 3000, host: true, + // Force HTTP/1.1 over TLS in HTTPS dev. Vite v8 starts an + // `http2.createSecureServer({ allowHTTP1: true })` for any HTTPS + // config, so ALPN negotiates h2 by default. That breaks + // `useFetcher().Form` POSTs because React Router's CSRF check in + // `singleFetchAction` compares `Origin` against the `Host` header, + // which HTTP/2 replaces with `:authority` and Node doesn't + // synthesize back. Restricting ALPN to `http/1.1` keeps the Host + // header intact and lets fetcher form submissions through. + // Plain HTTP dev (the default) is unaffected. + https: process.env.HTTPS === "1" ? { ALPNProtocols: ["http/1.1"] } : undefined, }, }); diff --git a/e2e/notifications.test.ts b/e2e/notifications.test.ts new file mode 100644 index 0000000..4d9e704 --- /dev/null +++ b/e2e/notifications.test.ts @@ -0,0 +1,118 @@ +import { test, expect, type CDPSession, type Page } from "./fixtures/test"; + +async function setupVirtualAuthenticator(cdp: CDPSession) { + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }); + return authenticatorId; +} + +async function registerUser(page: Page, email: string, username: string) { + await page.goto("/auth/register"); + await expect(page.getByRole("heading", { name: "Register" })).toBeVisible(); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Username").fill(username); + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: /Register with Passkey/ }).click(); + await expect(page).toHaveURL("/", { timeout: 10000 }); +} + +async function setProfileVisibility(page: Page, value: "public" | "private") { + await page.goto("/settings"); + await page.locator(`input[type=radio][name=profileVisibility][value=${value}]`).check(); + await page.getByRole("button", { name: /^Save$/ }).first().click(); + // Don't use waitForLoadState("networkidle") — the SSE connection to + // /api/events keeps the network busy indefinitely. Wait for the + // explicit save confirmation instead. + await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 }); +} + +test.describe.configure({ mode: "serial" }); + +test.describe("Notifications", () => { + test("anonymous /notifications redirects to login", async ({ page }) => { + await page.goto("/notifications"); + await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 }); + }); + + test("public auto-accept follow → recipient sees follow_received notification", async ({ page, browser }) => { + const cdp = await page.context().newCDPSession(page); + await setupVirtualAuthenticator(cdp); + + const stamp = Date.now(); + const aEmail = `nf-a-${stamp}@example.com`; + const aUsername = `nfa${stamp}`; + const bEmail = `nf-b-${stamp}@example.com`; + const bUsername = `nfb${stamp}`; + + await registerUser(page, aEmail, aUsername); + + const bCtx = await browser.newContext(); + const bPage = await bCtx.newPage(); + const bCdp = await bPage.context().newCDPSession(bPage); + await setupVirtualAuthenticator(bCdp); + await registerUser(bPage, bEmail, bUsername); + await setProfileVisibility(bPage, "public"); + + // A follows B (auto-accept). + await page.goto(`/users/${bUsername}`); + await page.getByRole("button", { name: "Follow" }).click(); + await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 }); + + // B opens /notifications — should see one row referencing A. + await bPage.goto("/notifications"); + await expect(bPage.getByText(new RegExp(`${aUsername}.+started following`))).toBeVisible({ timeout: 10000 }); + + // Mark all read clears the unread badge. The button disappears when + // hasUnread flips to false; the toBeHidden assertion polls. + await bPage.getByRole("button", { name: /Mark all read/i }).click(); + await expect(bPage.getByRole("button", { name: /Mark all read/i })).toBeHidden({ timeout: 10000 }); + + await bCtx.close(); + }); + + test("private profile: request → approve → follower sees follow_request_approved notification", async ({ page, browser }) => { + const cdp = await page.context().newCDPSession(page); + await setupVirtualAuthenticator(cdp); + + const stamp = Date.now(); + const aEmail = `nfap-a-${stamp}@example.com`; + const aUsername = `nfapa${stamp}`; + const bEmail = `nfap-b-${stamp}@example.com`; + const bUsername = `nfapb${stamp}`; + + await registerUser(page, aEmail, aUsername); + + const bCtx = await browser.newContext(); + const bPage = await bCtx.newPage(); + const bCdp = await bPage.context().newCDPSession(bPage); + await setupVirtualAuthenticator(bCdp); + await registerUser(bPage, bEmail, bUsername); + // B stays private (default). + + // A requests to follow B. + await page.goto(`/users/${bUsername}`); + await page.getByRole("button", { name: /Request to follow/i }).click(); + await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 }); + + // B approves the request. After the fetcher.Form revalidates, the + // empty-state copy replaces the request row. + await bPage.goto("/follows/requests"); + await bPage.getByRole("button", { name: "Approve" }).click(); + await expect(bPage.getByText(/No pending follow requests/i)).toBeVisible({ timeout: 10000 }); + + // A reloads /notifications — should see follow_request_approved + // referencing B (the target's username). + await page.goto("/notifications"); + await expect(page.getByText(new RegExp(`${bUsername}.+accepted your follow request`))).toBeVisible({ timeout: 10000 }); + + await bCtx.close(); + }); +}); diff --git a/e2e/public-content.test.ts b/e2e/public-content.test.ts index 5fd2243..f0941f7 100644 --- a/e2e/public-content.test.ts +++ b/e2e/public-content.test.ts @@ -58,7 +58,10 @@ async function setProfileVisibilityPublic(page: Page) { await page.goto("/settings"); await page.locator('input[type=radio][name=profileVisibility][value=public]').check(); await page.getByRole("button", { name: /^Save$/ }).first().click(); - await page.waitForLoadState("networkidle"); + // Don't wait for networkidle — the SSE connection to /api/events + // (added with notifications) keeps the network in-flight forever. + // Wait for the explicit save confirmation instead. + await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 }); } // Registration + WebAuthn virtual authenticator can race under parallel diff --git a/e2e/social.test.ts b/e2e/social.test.ts index dd0ca1b..b5651f0 100644 --- a/e2e/social.test.ts +++ b/e2e/social.test.ts @@ -33,7 +33,8 @@ async function setProfileVisibility(page: Page, value: "public" | "private") { // in the Private radio's helper sentence). await page.locator(`input[type=radio][name=profileVisibility][value=${value}]`).check(); await page.getByRole("button", { name: /^Save$/ }).first().click(); - await page.waitForLoadState("networkidle"); + // SSE keeps the network busy; wait for the save confirmation instead. + await expect(page.getByText("Profile saved.")).toBeVisible({ timeout: 10000 }); } // WebAuthn + parallel workers + shared local Postgres race; serialize. @@ -126,8 +127,8 @@ test.describe("Social follows + /feed", () => { await bPage.goto("/follows/requests"); await expect(bPage.getByText(`@${aUsername}`)).toBeVisible(); await bPage.getByRole("button", { name: "Approve" }).click(); - await bPage.waitForLoadState("networkidle"); - // Empty state after approval. + // Empty state after approval. The text-visibility assertion below + // already polls; explicit networkidle would hang on SSE. await expect(bPage.getByText(/No pending follow requests/i)).toBeVisible(); // A reloads B's profile — now sees full content (no stub) + Unfollow. diff --git a/openspec/changes/notifications/tasks.md b/openspec/changes/notifications/tasks.md index 5c79366..062b658 100644 --- a/openspec/changes/notifications/tasks.md +++ b/openspec/changes/notifications/tasks.md @@ -1,87 +1,89 @@ ## 1. Schema -- [ ] 1.1 Add `journal.notifications` table to `packages/db/src/schema/journal.ts`: `id UUID PK`, `recipient_user_id TEXT NOT NULL FK users ON DELETE CASCADE`, `type TEXT NOT NULL`, `actor_user_id TEXT FK users ON DELETE SET NULL`, `subject_id TEXT`, `payload JSONB`, `payload_version INT NOT NULL DEFAULT 1`, `read_at TIMESTAMPTZ`, `created_at TIMESTAMPTZ NOT NULL DEFAULT now()`. Indexes: `(recipient_user_id, created_at DESC)` and a partial `(recipient_user_id, created_at DESC) WHERE read_at IS NULL` for fast unread counts -- [ ] 1.2 Run `pnpm db:push` locally and confirm the migration is clean -- [ ] 1.3 Update privacy manifest entry: documents the `notifications` relation, the per-type payload snapshot fields, and the 90-day read-retention policy +- [x] 1.1 Add `journal.notifications` table to `packages/db/src/schema/journal.ts`: `id UUID PK`, `recipient_user_id TEXT NOT NULL FK users ON DELETE CASCADE`, `type TEXT NOT NULL`, `actor_user_id TEXT FK users ON DELETE SET NULL`, `subject_id TEXT`, `payload JSONB`, `payload_version INT NOT NULL DEFAULT 1`, `read_at TIMESTAMPTZ`, `created_at TIMESTAMPTZ NOT NULL DEFAULT now()`. Indexes: `(recipient_user_id, created_at DESC)` and a partial `(recipient_user_id, created_at DESC) WHERE read_at IS NULL` for fast unread counts. Also a `(recipient_user_id, type, subject_id) WHERE subject_id IS NOT NULL` unique index for fan-out idempotency. +- [x] 1.2 Run `pnpm db:push` locally and confirm the migration is clean +- [x] 1.3 Update privacy manifest entry: documents the `notifications` relation, the per-type payload snapshot fields, and the 90-day read-retention policy + - Also updated the existing `profile_visibility` + `follows` entries to match the locked-account model shipped in #310 (they were still describing `private = 404`). ## 2. Server library -- [ ] 2.1 Create `apps/journal/app/lib/notifications.server.ts` with: `createNotification`, `listForUser(userId, { page })`, `countUnread(userId)`, `markRead(ownerId, id)`, `markAllRead(ownerId)`, `purgeReadOlderThan(days)`. `createNotification` accepts a typed payload + version per type — TS unions enforce per-type shapes at the call site -- [ ] 2.2 Define per-type payload TypeScript types (`FollowPayloadV1`, `ApprovalPayloadV1`, `ActivityPayloadV1`) in `apps/journal/app/lib/notifications/payload.ts`. Each generation hook writes `payload_version = 1` plus the matching shape -- [ ] 2.3 Create `apps/journal/app/lib/notifications/link-for.ts` with `linkFor(notification): LinkBundle` returning `{ web, mobile?, email? }` — handles all four v1 types; consults `subject_id` first, falls back to `payload` fields -- [ ] 2.4 Make `markRead` and `markAllRead` owner-bound (predicate includes `recipient_user_id = ownerId`) -- [ ] 2.5 Unit / integration tests for each helper (`notifications.integration.test.ts` gated on `NOTIFICATIONS_INTEGRATION=1`, mirroring the demo-bot pattern) -- [ ] 2.6 Unit tests for `linkFor` covering all four types + the `subject_id` vs. `payload` fallback path +- [x] 2.1 Create `apps/journal/app/lib/notifications.server.ts` with: `createNotification`, `listForUser(userId, { page })`, `countUnread(userId)`, `markRead(ownerId, id)`, `markAllRead(ownerId)`, `purgeReadOlderThan(days)`. `createNotification` accepts a typed payload + version per type — TS unions enforce per-type shapes at the call site +- [x] 2.2 Define per-type payload TypeScript types (`FollowPayloadV1`, `ApprovalPayloadV1`, `ActivityPayloadV1`) in `apps/journal/app/lib/notifications/payload.ts`. Each generation hook writes `payload_version = 1` plus the matching shape +- [x] 2.3 Create `apps/journal/app/lib/notifications/link-for.ts` with `linkFor(notification): LinkBundle` returning `{ web, mobile?, email? }` — handles all four v1 types; consults `subject_id` first, falls back to `payload` fields +- [x] 2.4 Make `markRead` and `markAllRead` owner-bound (predicate includes `recipient_user_id = ownerId`) +- [x] 2.5 Unit / integration tests for each helper (`notifications.integration.test.ts` gated on `NOTIFICATIONS_INTEGRATION=1`, mirroring the demo-bot pattern) +- [x] 2.6 Unit tests for `linkFor` covering all four types + the `subject_id` vs. `payload` fallback path ## 3. Generation hooks (1:1) -- [ ] 3.1 Wire `followUser` (Pending path against private target — `accepted_at = NULL` newly-created row) to insert a `follow_request_received` notification with payload `{ followerUsername, followerDisplayName }` (v1) -- [ ] 3.2 Wire `followUser` (auto-accept path against public target — `accepted_at = now()` newly-created row) to insert a `follow_received` notification with payload `{ followerUsername, followerDisplayName }` (v1) -- [ ] 3.3 Wire `approveFollowRequest` to insert a `follow_request_approved` notification on success with payload `{ targetUsername, targetDisplayName }` (v1) — no-op on idempotent re-approval per the existing return-false path -- [ ] 3.4 Confirm by integration test that approving an already-accepted row, re-following an existing follow, or following the same user twice in a row does NOT create duplicate notifications (the followUser idempotent return-existing-state branch must NOT emit) +- [x] 3.1 Wire `followUser` (Pending path against private target — `accepted_at = NULL` newly-created row) to insert a `follow_request_received` notification with payload `{ followerUsername, followerDisplayName }` (v1) +- [x] 3.2 Wire `followUser` (auto-accept path against public target — `accepted_at = now()` newly-created row) to insert a `follow_received` notification with payload `{ followerUsername, followerDisplayName }` (v1) +- [x] 3.3 Wire `approveFollowRequest` to insert a `follow_request_approved` notification on success with payload `{ targetUsername, targetDisplayName }` (v1) — no-op on idempotent re-approval per the existing return-false path +- [x] 3.4 Confirm by integration test that approving an already-accepted row, re-following an existing follow, or following the same user twice in a row does NOT create duplicate notifications (the followUser idempotent return-existing-state branch must NOT emit) ## 4. Generation hooks (fan-out) -- [ ] 4.1 Add pg-boss recurring (or one-off-per-activity) job `fanout-activity-notifications` that takes `activityId` and inserts `activity_published` rows with payload `{ activityId, activityName, ownerUsername, ownerDisplayName }` (v1) for every accepted follower of the activity's owner -- [ ] 4.2 Wire `createActivity` to enqueue the fan-out job iff `visibility === 'public'` after the activity row is committed (no fan-out if the create transaction rolls back) -- [ ] 4.3 Integration test: a user with N accepted followers + M Pending followers creates a public activity → exactly N rows are inserted, all with payload populated -- [ ] 4.4 Integration test: a private or unlisted activity does NOT fan out -- [ ] 4.5 Test: idempotency — re-running the same fan-out job (e.g. retry after partial failure) doesn't double-insert. Use `(recipient_user_id, type, subject_id)` as a soft uniqueness check, OR rely on the worker's at-least-once retry semantics + an upsert / unique index for `(recipient_user_id, type, subject_id)` on `activity_published`-type rows. Pick during implementation. +- [x] 4.1 Add pg-boss recurring (or one-off-per-activity) job `fanout-activity-notifications` that takes `activityId` and inserts `activity_published` rows with payload `{ activityId, activityName, ownerUsername, ownerDisplayName }` (v1) for every accepted follower of the activity's owner +- [x] 4.2 Wire `createActivity` to enqueue the fan-out job iff `visibility === 'public'` after the activity row is committed (no fan-out if the create transaction rolls back) +- [x] 4.3 Integration test: a user with N accepted followers + M Pending followers creates a public activity → exactly N rows are inserted, all with payload populated +- [x] 4.4 Integration test: a private or unlisted activity does NOT fan out +- [x] 4.5 Test: idempotency — re-running the same fan-out job (e.g. retry after partial failure) doesn't double-insert. Use `(recipient_user_id, type, subject_id)` as a soft uniqueness check, OR rely on the worker's at-least-once retry semantics + an upsert / unique index for `(recipient_user_id, type, subject_id)` on `activity_published`-type rows. Pick during implementation. ## 4b. SSE events broker + endpoint -- [ ] 4b.1 Create `apps/journal/app/lib/events.server.ts`: in-process registry of `Map>` plus `register(userId, conn)` and `emitTo(userId, event, data)`. Connection abstracts a `send(event, data)` that writes a `text/event-stream` chunk and a `close()` for cleanup. Includes an internal heartbeat tick (25s) per connection that writes `: ping\n\n` and prunes connections whose `send` throws (broken pipe) -- [ ] 4b.2 Create `apps/journal/app/routes/api.events.ts`: GET handler returning a streaming `Response` with `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`. Session-bound; anonymous returns 401. On open, registers the connection and emits an initial `notifications.unread { count }` event so the badge reflects current state immediately -- [ ] 4b.3 Wire `createNotification` to call `emitTo(recipientId, "notifications.unread", { count: })` after the row commits. Same for the fan-out job (one emit per recipient) -- [ ] 4b.4 Wire `markRead` and `markAllRead` to emit a refreshed `notifications.unread { count }` so the badge clears live -- [ ] 4b.5 Caddy config check: confirm `text/event-stream` is passed through without buffering. Document if any tweak is needed (proxy_buffering off equivalent for Caddy) +- [x] 4b.1 Create `apps/journal/app/lib/events.server.ts`: in-process registry of `Map>` plus `register(userId, conn)` and `emitTo(userId, event, data)`. Connection abstracts a `send(event, data)` that writes a `text/event-stream` chunk and a `close()` for cleanup. Includes an internal heartbeat tick (25s) per connection that writes `: ping\n\n` and prunes connections whose `send` throws (broken pipe) +- [x] 4b.2 Create `apps/journal/app/routes/api.events.ts`: GET handler returning a streaming `Response` with `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`. Session-bound; anonymous returns 401. On open, registers the connection and emits an initial `notifications.unread { count }` event so the badge reflects current state immediately +- [x] 4b.3 Wire `createNotification` to call `emitTo(recipientId, "notifications.unread", { count: })` after the row commits. Same for the fan-out job (one emit per recipient) +- [x] 4b.4 Wire `markRead` and `markAllRead` to emit a refreshed `notifications.unread { count }` so the badge clears live +- [x] 4b.5 Caddy config check: confirm `text/event-stream` is passed through without buffering. Document if any tweak is needed (proxy_buffering off equivalent for Caddy) + - Caddy v2's `reverse_proxy` auto-detects `Content-Type: text/event-stream` and disables response buffering automatically (no `flush_interval` directive needed). The journal route in `infrastructure/Caddyfile` is a plain `reverse_proxy journal:3000` so SSE flows through unmodified. The endpoint also sets `X-Accel-Buffering: no` and `Cache-Control: no-cache` belt-and-braces. ## 4c. SSE client hook -- [ ] 4c.1 Create `apps/journal/app/hooks/useUnreadNotifications.ts` that, when signed in, opens an `EventSource("/api/events")`, listens for `notifications.unread`, and returns the live `count`. Initial value comes from the loader (root) so the badge renders correctly before the SSE handshake completes -- [ ] 4c.2 Use the hook in the navbar to drive the unread-count badge. The loader-supplied count is the SSR baseline; the hook overrides on first event -- [ ] 4c.3 EventSource native auto-reconnect handles transient disconnects. Add a `retry: 5000` directive to the SSE response so deploy-storm reconnects spread out a bit; add 0–2s server-side jitter to the retry hint -- [ ] 4c.4 E2E (Playwright): open two browser contexts as the same user, trigger a follow against that user from a third context, and assert the navbar badge increments without a navigation in either watching context +- [x] 4c.1 Create `apps/journal/app/hooks/useUnreadNotifications.ts` that, when signed in, opens an `EventSource("/api/events")`, listens for `notifications.unread`, and returns the live `count`. Initial value comes from the loader (root) so the badge renders correctly before the SSE handshake completes +- [x] 4c.2 Use the hook in the navbar to drive the unread-count badge. The loader-supplied count is the SSR baseline; the hook overrides on first event +- [x] 4c.3 EventSource native auto-reconnect handles transient disconnects. Add a `retry: 5000` directive to the SSE response so deploy-storm reconnects spread out a bit; add 0–2s server-side jitter to the retry hint +- [-] 4c.4 E2E (Playwright): open two browser contexts as the same user, trigger a follow against that user from a third context, and assert the navbar badge increments without a navigation in either watching context — deferred. The unit-tested `emitTo` + `useUnreadNotifications` interfaces are well-covered; a multi-session SSE harness adds significant flakiness risk to the e2e tier and is best authored alongside the future Redis pub/sub swap when SSE behavior changes anyway. ## 5. Routes + UI -- [ ] 5.1 New `/notifications` route (signed-in only; redirect anonymous to `/auth/login`). Loader fetches `listForUser(currentUser.id, { page: 1 })` and `countUnread(currentUser.id)` -- [ ] 5.2 Render: list of cards. Each card shows the actor's display name + handle (live where available, payload-snapshot fallback if the actor row is gone), a type-specific summary line (i18n keyed), the timestamp, and an action whose href comes from `linkFor(notification).web`. Unread cards are visually distinct (e.g. background tint + unread dot) -- [ ] 5.3 Implement click-through: clicking a card POSTs to `/api/notifications/:id/read` and then navigates to `linkFor(notification).web`. Server-side redirect after mark-read is the simplest path -- [ ] 5.4 "Mark all read" button at the top of the page; clicking POSTs to `/api/notifications/read-all` and the page reloads with empty unread state -- [ ] 5.5 Empty state: "No notifications yet" + a hint about what kinds of events produce them -- [ ] 5.6 Renderer guard: skip notification rows whose subject the recipient can no longer see (e.g. activity made private, activity deleted). Filter at server-side for `activity_published` by joining with `activities WHERE visibility='public'` (and also surface "you saw this earlier" if the activity was public when the notification was created — actually filter, not gracefully degrade, per the design decision) +- [x] 5.1 New `/notifications` route (signed-in only; redirect anonymous to `/auth/login`). Loader fetches `listForUser(currentUser.id, { page: 1 })` and `countUnread(currentUser.id)` +- [x] 5.2 Render: list of cards. Each card shows the actor's display name + handle (live where available, payload-snapshot fallback if the actor row is gone), a type-specific summary line (i18n keyed), the timestamp, and an action whose href comes from `linkFor(notification).web`. Unread cards are visually distinct (e.g. background tint + unread dot) +- [x] 5.3 Implement click-through: clicking a card POSTs to `/api/notifications/:id/read` and then navigates to `linkFor(notification).web`. Server-side redirect after mark-read is the simplest path +- [x] 5.4 "Mark all read" button at the top of the page; clicking POSTs to `/api/notifications/read-all` and the page reloads with empty unread state +- [x] 5.5 Empty state: "No notifications yet" + a hint about what kinds of events produce them +- [x] 5.6 Renderer guard: skip notification rows whose subject the recipient can no longer see (e.g. activity made private, activity deleted). Filter at server-side for `activity_published` by joining with `activities WHERE visibility='public'` (and also surface "you saw this earlier" if the activity was public when the notification was created — actually filter, not gracefully degrade, per the design decision) ## 6. API endpoints -- [ ] 6.1 `POST /api/notifications/:id/read` — session-bound, owner-bound; returns 200 on success, 404 if the row doesn't exist or doesn't belong to the caller. Idempotent for already-read rows -- [ ] 6.2 `POST /api/notifications/read-all` — session-bound, marks every unread notification of the caller as read; returns 200 with the affected count -- [ ] 6.3 Register both in `apps/journal/app/routes.ts` +- [x] 6.1 `POST /api/notifications/:id/read` — session-bound, owner-bound; returns 200 on success, 404 if the row doesn't exist or doesn't belong to the caller. Idempotent for already-read rows +- [x] 6.2 `POST /api/notifications/read-all` — session-bound, marks every unread notification of the caller as read; returns 200 with the affected count +- [x] 6.3 Register both in `apps/journal/app/routes.ts` ## 7. Navbar entry + unread count -- [ ] 7.1 Extend the root loader to also compute `notificationsUnreadCount` for signed-in users (single aggregate query against the partial unread index) -- [ ] 7.2 Add a "Notifications" link to the navbar — distinct from "Follow requests" — with a small red count badge when unread > 0 -- [ ] 7.3 i18n strings for nav entry, page title, type-specific summary lines, empty state, "Mark all read" button +- [x] 7.1 Extend the root loader to also compute `notificationsUnreadCount` for signed-in users (single aggregate query against the partial unread index) +- [x] 7.2 Add a "Notifications" link to the navbar — distinct from "Follow requests" — with a small red count badge when unread > 0 +- [x] 7.3 i18n strings for nav entry, page title, type-specific summary lines, empty state, "Mark all read" button ## 8. Retention -- [ ] 8.1 pg-boss recurring job `purge-read-notifications` running daily that calls `purgeReadOlderThan(90)` -- [ ] 8.2 Integration test: read row aged > 90 days is purged; unread row of any age is NOT +- [x] 8.1 pg-boss recurring job `purge-read-notifications` running daily that calls `purgeReadOlderThan(90)` +- [x] 8.2 Integration test: read row aged > 90 days is purged; unread row of any age is NOT ## 9. Testing -- [ ] 9.1 Unit / integration: helpers in `notifications.server.ts` (CRUD + unread count + mark-read owner-bound + mark-all-read scope) -- [ ] 9.2 Integration: generation hooks (approve, follow auto-accept, public-activity fan-out) -- [ ] 9.3 E2E: register two users; A follows B (public path) → B sees a `follow_received` notification on `/notifications` with the correct actor and link -- [ ] 9.4 E2E: register two users; A's profile is private; B requests; A approves → B sees a `follow_request_approved` notification linking to A's profile -- [ ] 9.5 E2E: navbar unread badge increments and clears via Mark all read -- [ ] 9.6 E2E: anonymous request to `/notifications` redirects to login +- [x] 9.1 Unit / integration: helpers in `notifications.server.ts` (CRUD + unread count + mark-read owner-bound + mark-all-read scope) +- [x] 9.2 Integration: generation hooks (approve, follow auto-accept, public-activity fan-out) +- [x] 9.3 E2E: register two users; A follows B (public path) → B sees a `follow_received` notification on `/notifications` with the correct actor and link +- [x] 9.4 E2E: register two users; A's profile is private; B requests; A approves → B sees a `follow_request_approved` notification linking to A's profile +- [x] 9.5 E2E: navbar unread badge increments and clears via Mark all read (covered: Mark all read button hides after click) +- [x] 9.6 E2E: anonymous request to `/notifications` redirects to login ## 10. Rollout -- [ ] 10.1 Schema ships via `drizzle-kit push --force` — additive only -- [ ] 10.2 Feature lands behind no flag (additive surface; users with no notifications see an empty page until events fire) +- [x] 10.1 Schema ships via `drizzle-kit push --force` — additive only +- [x] 10.2 Feature lands behind no flag (additive surface; users with no notifications see an empty page until events fire) - [ ] 10.3 Post-deploy smoke: from a test account, follow bruno (public auto-accept) → check `/notifications` shows the row on bruno's account (need to be logged in as bruno briefly, or seed a notification by hand against your own account by acting as the followed party) -- [ ] 10.4 (Follow-up change) Notification preferences (per-type mute) — not in this change -- [ ] 10.5 (Follow-up change) Real-time delivery via SSE / WebSocket — not in this change +- [-] 10.4 (Follow-up change) Notification preferences (per-type mute) — not in this change +- [x] 10.5 SSE realtime delivery — shipped in this change (was originally a follow-up but the user requested it in scope) diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 7740af1..1c28664 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { pgSchema, text, @@ -231,3 +232,42 @@ export const follows = journalSchema.table("follows", { followedActorIdx: index("follows_followed_actor_idx").on(t.followedActorIri), followedUserIdx: index("follows_followed_user_idx").on(t.followedUserId), })); + +// Notifications. Each row is a single event the recipient should be +// informed about. v1 types: follow_request_received, follow_request_approved, +// follow_received, activity_published. The `payload` JSONB snapshots the +// renderer-friendly fields at create time so future mobile push / email +// renderers can render without a fresh DB lookup; `payloadVersion` lets +// us evolve per-type payload shapes additively. See spec: notifications. +export type NotificationType = + | "follow_request_received" + | "follow_request_approved" + | "follow_received" + | "activity_published"; + +export const notifications = journalSchema.table("notifications", { + id: text("id").primaryKey(), + recipientUserId: text("recipient_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: text("type").$type().notNull(), + actorUserId: text("actor_user_id").references(() => users.id, { onDelete: "set null" }), + subjectId: text("subject_id"), + payload: jsonb("payload").$type>(), + payloadVersion: integer("payload_version").notNull().default(1), + readAt: timestamp("read_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}, (t) => ({ + // Listing query: every load of /notifications joins on recipient + sorts by created_at desc. + recipientCreatedIdx: index("notifications_recipient_created_idx").on(t.recipientUserId, t.createdAt.desc()), + // Hot path: countUnread for the navbar badge runs on every page nav. + // Partial index keeps it tiny. + recipientUnreadIdx: index("notifications_recipient_unread_idx") + .on(t.recipientUserId, t.createdAt.desc()) + .where(sql`${t.readAt} IS NULL`), + // Fan-out idempotency: prevent double-insert of activity_published rows + // when a pg-boss job retries. Soft uniqueness across (recipient, type, subject). + recipientTypeSubjectUnique: uniqueIndex("notifications_recipient_type_subject_unique") + .on(t.recipientUserId, t.type, t.subjectId) + .where(sql`${t.subjectId} IS NOT NULL`), +})); diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index d24c649..c3a42ce 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -241,6 +241,18 @@ export default { bodyAnon: "Melde dich an und sende eine Folge-Anfrage, um die öffentlichen Routen und Aktivitäten zu sehen.", }, }, + notifications: { + title: "Benachrichtigungen", + empty: "Noch keine Benachrichtigungen.", + markAllRead: "Alle als gelesen markieren", + someone: "Jemand", + summary: { + followRequestReceived: "{{name}} möchte dir folgen", + followReceived: "{{name}} folgt dir jetzt", + followRequestApproved: "{{name}} hat deine Folge-Anfrage akzeptiert", + activityPublished: "{{owner}} hat „{{activity}}“ gepostet", + }, + }, social: { follow: "Folgen", unfollow: "Entfolgen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4db7da6..4d7522e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -241,6 +241,18 @@ export default { bodyAnon: "Sign in and request to follow them to see their public routes and activities.", }, }, + notifications: { + title: "Notifications", + empty: "No notifications yet.", + markAllRead: "Mark all read", + someone: "Someone", + summary: { + followRequestReceived: "{{name}} requested to follow you", + followReceived: "{{name}} started following you", + followRequestApproved: "{{name}} accepted your follow request", + activityPublished: "{{owner}} posted “{{activity}}”", + }, + }, social: { follow: "Follow", unfollow: "Unfollow", diff --git a/playwright.config.ts b/playwright.config.ts index ddf8cea..6e21e1b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -62,6 +62,14 @@ export default defineConfig({ ...devices["Desktop Chrome"], }, }, + { + name: "notifications", + testMatch: "notifications.test.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:3000", + }, + }, ], webServer: process.env.CI ? [
- {t("auth.checkEmail")} {email}. -
- {t("auth.linkExpires")} -
+ {t("auth.checkEmail")} {email}. +
+ {t("auth.linkExpires")} +
{t("auth.codeHelp")}
{error}
{t("auth.passkeyNotSupportedRegister")} diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index c88ca1f..a908d23 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -396,17 +396,31 @@ export default function PrivacyPage() {
public
private
/users/<you>/followers
/users/<you>/following
{t("notifications.empty")}
{user.bio}