fix(05-review): IN-01 resolve actor display name in eventChangeDispatcher for D-02/D-03

This commit is contained in:
Lucas Berger
2026-06-09 22:25:32 -04:00
parent 8cecbab7ab
commit 50da9b3bca
2 changed files with 115 additions and 52 deletions
+31 -20
View File
@@ -15,9 +15,9 @@
* Fire-and-forget: sync correctness does not depend on push success.
*/
import { ne } from 'drizzle-orm'
import { eq, ne } from 'drizzle-orm'
import { db } from '../db/client.js'
import { pushSubscriptions } from '../db/schema.js'
import { users, pushSubscriptions } from '../db/schema.js'
import { dispatchPush } from './pushDispatcher.js'
// ── Types ────────────────────────────────────────────────────────────────────
@@ -71,32 +71,32 @@ export function isMeaningfulChange(change: EventChange): boolean {
}
/**
* Builds the notification title for a change.
* Actor name is omitted here (we don't have it from userId alone in the fast path);
* the copy follows the D-02 spec using generic "A member" as fallback. For the
* two-person household the non-actor will always be informed by the other member's
* action, so the body context is sufficient.
* Builds the notification copy for a change (D-02/D-03).
*
* Note: actorName resolution (querying users table) is a future D-02 enhancement.
* For MVP, use generic copy that still satisfies the acceptance criteria.
* @param change - The detected calendar event change.
* @param actorName - Display name of the actor (D-03: named in every notification).
* Falls back to 'A family member' when the users row is absent.
*/
function buildCopy(
change: EventChange,
actorName: string,
): { notifTitle: string; notifBody: string; navigate: string } {
const eventTitle = change.title ?? change.uid
let notifTitle: string
let notifBody: string
// D-02: event notifications show specifics — actor + title.
// D-03: name the actor in every change notification.
if (change.operation === 'create') {
notifTitle = 'New calendar event'
notifTitle = `${actorName} added an event`
notifBody = eventTitle
} else if (change.operation === 'delete') {
notifTitle = 'Calendar event removed'
notifTitle = `${actorName} removed an event`
notifBody = eventTitle
} else {
// update
notifTitle = 'Calendar event updated'
notifTitle = `${actorName} updated an event`
notifBody = eventTitle
}
@@ -128,13 +128,24 @@ export async function dispatchEventChange(
return
}
// D-13: query push_subscriptions from MariaDB only
// D-03: ne() filter excludes the actor at DB level; application-level filter
// below provides defence-in-depth (also makes the mock-based tests deterministic).
const allSubs = await db
.select()
.from(pushSubscriptions)
.where(ne(pushSubscriptions.userId, actorUserId))
// IN-01: resolve actor display name for D-02/D-03 notification copy.
// Runs in parallel with subscription query for minimal latency.
const [actorRows, allSubs] = await Promise.all([
db
.select({ displayName: users.displayName })
.from(users)
.where(eq(users.id, actorUserId))
.limit(1),
// D-13: query push_subscriptions from MariaDB only
// D-03: ne() filter excludes the actor at DB level; application-level filter
// below provides defence-in-depth (also makes the mock-based tests deterministic).
db
.select()
.from(pushSubscriptions)
.where(ne(pushSubscriptions.userId, actorUserId)),
])
const actorName: string = actorRows[0]?.displayName ?? 'A family member'
// D-03: additional application-level actor exclusion (defence-in-depth)
const subs = allSubs.filter((s) => s.userId !== actorUserId)
@@ -143,7 +154,7 @@ export async function dispatchEventChange(
return
}
const { notifTitle, notifBody, navigate } = buildCopy(change)
const { notifTitle, notifBody, navigate } = buildCopy(change, actorName)
// Fan out to all non-actor subscriptions (D-03 already enforced by ne() filter)
for (const sub of subs) {