Compare commits
2 Commits
397ddb4c45
...
43e429f204
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e429f204 | ||
|
|
1c335e8daa |
71
.github/workflows/deploy-web.yml
vendored
Normal file
71
.github/workflows/deploy-web.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Deploy claudemesh-web
|
||||
|
||||
# Triggers a Coolify deploy of the apps/web Next.js app on the OVH VPS.
|
||||
# Coolify only auto-deploys the broker (it watches the gitea-vps mirror);
|
||||
# the web app needs an explicit poke. This workflow is the poke.
|
||||
#
|
||||
# The Coolify dashboard is bound to a Tailscale-only address
|
||||
# (100.122.34.28:8000), so the runner first joins the tailnet via
|
||||
# an OAuth-issued ephemeral node, then hits Coolify's deploy API.
|
||||
#
|
||||
# Path filter: redeploy on changes to the web app, the API package
|
||||
# (bundled into the web build), or any shared package the web app
|
||||
# transpiles. Anything else (broker-only, cli-only, docs) skips it.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "apps/web/**"
|
||||
- "packages/api/**"
|
||||
- "packages/db/**"
|
||||
- "packages/auth/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/i18n/**"
|
||||
- "packages/shared/**"
|
||||
- "packages/email/**"
|
||||
- "packages/billing/**"
|
||||
- "packages/storage/**"
|
||||
- "packages/monitoring-web/**"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/deploy-web.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
# Coalesce rapid pushes — only one deploy in flight at a time, and
|
||||
# if a newer push lands while one is queued, the older one is
|
||||
# cancelled. Avoids the "5 commits, 5 deploys" stampede.
|
||||
concurrency:
|
||||
group: deploy-web
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Connect to Tailscale
|
||||
uses: tailscale/github-action@v3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:ci
|
||||
|
||||
- name: Trigger Coolify deploy
|
||||
env:
|
||||
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
|
||||
APP_UUID: p68x1e3k4xmrjmblca5ybe09
|
||||
run: |
|
||||
if [ -z "$COOLIFY_TOKEN" ]; then
|
||||
echo "::error::COOLIFY_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
response=$(curl -sS -w "\n%{http_code}" -X GET \
|
||||
"http://100.122.34.28:8000/api/v1/deploy?uuid=${APP_UUID}" \
|
||||
-H "Authorization: Bearer ${COOLIFY_TOKEN}")
|
||||
status=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
echo "HTTP $status"
|
||||
echo "$body"
|
||||
if [ "$status" != "200" ]; then
|
||||
echo "::error::Coolify returned HTTP $status"
|
||||
exit 1
|
||||
fi
|
||||
@@ -20,11 +20,12 @@ Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
|
||||
|
||||
## Deploy
|
||||
|
||||
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`. Pending migrations apply automatically on startup.
|
||||
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy via the gitea webhook. Pending migrations apply automatically on startup.
|
||||
- **Web:** Coolify on the OVH VPS (`claudemesh.com` resolves to `135.125.191.245`, NOT Vercel — the `apps/web/Dockerfile` is what Coolify builds). Auto-deploys via `.github/workflows/deploy-web.yml` on push to `main` when paths under `apps/web/**` or `packages/{api,db,auth,ui,i18n,shared,email,billing,storage,monitoring-web}/**` change. The workflow joins the tailnet via Tailscale OAuth, then hits the Coolify API.
|
||||
- **Manual deploy** (if the workflow is broken or the path filter missed something) — Coolify dashboard at `http://100.122.34.28:8000` (Tailscale only). Token in `COOLIFY_TOKEN` repo secret. App UUIDs: broker `mcn8m74tbxfxbplmyb40b2ia`, web `p68x1e3k4xmrjmblca5ybe09`.
|
||||
- **CLI:**
|
||||
- npm: `cd apps/cli && npm publish --tag alpha --access public --no-git-checks --ignore-scripts`
|
||||
- npm: `cd apps/cli && npm publish --access public --no-git-checks --ignore-scripts`
|
||||
- Binaries: `git tag cli-v<version> && git push github cli-v<version>` — workflow builds 5 platforms.
|
||||
- **Web:** Coolify on the OVH VPS (`claudemesh.com` resolves to `135.125.191.245`, NOT Vercel — the `apps/web/Dockerfile` is what Coolify builds). Push to `gitea-vps` does NOT auto-deploy the web app the way it does the broker. Trigger manually: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=p68x1e3k4xmrjmblca5ybe09" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`
|
||||
|
||||
## Dev
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
|
||||
@@ -200,6 +200,100 @@ export async function runMeTopics(flags: MeTopicsFlags): Promise<number> {
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceNotification {
|
||||
notificationId: string;
|
||||
messageId: string;
|
||||
topicId: string;
|
||||
topicName: string;
|
||||
meshId: string;
|
||||
meshSlug: string;
|
||||
meshName: string;
|
||||
senderName: string | null;
|
||||
snippet: string | null;
|
||||
ciphertext: string | null;
|
||||
bodyVersion: number;
|
||||
read: boolean;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface WorkspaceNotificationsResponse {
|
||||
notifications: WorkspaceNotification[];
|
||||
totals: { unread: number; total: number };
|
||||
}
|
||||
|
||||
export interface MeNotificationsFlags extends MeFlags {
|
||||
all?: boolean;
|
||||
since?: string;
|
||||
}
|
||||
|
||||
export async function runMeNotifications(
|
||||
flags: MeNotificationsFlags,
|
||||
): Promise<number> {
|
||||
return withRestKey(
|
||||
{
|
||||
meshSlug: flags.mesh ?? null,
|
||||
purpose: "workspace-notifications",
|
||||
capabilities: ["read"],
|
||||
},
|
||||
async ({ secret }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (flags.all) params.set("include", "all");
|
||||
if (flags.since) params.set("since", flags.since);
|
||||
const path =
|
||||
"/api/v1/me/notifications" +
|
||||
(params.toString() ? `?${params.toString()}` : "");
|
||||
const ws = await request<WorkspaceNotificationsResponse>({
|
||||
path,
|
||||
token: secret,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(ws, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
const headerLabel = flags.all ? "@-mentions (all)" : "@-mentions (unread)";
|
||||
render.section(
|
||||
`${clay(headerLabel)} — ${ws.totals.total} ${dim(
|
||||
ws.totals.unread > 0 ? `· ${ws.totals.unread} unread` : "· nothing pending",
|
||||
)}`,
|
||||
);
|
||||
|
||||
if (ws.notifications.length === 0) {
|
||||
process.stdout.write(
|
||||
dim(
|
||||
flags.all
|
||||
? " no @-mentions in window\n"
|
||||
: " inbox zero — nothing waiting\n",
|
||||
),
|
||||
);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
const slugWidth = Math.max(
|
||||
...ws.notifications.map((n) => n.meshSlug.length),
|
||||
6,
|
||||
);
|
||||
|
||||
for (const n of ws.notifications) {
|
||||
const slug = dim(n.meshSlug.padEnd(slugWidth));
|
||||
const topic = cyan(`#${n.topicName}`);
|
||||
const sender = n.senderName ? `from ${n.senderName}` : "from ?";
|
||||
const ago = formatRelativeTime(n.createdAt);
|
||||
const dot = n.read ? dim("·") : yellow("●");
|
||||
const snippet =
|
||||
n.snippet ?? (n.ciphertext ? dim("[encrypted]") : dim("[empty]"));
|
||||
process.stdout.write(
|
||||
` ${dot} ${slug} ${topic} ${dim(sender)} ${dim(ago)}\n` +
|
||||
` ${snippet.length > 200 ? snippet.slice(0, 200) + "…" : snippet}\n`,
|
||||
);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -125,6 +125,7 @@ Topic (conversation scope, v0.2.0)
|
||||
claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext)
|
||||
claudemesh me cross-mesh workspace overview (v0.4.0)
|
||||
claudemesh me topics cross-mesh topic list [--unread]
|
||||
claudemesh me notifications cross-mesh @-mentions [--all] [--since=ISO]
|
||||
claudemesh member list mesh roster with online state [--online]
|
||||
claudemesh notification list recent @-mentions of you [--since <ISO>]
|
||||
|
||||
@@ -687,11 +688,23 @@ async function main(): Promise<void> {
|
||||
} else if (sub === "topics") {
|
||||
const { runMeTopics } = await import("~/commands/me.js");
|
||||
process.exit(await runMeTopics({ ...f, unread: !!flags.unread }));
|
||||
} else if (sub === "notifications" || sub === "notifs") {
|
||||
const { runMeNotifications } = await import("~/commands/me.js");
|
||||
process.exit(
|
||||
await runMeNotifications({
|
||||
...f,
|
||||
all: !!flags.all,
|
||||
since: flags.since as string | undefined,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
"Usage: claudemesh me (cross-mesh overview)\n" +
|
||||
" claudemesh me topics (cross-mesh topic list)\n" +
|
||||
" claudemesh me topics --unread (only unread topics)",
|
||||
" claudemesh me topics --unread (only unread topics)\n" +
|
||||
" claudemesh me notifications (unread @-mentions, last 7d)\n" +
|
||||
" claudemesh me notifications --all (include already-read)\n" +
|
||||
" claudemesh me notifications --since=ISO (custom window)",
|
||||
);
|
||||
process.exit(EXIT.INVALID_ARGS);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ const menu = [
|
||||
href: pathsConfig.dashboard.user.topics,
|
||||
icon: Icons.MessageSquare,
|
||||
},
|
||||
{
|
||||
title: "notifications",
|
||||
href: pathsConfig.dashboard.user.notifications,
|
||||
icon: Icons.Bell,
|
||||
},
|
||||
{
|
||||
title: "invites",
|
||||
href: pathsConfig.dashboard.user.invites,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { db } from "@turbostarter/db/server";
|
||||
import {
|
||||
mesh,
|
||||
meshMember,
|
||||
meshNotification,
|
||||
meshTopic,
|
||||
meshTopicMessage,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
import { aliasedTable, and, desc, eq, gt, inArray, isNull } from "drizzle-orm";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Reveal } from "~/modules/dashboard/universe/reveal";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Notifications",
|
||||
description: "@-mentions across every mesh, last 7 days.",
|
||||
});
|
||||
|
||||
const formatRelative = (iso: string) => {
|
||||
const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
if (sec < 86_400 * 30) return `${Math.floor(sec / 86_400)}d ago`;
|
||||
if (sec < 86_400 * 365) return `${Math.floor(sec / (86_400 * 30))}mo ago`;
|
||||
return `${Math.floor(sec / (86_400 * 365))}y ago`;
|
||||
};
|
||||
|
||||
const decode = (b64: string) => {
|
||||
try {
|
||||
return Buffer.from(b64, "base64").toString("utf-8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ all?: string }>;
|
||||
}
|
||||
|
||||
export default async function WorkspaceNotificationsPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const { user } = await getSession();
|
||||
if (!user) return null;
|
||||
|
||||
const params = await searchParams;
|
||||
const includeAll = params.all === "1";
|
||||
|
||||
const memberships = await db
|
||||
.select({ memberId: meshMember.id })
|
||||
.from(meshMember)
|
||||
.innerJoin(mesh, eq(mesh.id, meshMember.meshId))
|
||||
.where(
|
||||
and(
|
||||
eq(meshMember.userId, user.id),
|
||||
isNull(meshMember.revokedAt),
|
||||
isNull(mesh.archivedAt),
|
||||
),
|
||||
);
|
||||
|
||||
const myMemberIds = memberships.map((m) => m.memberId);
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const senderMember = aliasedTable(meshMember, "sender_member");
|
||||
const rows = myMemberIds.length
|
||||
? await db
|
||||
.select({
|
||||
id: meshNotification.id,
|
||||
messageId: meshTopicMessage.id,
|
||||
topicName: meshTopic.name,
|
||||
meshId: meshTopic.meshId,
|
||||
meshSlug: mesh.slug,
|
||||
senderName: senderMember.displayName,
|
||||
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(
|
||||
and(
|
||||
inArray(meshNotification.recipientMemberId, myMemberIds),
|
||||
isNull(meshTopic.archivedAt),
|
||||
gt(meshTopicMessage.createdAt, since),
|
||||
...(includeAll ? [] : [isNull(meshNotification.readAt)]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(meshTopicMessage.createdAt))
|
||||
.limit(100)
|
||||
: [];
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
messageId: r.messageId,
|
||||
topicName: r.topicName,
|
||||
meshId: r.meshId,
|
||||
meshSlug: r.meshSlug,
|
||||
senderName: r.senderName ?? "?",
|
||||
snippet: r.bodyVersion === 1 ? decode(r.ciphertext).slice(0, 240) : null,
|
||||
encrypted: r.bodyVersion === 2,
|
||||
read: !!r.readAt,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
const unreadCount = items.filter((i) => !i.read).length;
|
||||
|
||||
return (
|
||||
<div className="@container relative h-full p-6 md:p-10">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 70% 50% at 50% -10%, rgba(217,119,87,0.08), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 mx-auto max-w-[900px]">
|
||||
<header className="mb-10 grid gap-6 border-b border-[var(--cm-border-soft,rgba(217,119,87,0.1))] pb-8 md:mb-14 md:grid-cols-[1fr_auto] md:items-end md:pb-10">
|
||||
<Reveal delay={0}>
|
||||
<h1
|
||||
className="text-[clamp(2rem,1.6rem+2.5vw,3.25rem)] leading-[1.05] tracking-tight"
|
||||
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
||||
>
|
||||
<span className="italic text-[var(--cm-fg-tertiary)]">Mentions</span>,{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">on you</span>.
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={1}>
|
||||
<div className="flex items-center gap-6 font-mono text-[12px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
|
||||
<span>
|
||||
<span
|
||||
className={`mr-2 ${unreadCount > 0 ? "text-[var(--cm-clay)]" : "text-[var(--cm-fg)]"}`}
|
||||
>
|
||||
{unreadCount}
|
||||
</span>
|
||||
unread
|
||||
</span>
|
||||
<span>
|
||||
<span className="mr-2 text-[var(--cm-fg)]">{items.length}</span>
|
||||
{includeAll ? "shown" : "in window"}
|
||||
</span>
|
||||
<Link
|
||||
href={
|
||||
includeAll
|
||||
? pathsConfig.dashboard.user.notifications
|
||||
: `${pathsConfig.dashboard.user.notifications}?all=1`
|
||||
}
|
||||
className="text-[var(--cm-clay)] underline-offset-4 hover:underline"
|
||||
>
|
||||
{includeAll ? "unread only" : "show all"}
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</header>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-[var(--cm-fg-secondary)]">
|
||||
{includeAll
|
||||
? "No mentions in the last 7 days."
|
||||
: "Inbox zero. Nothing waiting on you."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{items.map((n, i) => (
|
||||
<Reveal key={n.id} delay={Math.min(i, 8)}>
|
||||
<li
|
||||
className={`group relative rounded-md border bg-[var(--cm-bg-elevated)] px-5 py-4 transition-colors duration-200 ${
|
||||
n.read
|
||||
? "border-[var(--cm-border-soft,rgba(217,119,87,0.1))]"
|
||||
: "border-[rgba(217,119,87,0.4)]"
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
href={pathsConfig.dashboard.user.meshes.topic(
|
||||
n.meshId,
|
||||
n.topicName,
|
||||
)}
|
||||
className="block"
|
||||
>
|
||||
<div className="mb-2 flex items-baseline gap-3 font-mono text-[11px] uppercase tracking-[0.16em]">
|
||||
{!n.read ? (
|
||||
<span className="size-[6px] rounded-full bg-[var(--cm-clay)]" />
|
||||
) : null}
|
||||
<span className="text-[var(--cm-fg-tertiary)]">
|
||||
{n.meshSlug}
|
||||
</span>
|
||||
<span className="text-[var(--cm-clay)]">#{n.topicName}</span>
|
||||
<span className="text-[var(--cm-fg-tertiary)]">
|
||||
from {n.senderName}
|
||||
</span>
|
||||
<span className="ml-auto text-[var(--cm-fg-tertiary)]">
|
||||
{formatRelative(n.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={`text-[15px] leading-[1.55] ${n.read ? "text-[var(--cm-fg-secondary)]" : "text-[var(--cm-fg)]"}`}
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{n.encrypted
|
||||
? <span className="text-[var(--cm-fg-tertiary)] italic">(encrypted — open the topic to decrypt)</span>
|
||||
: n.snippet || <span className="text-[var(--cm-fg-tertiary)] italic">(empty)</span>}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
</Reveal>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,7 @@ const pathsConfig = {
|
||||
`${DASHBOARD_PREFIX}/meshes/${id}/topics/${encodeURIComponent(name)}`,
|
||||
},
|
||||
topics: `${DASHBOARD_PREFIX}/topics`,
|
||||
notifications: `${DASHBOARD_PREFIX}/notifications`,
|
||||
invites: `${DASHBOARD_PREFIX}/invites`,
|
||||
settings: {
|
||||
index: `${DASHBOARD_PREFIX}/settings`,
|
||||
|
||||
@@ -255,11 +255,20 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code.
|
||||
matching `/dashboard/topics` page (SSR, direct DB) with a Topics
|
||||
entry in the sidebar between Meshes and Invites. *Shipped
|
||||
2026-05-03 in CLI v1.11.0.*
|
||||
- **v0.4.0 phase 3+ — `me notifications`, `me activity`, `me
|
||||
search`** — additional aggregating verbs over `/v1/me/*`
|
||||
mirroring the existing per-mesh reads. Default aggregation rule
|
||||
for existing read verbs (`notification list`, `task list`, `state
|
||||
list`, `memory recall`) when no `--mesh` is passed.
|
||||
- **v0.4.0 phase 3 — `claudemesh me notifications` + dashboard
|
||||
parity** — `GET /v1/me/notifications` aggregates @-mention rows
|
||||
across every joined mesh in a 7-day window (`?since=ISO`
|
||||
override, `?include=all` to surface already-read). CLI verb
|
||||
prints unread feed with sender + topic + snippet (or
|
||||
`[encrypted]` for v2 ciphertext). Web dashboard adds
|
||||
`/dashboard/notifications` with a "show all" toggle, matching
|
||||
the universe page's mention card aesthetic. *Shipped 2026-05-03
|
||||
in CLI v1.12.0.*
|
||||
- **v0.4.0 phase 4+ — `me activity`, `me search`** — remaining
|
||||
aggregating verbs over `/v1/me/*` mirroring the existing
|
||||
per-mesh reads. Default aggregation rule for existing read verbs
|
||||
(`task list`, `state list`, `memory recall`) when no `--mesh` is
|
||||
passed.
|
||||
- **v0.3.2 — multi-session DM routing + broadcast self-loopback** —
|
||||
fixes two production bugs: (1) replies via `claudemesh send
|
||||
<from_id>` rejected with "no connected peer" when the sender's
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user