Merge pull request #313 from trails-cool/feat/notifications

Implement notifications + supporting fixes
This commit is contained in:
Ullrich Schäfer 2026-04-26 01:41:49 +02:00 committed by GitHub
commit 2892cf9360
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1915 additions and 113 deletions

View file

@ -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 (
<time dateTime={iso}>{new Date(iso).toLocaleDateString(locale)}</time>
);
const d = new Date(iso);
const text = withTime
? d.toLocaleString(locale, { dateStyle: "short", timeStyle: "short" })
: d.toLocaleDateString(locale);
return <time dateTime={iso}>{text}</time>;
}

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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<void> {
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",
);
}

View file

@ -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 };
},
};

View file

@ -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;
}

View file

@ -154,7 +154,7 @@ export async function registerWithMagicLink(
email: string,
username: string,
termsVersion: string,
): Promise<string> {
): 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 ---

View file

@ -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<string | null>;
}
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<string, unknown> = {},
): Promise<void> {
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");
}
}

View file

@ -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<string /* userId */, Set<Connection>>();
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;
}

View file

@ -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;
}
/**

View file

@ -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);
});
});

View file

@ -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<void> {
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<boolean> {
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<string, unknown>,
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<string, unknown> | null;
payloadVersion: number;
readAt: Date | null;
createdAt: Date;
}
const PAGE_SIZE = 50;
export async function listForUser(
userId: string,
opts: { page?: number } = {},
): Promise<NotificationRow[]> {
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<number> {
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<boolean> {
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<number> {
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<number> {
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;
}

View file

@ -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("/");
});
});

View file

@ -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<string, unknown> | 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}`,
};
}

View file

@ -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<string, unknown> | 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,
};
}
}

View file

@ -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({
<div className="flex items-center gap-4">
{user ? (
<>
<Link
to="/notifications"
className={`relative inline-flex items-center ${linkClass("/notifications")}`}
aria-label={t("notifications.title")}
title={t("notifications.title")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.75}
stroke="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"
/>
</svg>
{liveUnread > 0 && (
<span className="absolute -right-1.5 -top-1.5 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold leading-none text-white">
{liveUnread}
</span>
)}
</Link>
<Link
to="/follows/requests"
className={`relative ${linkClass("/follows/requests")}`}
@ -176,6 +215,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
const user = loaderData?.user;
const locale = loaderData?.locale ?? "en";
const pendingFollowRequests = loaderData?.pendingFollowRequests ?? 0;
const unreadNotifications = loaderData?.unreadNotifications ?? 0;
useEffect(() => {
if (user) {
initSentryClient();
@ -189,7 +229,11 @@ export default function App({ loaderData }: Route.ComponentProps) {
return (
<LocaleProvider locale={locale}>
<AlphaBanner />
<NavBar user={user ?? null} pendingFollowRequests={pendingFollowRequests} />
<NavBar
user={user ?? null}
pendingFollowRequests={pendingFollowRequests}
unreadNotifications={unreadNotifications}
/>
<Outlet />
<Footer />
</LocaleProvider>

View file

@ -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"),

View file

@ -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"),
);

View file

@ -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<Uint8Array>({
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",
},
});
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -10,13 +10,17 @@ export default function RegisterPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(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 (
<div className="mx-auto max-w-md px-4 py-16">
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
<div className="mt-8 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
{t("auth.checkEmail")} <strong>{email}</strong>.
</p>
<p className="mt-2 text-xs text-green-600">
{t("auth.linkExpires")}
</p>
<div className="mt-8 space-y-4">
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
{t("auth.checkEmail")} <strong>{email}</strong>.
</p>
<p className="mt-2 text-xs text-green-600">
{t("auth.linkExpires")}
</p>
</div>
<form onSubmit={handleVerifyCode} className="space-y-3">
<p className="text-sm text-gray-600">{t("auth.codeHelp")}</p>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
placeholder="000000"
value={verifyCode}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={loading || verifyCode.length !== 6}
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
>
{loading ? t("auth.authenticating") : t("auth.verifyCode")}
</button>
{error && <p className="text-sm text-red-600">{error}</p>}
</form>
</div>
</div>
);
@ -140,7 +196,7 @@ export default function RegisterPage() {
{t("auth.registerDescription")}
</p>
<form onSubmit={supportsPasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
<form onSubmit={mode === "passkey" && supportsPasskey ? handleRegisterPasskey : handleRegisterMagicLink} className="mt-8 space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
{t("auth.email")}
@ -200,7 +256,7 @@ export default function RegisterPage() {
<p className="text-sm text-red-600">{error}</p>
)}
{supportsPasskey ? (
{mode === "passkey" && supportsPasskey ? (
<button
type="submit"
disabled={loading || !termsAccepted}
@ -218,6 +274,18 @@ export default function RegisterPage() {
</button>
)}
{supportsPasskey && (
<div className="text-center">
<button
type="button"
onClick={() => setMode(mode === "passkey" ? "magic-link" : "passkey")}
className="text-sm text-gray-500 hover:text-gray-700"
>
{mode === "passkey" ? t("auth.useMagicLink") : t("auth.backToPasskey")}
</button>
</div>
)}
{supportsPasskey === false && (
<p className="text-sm text-gray-500">
{t("auth.passkeyNotSupportedRegister")}

View file

@ -396,17 +396,31 @@ export default function PrivacyPage() {
</li>
<li>
Profile visibility (<code>public</code> / <code>private</code>,
default <code>public</code>): a separate switch from content
visibility. <code>private</code> 404s your profile page and makes
you unfollowable; you can still post <code>public</code> content
reachable by direct URL. Change anytime in account settings.
default <code>private</code> for new accounts): a separate
switch from content visibility. <code>public</code> means
anyone can view your profile and follows auto-accept.
<code> private</code> 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.
</li>
<li>
Follows: which users on this instance follow which. Visible to
anyone via your <code>/users/&lt;you&gt;/followers</code> and
<code>/users/&lt;you&gt;/following</code> pages, mirroring
Mastodon-style conventions. Set your profile to{" "}
<code>private</code> to be unfollowable.
Follows: which users on this instance follow which.
Accepted relations are visible to anyone via your{" "}
<code>/users/&lt;you&gt;/followers</code> and{" "}
<code>/users/&lt;you&gt;/following</code> pages. Pending
requests against private profiles are visible only to the
requester and the target.
</li>
<li>
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.
</li>
</ul>
</section>

View file

@ -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<string>();
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<string, unknown> | null;
}
function summary(t: (key: string, opts?: Record<string, unknown>) => 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 (
<li
className={
row.readAt
? "border-b border-gray-100 px-4 py-3"
: "border-b border-blue-100 bg-blue-50 px-4 py-3"
}
>
<a href={row.link} onClick={onClick} className="block hover:underline">
<div className="flex items-start justify-between gap-3">
<div className="text-sm text-gray-900">
{!row.readAt && (
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-blue-500" aria-hidden="true" />
)}
{summary(t, row)}
</div>
<span className="shrink-0 text-xs text-gray-400">
<ClientDate iso={row.createdAt} withTime />
</span>
</div>
</a>
{inFlight && <span className="sr-only">marking read</span>}
</li>
);
}
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 (
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">{t("notifications.title")}</h1>
{hasUnread && (
<markAll.Form method="post" action="/api/notifications/read-all">
<button
type="submit"
disabled={markAll.state !== "idle"}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{t("notifications.markAllRead")}
</button>
</markAll.Form>
)}
</div>
{notifications.length === 0 ? (
<p className="mt-12 text-center text-gray-500">{t("notifications.empty")}</p>
) : (
<ul className="mt-6 rounded-lg border border-gray-200 bg-white">
{notifications.map((n) => (
<NotificationItem key={n.id} row={n} />
))}
</ul>
)}
</div>
);
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -146,20 +146,34 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
</p>
{user.bio && <p className="mt-2 text-gray-700">{user.bio}</p>}
<div className="mt-2 flex gap-4 text-sm text-gray-600">
<a
href={`/users/${user.username}/followers`}
className="hover:text-gray-900 hover:underline"
>
<span className="font-semibold text-gray-900">{followers}</span>{" "}
{t("social.followers.label")}
</a>
<a
href={`/users/${user.username}/following`}
className="hover:text-gray-900 hover:underline"
>
<span className="font-semibold text-gray-900">{following}</span>{" "}
{t("social.following.label")}
</a>
{canSeeContent ? (
<a
href={`/users/${user.username}/followers`}
className="hover:text-gray-900 hover:underline"
>
<span className="font-semibold text-gray-900">{followers}</span>{" "}
{t("social.followers.label")}
</a>
) : (
<span>
<span className="font-semibold text-gray-900">{followers}</span>{" "}
{t("social.followers.label")}
</span>
)}
{canSeeContent ? (
<a
href={`/users/${user.username}/following`}
className="hover:text-gray-900 hover:underline"
>
<span className="font-semibold text-gray-900">{following}</span>{" "}
{t("social.following.label")}
</a>
) : (
<span>
<span className="font-semibold text-gray-900">{following}</span>{" "}
{t("social.following.label")}
</span>
)}
</div>
</div>
{!isOwn && isLoggedIn && (

View file

@ -133,13 +133,23 @@ server.listen(port, async () => {
}
// Start background job worker
const demoJobs: Parameters<typeof startWorker>[1] = [];
const jobs: Parameters<typeof startWorker>[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");
});

View file

@ -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,
},
});

118
e2e/notifications.test.ts Normal file
View file

@ -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();
});
});

View file

@ -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

View file

@ -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.

View file

@ -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<userId, Set<Connection>>` 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: <fresh count after insert> })` 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<userId, Set<Connection>>` 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: <fresh count after insert> })` 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 02s 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 02s 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)

View file

@ -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<NotificationType>().notNull(),
actorUserId: text("actor_user_id").references(() => users.id, { onDelete: "set null" }),
subjectId: text("subject_id"),
payload: jsonb("payload").$type<Record<string, unknown>>(),
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`),
}));

View file

@ -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",

View file

@ -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",

View file

@ -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
? [