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>
81 lines
3.6 KiB
SQL
81 lines
3.6 KiB
SQL
-- Notifications — write-time mention fan-out (v0.3.0 phase 1).
|
|
--
|
|
-- Replaces the regex-on-decoded-ciphertext scan in /v1/notifications and
|
|
-- the dashboard MentionsSection. Lets us drop the
|
|
-- `convert_from(decode(ciphertext, 'base64'), 'UTF8') ~* @name` query that
|
|
-- breaks the moment ciphertext stops being base64-of-UTF8 (i.e. the
|
|
-- moment per-topic encryption lands in v0.3.0 phase 2).
|
|
--
|
|
-- One row per (recipient_member, topic_message). Idempotent ON CONFLICT
|
|
-- on the unique pair; if the broker re-fans a message after a crash the
|
|
-- recipient sees one notification, not two.
|
|
--
|
|
-- Server-side mention extraction happens in POST /v1/messages and the
|
|
-- broker's WS message handler. Both extract @-tokens from the body
|
|
-- BEFORE encryption (the only point at which the server can read it),
|
|
-- match against the topic's member roster, and insert a row per match.
|
|
|
|
CREATE TABLE IF NOT EXISTS "mesh"."notification" (
|
|
"id" text PRIMARY KEY NOT NULL,
|
|
"mesh_id" text NOT NULL REFERENCES "mesh"."mesh"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
"topic_id" text NOT NULL REFERENCES "mesh"."topic"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
"message_id" text NOT NULL REFERENCES "mesh"."topic_message"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
"recipient_member_id" text NOT NULL REFERENCES "mesh"."member"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
"sender_member_id" text NOT NULL REFERENCES "mesh"."member"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
"kind" text NOT NULL DEFAULT 'mention',
|
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
"read_at" timestamp
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "notification_unique"
|
|
ON "mesh"."notification" ("message_id", "recipient_member_id");
|
|
|
|
CREATE INDEX IF NOT EXISTS "notification_by_recipient_unread"
|
|
ON "mesh"."notification" ("recipient_member_id", "created_at" DESC)
|
|
WHERE "read_at" IS NULL;
|
|
|
|
CREATE INDEX IF NOT EXISTS "notification_by_recipient"
|
|
ON "mesh"."notification" ("recipient_member_id", "created_at" DESC);
|
|
|
|
CREATE INDEX IF NOT EXISTS "notification_by_mesh"
|
|
ON "mesh"."notification" ("mesh_id", "created_at" DESC);
|
|
|
|
-- Backfill existing v0.2.0 messages so the new table has history. Safe
|
|
-- to run multiple times (ON CONFLICT DO NOTHING). The regex matches the
|
|
-- same shape as the in-app autocomplete + render: @-prefixed token with
|
|
-- a non-word boundary on both sides (or string edges).
|
|
--
|
|
-- We skip messages that fail to decode — defensive against any non-base64
|
|
-- ciphertext that may have slipped in via future writers.
|
|
INSERT INTO "mesh"."notification"
|
|
("id", "mesh_id", "topic_id", "message_id", "recipient_member_id",
|
|
"sender_member_id", "kind", "created_at")
|
|
SELECT
|
|
replace(gen_random_uuid()::text, '-', ''),
|
|
t."mesh_id",
|
|
m."topic_id",
|
|
m."id",
|
|
recipient."id",
|
|
m."sender_member_id",
|
|
'mention',
|
|
m."created_at"
|
|
FROM "mesh"."topic_message" m
|
|
INNER JOIN "mesh"."topic" t ON t."id" = m."topic_id"
|
|
INNER JOIN "mesh"."member" recipient
|
|
ON recipient."mesh_id" = t."mesh_id"
|
|
AND recipient."revoked_at" IS NULL
|
|
AND recipient."id" <> m."sender_member_id"
|
|
WHERE
|
|
-- Only scan messages that look like base64-of-UTF8. Defensive guard
|
|
-- against a future writer storing binary ciphertext — convert_from
|
|
-- would otherwise raise and abort the whole migration.
|
|
m."ciphertext" ~ '^[A-Za-z0-9+/=]+$'
|
|
AND length(m."ciphertext") > 0
|
|
AND length(m."ciphertext") % 4 = 0
|
|
AND convert_from(decode(m."ciphertext", 'base64'), 'UTF8') ~* (
|
|
'(^|\s|[^A-Za-z0-9_-])@'
|
|
|| regexp_replace(recipient."display_name", '([.*+?^${}()|\[\]\\])', '\\\1', 'g')
|
|
|| '($|[^A-Za-z0-9_-])'
|
|
)
|
|
ON CONFLICT ("message_id", "recipient_member_id") DO NOTHING;
|