feat(api+broker+web): write-time mention fan-out via notification table
Phase 1 of v0.3.0 — replaces the regex-on-decoded-ciphertext scan in /v1/notifications and the dashboard MentionsSection with reads from a new mesh.notification table populated at write time. Schema 0025: mesh.notification (id, mesh_id, topic_id, message_id, recipient_member_id, sender_member_id, kind, created_at, read_at) with a unique (message_id, recipient) so a re-fanned message yields one row per recipient. Backfills existing v0.2.0 messages by regex-matching the (still-base64-plaintext) bodies — guarded with a base64 + length check so binary ciphertext doesn't crash the migration. Writers (POST /v1/messages + broker appendTopicMessage) now extract @-mentions from either an explicit `mentions: string[]` on the request OR a regex over the base64 plaintext (transitional fallback). Targets are intersected with the mesh roster + capped at 32 per message. Web chat panel sends the explicit array now so it keeps working after phase 2 lands. Readers switch to JOIN-on-notification: /v1/notifications — table-backed, supports ?unread=1 POST /v1/notifications/read — new, mark by ids or all-up-to MentionsSection (RSC) — same JOIN, returns readAt for each row GET /v1/notifications also gains a read_at field per row so a future bell UI can show unread vs read. Once per-topic encryption (phase 2) lands, the regex fallback becomes a no-op for v2 messages — clients MUST send `mentions`, which they already do. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1484,6 +1484,89 @@ export const insertMeshTopicMessageSchema =
|
||||
export type SelectMeshTopicMessage = typeof meshTopicMessage.$inferSelect;
|
||||
export type InsertMeshTopicMessage = typeof meshTopicMessage.$inferInsert;
|
||||
|
||||
/**
|
||||
* Per-recipient notifications. v0.3.0 phase 1: server-side mention
|
||||
* extraction at write time replaces the regex-on-decoded-ciphertext
|
||||
* scan in /v1/notifications. Fanned out at POST /v1/messages and the
|
||||
* broker's WS topic_send handler — one row per (recipient, message).
|
||||
*
|
||||
* `kind` is open-ended ("mention" today; future kinds: "reply",
|
||||
* "task_assigned", etc.) so we can extend without a migration.
|
||||
*/
|
||||
export const meshNotification = meshSchema.table(
|
||||
"notification",
|
||||
{
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
meshId: text()
|
||||
.references(() => mesh.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
topicId: text()
|
||||
.references(() => meshTopic.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
messageId: text()
|
||||
.references(() => meshTopicMessage.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
recipientMemberId: text()
|
||||
.references(() => meshMember.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
senderMemberId: text()
|
||||
.references(() => meshMember.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
kind: text().notNull().default("mention"),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
readAt: timestamp(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("notification_unique").on(t.messageId, t.recipientMemberId),
|
||||
index("notification_by_recipient").on(t.recipientMemberId, t.createdAt),
|
||||
index("notification_by_mesh").on(t.meshId, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const meshNotificationRelations = relations(
|
||||
meshNotification,
|
||||
({ one }) => ({
|
||||
topic: one(meshTopic, {
|
||||
fields: [meshNotification.topicId],
|
||||
references: [meshTopic.id],
|
||||
}),
|
||||
message: one(meshTopicMessage, {
|
||||
fields: [meshNotification.messageId],
|
||||
references: [meshTopicMessage.id],
|
||||
}),
|
||||
recipient: one(meshMember, {
|
||||
fields: [meshNotification.recipientMemberId],
|
||||
references: [meshMember.id],
|
||||
}),
|
||||
sender: one(meshMember, {
|
||||
fields: [meshNotification.senderMemberId],
|
||||
references: [meshMember.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const selectMeshNotificationSchema =
|
||||
createSelectSchema(meshNotification);
|
||||
export const insertMeshNotificationSchema =
|
||||
createInsertSchema(meshNotification);
|
||||
export type SelectMeshNotification = typeof meshNotification.$inferSelect;
|
||||
export type InsertMeshNotification = typeof meshNotification.$inferInsert;
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────
|
||||
* API keys (v0.2.0) — REST + external WS auth.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user