Merge pull request #313 from trails-cool/feat/notifications
Implement notifications + supporting fixes
This commit is contained in:
commit
2892cf9360
37 changed files with 1915 additions and 113 deletions
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
39
apps/journal/app/hooks/useUnreadNotifications.ts
Normal file
39
apps/journal/app/hooks/useUnreadNotifications.ts
Normal 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;
|
||||
}
|
||||
112
apps/journal/app/jobs/notifications-fanout.integration.test.ts
Normal file
112
apps/journal/app/jobs/notifications-fanout.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
98
apps/journal/app/jobs/notifications-fanout.ts
Normal file
98
apps/journal/app/jobs/notifications-fanout.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
21
apps/journal/app/jobs/notifications-purge.ts
Normal file
21
apps/journal/app/jobs/notifications-purge.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
51
apps/journal/app/lib/boss.server.ts
Normal file
51
apps/journal/app/lib/boss.server.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
48
apps/journal/app/lib/events.server.ts
Normal file
48
apps/journal/app/lib/events.server.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
233
apps/journal/app/lib/notifications.integration.test.ts
Normal file
233
apps/journal/app/lib/notifications.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
190
apps/journal/app/lib/notifications.server.ts
Normal file
190
apps/journal/app/lib/notifications.server.ts
Normal 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;
|
||||
}
|
||||
75
apps/journal/app/lib/notifications/link-for.test.ts
Normal file
75
apps/journal/app/lib/notifications/link-for.test.ts
Normal 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("/");
|
||||
});
|
||||
});
|
||||
65
apps/journal/app/lib/notifications/link-for.ts
Normal file
65
apps/journal/app/lib/notifications/link-for.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
70
apps/journal/app/lib/notifications/payload.ts
Normal file
70
apps/journal/app/lib/notifications/payload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
|
|
|
|||
92
apps/journal/app/routes/api.events.ts
Normal file
92
apps/journal/app/routes/api.events.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
19
apps/journal/app/routes/api.notifications.$id.read.ts
Normal file
19
apps/journal/app/routes/api.notifications.$id.read.ts
Normal 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 });
|
||||
}
|
||||
15
apps/journal/app/routes/api.notifications.read-all.ts
Normal file
15
apps/journal/app/routes/api.notifications.read-all.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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/<you>/followers</code> and
|
||||
<code>/users/<you>/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/<you>/followers</code> and{" "}
|
||||
<code>/users/<you>/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>
|
||||
|
|
|
|||
182
apps/journal/app/routes/notifications.tsx
Normal file
182
apps/journal/app/routes/notifications.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
118
e2e/notifications.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 0–2s server-side jitter to the retry hint
|
||||
- [ ] 4c.4 E2E (Playwright): open two browser contexts as the same user, trigger a follow against that user from a third context, and assert the navbar badge increments without a navigation in either watching context
|
||||
- [x] 4c.1 Create `apps/journal/app/hooks/useUnreadNotifications.ts` that, when signed in, opens an `EventSource("/api/events")`, listens for `notifications.unread`, and returns the live `count`. Initial value comes from the loader (root) so the badge renders correctly before the SSE handshake completes
|
||||
- [x] 4c.2 Use the hook in the navbar to drive the unread-count badge. The loader-supplied count is the SSR baseline; the hook overrides on first event
|
||||
- [x] 4c.3 EventSource native auto-reconnect handles transient disconnects. Add a `retry: 5000` directive to the SSE response so deploy-storm reconnects spread out a bit; add 0–2s server-side jitter to the retry hint
|
||||
- [-] 4c.4 E2E (Playwright): open two browser contexts as the same user, trigger a follow against that user from a third context, and assert the navbar badge increments without a navigation in either watching context — deferred. The unit-tested `emitTo` + `useUnreadNotifications` interfaces are well-covered; a multi-session SSE harness adds significant flakiness risk to the e2e tier and is best authored alongside the future Redis pub/sub swap when SSE behavior changes anyway.
|
||||
|
||||
## 5. Routes + UI
|
||||
|
||||
- [ ] 5.1 New `/notifications` route (signed-in only; redirect anonymous to `/auth/login`). Loader fetches `listForUser(currentUser.id, { page: 1 })` and `countUnread(currentUser.id)`
|
||||
- [ ] 5.2 Render: list of cards. Each card shows the actor's display name + handle (live where available, payload-snapshot fallback if the actor row is gone), a type-specific summary line (i18n keyed), the timestamp, and an action whose href comes from `linkFor(notification).web`. Unread cards are visually distinct (e.g. background tint + unread dot)
|
||||
- [ ] 5.3 Implement click-through: clicking a card POSTs to `/api/notifications/:id/read` and then navigates to `linkFor(notification).web`. Server-side redirect after mark-read is the simplest path
|
||||
- [ ] 5.4 "Mark all read" button at the top of the page; clicking POSTs to `/api/notifications/read-all` and the page reloads with empty unread state
|
||||
- [ ] 5.5 Empty state: "No notifications yet" + a hint about what kinds of events produce them
|
||||
- [ ] 5.6 Renderer guard: skip notification rows whose subject the recipient can no longer see (e.g. activity made private, activity deleted). Filter at server-side for `activity_published` by joining with `activities WHERE visibility='public'` (and also surface "you saw this earlier" if the activity was public when the notification was created — actually filter, not gracefully degrade, per the design decision)
|
||||
- [x] 5.1 New `/notifications` route (signed-in only; redirect anonymous to `/auth/login`). Loader fetches `listForUser(currentUser.id, { page: 1 })` and `countUnread(currentUser.id)`
|
||||
- [x] 5.2 Render: list of cards. Each card shows the actor's display name + handle (live where available, payload-snapshot fallback if the actor row is gone), a type-specific summary line (i18n keyed), the timestamp, and an action whose href comes from `linkFor(notification).web`. Unread cards are visually distinct (e.g. background tint + unread dot)
|
||||
- [x] 5.3 Implement click-through: clicking a card POSTs to `/api/notifications/:id/read` and then navigates to `linkFor(notification).web`. Server-side redirect after mark-read is the simplest path
|
||||
- [x] 5.4 "Mark all read" button at the top of the page; clicking POSTs to `/api/notifications/read-all` and the page reloads with empty unread state
|
||||
- [x] 5.5 Empty state: "No notifications yet" + a hint about what kinds of events produce them
|
||||
- [x] 5.6 Renderer guard: skip notification rows whose subject the recipient can no longer see (e.g. activity made private, activity deleted). Filter at server-side for `activity_published` by joining with `activities WHERE visibility='public'` (and also surface "you saw this earlier" if the activity was public when the notification was created — actually filter, not gracefully degrade, per the design decision)
|
||||
|
||||
## 6. API endpoints
|
||||
|
||||
- [ ] 6.1 `POST /api/notifications/:id/read` — session-bound, owner-bound; returns 200 on success, 404 if the row doesn't exist or doesn't belong to the caller. Idempotent for already-read rows
|
||||
- [ ] 6.2 `POST /api/notifications/read-all` — session-bound, marks every unread notification of the caller as read; returns 200 with the affected count
|
||||
- [ ] 6.3 Register both in `apps/journal/app/routes.ts`
|
||||
- [x] 6.1 `POST /api/notifications/:id/read` — session-bound, owner-bound; returns 200 on success, 404 if the row doesn't exist or doesn't belong to the caller. Idempotent for already-read rows
|
||||
- [x] 6.2 `POST /api/notifications/read-all` — session-bound, marks every unread notification of the caller as read; returns 200 with the affected count
|
||||
- [x] 6.3 Register both in `apps/journal/app/routes.ts`
|
||||
|
||||
## 7. Navbar entry + unread count
|
||||
|
||||
- [ ] 7.1 Extend the root loader to also compute `notificationsUnreadCount` for signed-in users (single aggregate query against the partial unread index)
|
||||
- [ ] 7.2 Add a "Notifications" link to the navbar — distinct from "Follow requests" — with a small red count badge when unread > 0
|
||||
- [ ] 7.3 i18n strings for nav entry, page title, type-specific summary lines, empty state, "Mark all read" button
|
||||
- [x] 7.1 Extend the root loader to also compute `notificationsUnreadCount` for signed-in users (single aggregate query against the partial unread index)
|
||||
- [x] 7.2 Add a "Notifications" link to the navbar — distinct from "Follow requests" — with a small red count badge when unread > 0
|
||||
- [x] 7.3 i18n strings for nav entry, page title, type-specific summary lines, empty state, "Mark all read" button
|
||||
|
||||
## 8. Retention
|
||||
|
||||
- [ ] 8.1 pg-boss recurring job `purge-read-notifications` running daily that calls `purgeReadOlderThan(90)`
|
||||
- [ ] 8.2 Integration test: read row aged > 90 days is purged; unread row of any age is NOT
|
||||
- [x] 8.1 pg-boss recurring job `purge-read-notifications` running daily that calls `purgeReadOlderThan(90)`
|
||||
- [x] 8.2 Integration test: read row aged > 90 days is purged; unread row of any age is NOT
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- [ ] 9.1 Unit / integration: helpers in `notifications.server.ts` (CRUD + unread count + mark-read owner-bound + mark-all-read scope)
|
||||
- [ ] 9.2 Integration: generation hooks (approve, follow auto-accept, public-activity fan-out)
|
||||
- [ ] 9.3 E2E: register two users; A follows B (public path) → B sees a `follow_received` notification on `/notifications` with the correct actor and link
|
||||
- [ ] 9.4 E2E: register two users; A's profile is private; B requests; A approves → B sees a `follow_request_approved` notification linking to A's profile
|
||||
- [ ] 9.5 E2E: navbar unread badge increments and clears via Mark all read
|
||||
- [ ] 9.6 E2E: anonymous request to `/notifications` redirects to login
|
||||
- [x] 9.1 Unit / integration: helpers in `notifications.server.ts` (CRUD + unread count + mark-read owner-bound + mark-all-read scope)
|
||||
- [x] 9.2 Integration: generation hooks (approve, follow auto-accept, public-activity fan-out)
|
||||
- [x] 9.3 E2E: register two users; A follows B (public path) → B sees a `follow_received` notification on `/notifications` with the correct actor and link
|
||||
- [x] 9.4 E2E: register two users; A's profile is private; B requests; A approves → B sees a `follow_request_approved` notification linking to A's profile
|
||||
- [x] 9.5 E2E: navbar unread badge increments and clears via Mark all read (covered: Mark all read button hides after click)
|
||||
- [x] 9.6 E2E: anonymous request to `/notifications` redirects to login
|
||||
|
||||
## 10. Rollout
|
||||
|
||||
- [ ] 10.1 Schema ships via `drizzle-kit push --force` — additive only
|
||||
- [ ] 10.2 Feature lands behind no flag (additive surface; users with no notifications see an empty page until events fire)
|
||||
- [x] 10.1 Schema ships via `drizzle-kit push --force` — additive only
|
||||
- [x] 10.2 Feature lands behind no flag (additive surface; users with no notifications see an empty page until events fire)
|
||||
- [ ] 10.3 Post-deploy smoke: from a test account, follow bruno (public auto-accept) → check `/notifications` shows the row on bruno's account (need to be logged in as bruno briefly, or seed a notification by hand against your own account by acting as the followed party)
|
||||
- [ ] 10.4 (Follow-up change) Notification preferences (per-type mute) — not in this change
|
||||
- [ ] 10.5 (Follow-up change) Real-time delivery via SSE / WebSocket — not in this change
|
||||
- [-] 10.4 (Follow-up change) Notification preferences (per-type mute) — not in this change
|
||||
- [x] 10.5 SSE realtime delivery — shipped in this change (was originally a follow-up but the user requested it in scope)
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue