feat(web): universe dashboard — meshes + incoming invitations
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:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user