feat(workspace): claudemesh me notifications + dashboard parity
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

ships v0.4.0 phase 3.

api: GET /v1/me/notifications aggregates the mesh.notification
table across every joined mesh in a 7-day window (?since=iso
overrides, ?include=all surfaces already-read). returns sender +
topic + mesh context plus a 240-char snippet for v1 plaintext
messages or raw ciphertext for v2 (the dashboard topic-key cache
decrypts client-side).

cli (1.12.0): claudemesh me notifications — terse unread feed
with @ dot, --all to include read, --since for custom window.

web: /dashboard/notifications mirrors the cli view in card form,
adds a notifications entry to the dashboard sidebar between
topics and invites. each card links straight to the topic chat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-03 02:35:57 +01:00
parent 1c335e8daa
commit 43e429f204
8 changed files with 487 additions and 10 deletions

View File

@@ -39,7 +39,7 @@ import {
messageQueue,
presence,
} from "@turbostarter/db/schema/mesh";
import { and, asc, count, desc, eq, gt, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
import { aliasedTable, and, asc, count, desc, eq, gt, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
import { validate } from "../../middleware";
import {
@@ -496,6 +496,133 @@ export const v1Router = new Hono<Env>()
});
})
// GET /v1/me/notifications — cross-mesh @-mention feed.
//
// Returns recent unread notifications (default) or all notifications
// (?include=all) targeting the caller's member rows across every
// joined mesh. Each row carries mesh + topic + sender context plus a
// 240-char ciphertext-base64 snippet (clients decrypt under the
// topic key they already cached). 7-day window keeps the response
// bounded; use ?since=<iso> to override.
.get("/me/notifications", async (c) => {
const key = c.var.apiKey;
requireCapability(key, "read");
if (!key.issuedByMemberId) {
return c.json({ error: "api_key_has_no_issuer" }, 400);
}
const [issuer] = await db
.select({ userId: meshMember.userId })
.from(meshMember)
.where(eq(meshMember.id, key.issuedByMemberId));
if (!issuer?.userId) {
return c.json({ error: "issuer_member_has_no_user" }, 400);
}
const memberships = await db
.select({ memberId: meshMember.id })
.from(meshMember)
.innerJoin(mesh, eq(mesh.id, meshMember.meshId))
.where(
and(
eq(meshMember.userId, issuer.userId),
isNull(meshMember.revokedAt),
isNull(mesh.archivedAt),
),
);
if (memberships.length === 0) {
return c.json({
notifications: [],
totals: { unread: 0, total: 0 },
});
}
const myMemberIds = memberships.map((m) => m.memberId);
const includeAll = c.req.query("include") === "all";
const sinceParam = c.req.query("since");
const sinceDate = sinceParam
? new Date(sinceParam)
: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const senderMember = aliasedTable(meshMember, "sender_member");
const where = and(
inArray(meshNotification.recipientMemberId, myMemberIds),
isNull(meshTopic.archivedAt),
gt(meshTopicMessage.createdAt, sinceDate),
...(includeAll ? [] : [isNull(meshNotification.readAt)]),
);
const rows = await db
.select({
notificationId: meshNotification.id,
messageId: meshTopicMessage.id,
topicId: meshTopicMessage.topicId,
topicName: meshTopic.name,
meshId: meshTopic.meshId,
meshSlug: mesh.slug,
meshName: mesh.name,
senderName: senderMember.displayName,
senderMemberId: senderMember.id,
ciphertext: meshTopicMessage.ciphertext,
bodyVersion: meshTopicMessage.bodyVersion,
readAt: meshNotification.readAt,
createdAt: meshTopicMessage.createdAt,
})
.from(meshNotification)
.innerJoin(
meshTopicMessage,
eq(meshTopicMessage.id, meshNotification.messageId),
)
.innerJoin(meshTopic, eq(meshTopic.id, meshNotification.topicId))
.innerJoin(mesh, eq(mesh.id, meshTopic.meshId))
.innerJoin(
senderMember,
eq(senderMember.id, meshNotification.senderMemberId),
)
.where(where)
.orderBy(desc(meshTopicMessage.createdAt))
.limit(100);
const decode = (b64: string) => {
try {
return Buffer.from(b64, "base64").toString("utf-8");
} catch {
return "";
}
};
const notifications = rows.map((r) => ({
notificationId: r.notificationId,
messageId: r.messageId,
topicId: r.topicId,
topicName: r.topicName,
meshId: r.meshId,
meshSlug: r.meshSlug,
meshName: r.meshName,
senderName: r.senderName,
// For v1 (plaintext-base64) messages, surface a decoded snippet so
// CLI/dashboard can render it without doing crypto. v2 messages
// ship ciphertext only — the client decrypts with the topic key.
snippet:
r.bodyVersion === 1 ? decode(r.ciphertext).slice(0, 240) : null,
ciphertext: r.bodyVersion === 2 ? r.ciphertext : null,
bodyVersion: r.bodyVersion,
read: !!r.readAt,
readAt: r.readAt ? r.readAt.toISOString() : null,
createdAt: r.createdAt.toISOString(),
}));
const unreadCount = notifications.filter((n) => !n.read).length;
return c.json({
notifications,
totals: {
unread: unreadCount,
total: notifications.length,
},
});
})
// GET /v1/me/topics — cross-mesh topic list for the caller's user.
//
// For each topic across every mesh the user belongs to, returns