feat(web): universe dashboard — meshes + incoming invitations
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

New /dashboard landing that surfaces meshes and invitations-to-you
in one view. Replaces the simple mesh grid at /dashboard (preserved
at /dashboard/legacy).

Backend additions:
- GET /api/my/invites/incoming — pending_invite rows addressed to
  the authed user's email, joined with invite for role + expiry and
  user/mesh for display. Unaccepted + unrevoked + unexpired only.
- DELETE /api/my/invites/incoming/:id — dismiss a pending invite
  (revokes the pending_invite row only; underlying invite code stays
  valid so the inviter can re-send).

Web additions (all under apps/web/src/modules/dashboard/universe/):
- welcome.tsx — editorial serif header with mesh + invite counts
- invitations.tsx — client card with Accept (→ /i/:code claim flow)
  and optimistic Decline
- meshes-grid.tsx — hero card + compact grid, linked to mesh detail
- reveal.tsx — fade-up motion matching marketing _reveal.tsx

Styling uses the existing claudemesh design tokens (--cm-clay,
--cm-bg-elevated, Anthropic Sans/Serif/Mono) — nothing redefined.

Onboarding redirect (0 meshes → /meshes/new?onboarding=1) preserved,
now gated on 0 invitations too so users with pending invites still
land on the dashboard.

Sidebar icon switched to Atom for the "universe" concept.

Standalone prototype saved at prototypes/live-dashboard.html for
reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-19 21:31:15 +01:00
parent 2abf86d540
commit 0664180a54
13 changed files with 1734 additions and 66 deletions

View File

@@ -164,6 +164,38 @@ export const archiveMyMesh = async ({
return updated;
};
/**
* Decline an incoming pending invite addressed to this user's email.
* Marks the pending_invite row as revoked so it no longer surfaces
* in /invites/incoming. The underlying short-code invite is NOT revoked
* (inviter may re-send), only this user's copy is dismissed.
*/
export const declineIncomingInvite = async ({
email,
pendingInviteId,
}: {
email: string;
pendingInviteId: string;
}) => {
const [updated] = await db
.update(pendingInvite)
.set({ revokedAt: new Date() })
.where(
and(
eq(pendingInvite.id, pendingInviteId),
eq(pendingInvite.email, email),
isNull(pendingInvite.acceptedAt),
isNull(pendingInvite.revokedAt),
),
)
.returning({ id: pendingInvite.id });
if (!updated) {
throw new Error("Invitation not found or already resolved.");
}
return updated;
};
export const leaveMyMesh = async ({
userId,
meshId,

View File

@@ -5,6 +5,7 @@ import {
desc,
eq,
getOrderByFromSort,
gt,
ilike,
isNull,
or,
@@ -16,7 +17,9 @@ import {
mesh,
meshMember,
messageQueue,
pendingInvite,
presence,
user,
} from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
@@ -345,6 +348,48 @@ export const getMyExport = async ({ userId }: { userId: string }) => {
};
};
/**
* Pending invitations addressed to this user's email. A pending_invite row is
* created when someone calls `claudemesh share <email>`; we join it against the
* underlying `invite` row to get role + expiry, and against `user` (inviter)
* and `mesh` (target) for display. Returned only when unaccepted, unrevoked,
* and not expired.
*/
export const getMyInvitesIncoming = async ({ email }: { email: string }) => {
const now = new Date();
return db
.select({
id: pendingInvite.id,
meshId: pendingInvite.meshId,
meshName: mesh.name,
meshSlug: mesh.slug,
code: pendingInvite.code,
role: invite.role,
expiresAt: invite.expiresAt,
sentAt: pendingInvite.sentAt,
inviterName: user.name,
inviterEmail: user.email,
memberCount: sql<number>`(
SELECT COUNT(*)::int FROM mesh.member
WHERE mesh_id = ${pendingInvite.meshId} AND revoked_at IS NULL
)`,
})
.from(pendingInvite)
.leftJoin(mesh, eq(pendingInvite.meshId, mesh.id))
.leftJoin(invite, eq(pendingInvite.code, invite.code))
.leftJoin(user, eq(pendingInvite.createdBy, user.id))
.where(
and(
eq(pendingInvite.email, email),
isNull(pendingInvite.acceptedAt),
isNull(pendingInvite.revokedAt),
or(isNull(invite.expiresAt), gt(invite.expiresAt, now)),
),
)
.orderBy(desc(pendingInvite.sentAt))
.limit(50);
};
export const getMyInvitesSent = async ({ userId }: { userId: string }) =>
db
.select({

View File

@@ -15,10 +15,12 @@ import {
createEmailInvite,
createMyInvite,
createMyMesh,
declineIncomingInvite,
leaveMyMesh,
} from "./mutations";
import {
getMyExport,
getMyInvitesIncoming,
getMyInvitesSent,
getMyMeshById,
getMyMeshStream,
@@ -150,6 +152,29 @@ export const myRouter = new Hono<Env>()
const user = c.var.user;
return c.json({ sent: await getMyInvitesSent({ userId: user.id }) });
})
.get("/invites/incoming", async (c) => {
const user = c.var.user;
if (!user.email) return c.json({ incoming: [] });
return c.json({
incoming: await getMyInvitesIncoming({ email: user.email }),
});
})
.delete("/invites/incoming/:id", async (c) => {
const user = c.var.user;
if (!user.email) return c.json({ error: "No email on session" }, 400);
try {
await declineIncomingInvite({
email: user.email,
pendingInviteId: c.req.param("id"),
});
return c.json({ ok: true });
} catch (e) {
return c.json(
{ error: e instanceof Error ? e.message : "Failed to decline." },
400,
);
}
})
.get("/export", async (c) => {
const user = c.var.user;
const data = await getMyExport({ userId: user.id });

View File

@@ -296,3 +296,24 @@ export const getMyInvitesResponseSchema = z.object({
),
});
export type GetMyInvitesResponse = z.infer<typeof getMyInvitesResponseSchema>;
export const getMyInvitesIncomingResponseSchema = z.object({
incoming: z.array(
z.object({
id: z.string(),
meshId: z.string(),
meshName: z.string().nullable(),
meshSlug: z.string().nullable(),
code: z.string(),
role: meshRoleEnum.nullable(),
expiresAt: z.coerce.date().nullable(),
sentAt: z.coerce.date(),
inviterName: z.string().nullable(),
inviterEmail: z.string().nullable(),
memberCount: z.number(),
}),
),
});
export type GetMyInvitesIncomingResponse = z.infer<
typeof getMyInvitesIncomingResponseSchema
>;