cc71516278
- POST /api/email/invite: sends the invite email via the Twilio Emails API using server-only TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN (Vercel env), from EMAIL_FROM_ADDRESS (default support@lynkedup.dev). Fixed invite template; gated on a session cookie so it isn't an open relay. - Team Management emails the invite on create and resend (token now returned by both), with the Copy-link as a fallback. Mock mode skips email.
216 lines
11 KiB
TypeScript
216 lines
11 KiB
TypeScript
"use client";
|
|
|
|
// Team-management data layer. Serves EITHER the local mock (when the Shell isn't
|
|
// configured — the polished demo keeps working) OR the live be-crm data door
|
|
// (crm.team.*), behind one interface so the component is mode-agnostic.
|
|
//
|
|
// Live contract (be-crm, multi-role):
|
|
// query crm.team.member.search { perPage } -> { items: MemberDTO[], meta }
|
|
// query crm.team.role.list { includeEmpty } -> { items: RoleDTO[], meta }
|
|
// query crm.team.invitation.list { status } -> { items: InvitationDTO[], meta }
|
|
// cmd crm.team.member.setRoles { id, roleIds }
|
|
// cmd crm.team.member.update { id, jobTitle?, roleIds? }
|
|
// cmd crm.team.member.remove { id }
|
|
// cmd crm.team.invitation.create/resend/revoke
|
|
// cmd crm.team.role.addPermission/removePermission { id, permissionId }
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
|
|
import { isShellConfigured } from "./appshell";
|
|
import {
|
|
members as seedMembers, roles as seedRoles, pendingInvites as seedInvites,
|
|
type Member as SeedMember, type Role as SeedRole, type Invite as SeedInvite,
|
|
} from "@/components/dashboard/team-data";
|
|
|
|
/* ---- UI-facing shapes (multi-role; superset of the mock) ---------------- */
|
|
|
|
export type UiStatus = "active" | "away" | "offline";
|
|
|
|
export interface UiRole {
|
|
id: string; slug: string; name: string; color: string;
|
|
description: string; system: boolean; isOwner: boolean;
|
|
perms: string[]; attire: string[]; overlap: string; memberCount?: number;
|
|
}
|
|
export interface UiMember {
|
|
id: string; principalId?: string; name: string; initials: string; email: string;
|
|
title: string; roleIds: string[]; gradient: string; status: UiStatus;
|
|
lastActive: string; deals: number; joined: string; isOwner: boolean; isYou: boolean;
|
|
}
|
|
export interface UiInvite {
|
|
id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string;
|
|
/** Accept token for the invite link (pending invites only; no email delivery yet). */
|
|
token?: string;
|
|
}
|
|
|
|
export interface TeamData {
|
|
live: boolean; loading: boolean; error: string | null;
|
|
members: UiMember[]; roles: UiRole[]; invites: UiInvite[];
|
|
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
|
|
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
|
|
removeMember: (id: string) => Promise<void>;
|
|
/** Create an invite; resolves with the accept token so the caller can email the link. */
|
|
invite: (email: string, roleIds: string[]) => Promise<{ token?: string }>;
|
|
/** Resend an invite (regenerates the token); resolves with the fresh token. */
|
|
resendInvite: (id: string) => Promise<{ token?: string }>;
|
|
revokeInvite: (id: string) => Promise<void>;
|
|
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
|
|
refetch: () => void;
|
|
}
|
|
|
|
/* ---- helpers ------------------------------------------------------------ */
|
|
|
|
const GRADIENTS = [
|
|
"linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#6366f1,#8b5cf6)",
|
|
"linear-gradient(135deg,#06b6d4,#3b82f6)", "linear-gradient(135deg,#10b981,#059669)",
|
|
"linear-gradient(135deg,#f43f5e,#ec4899)", "linear-gradient(135deg,#f59e0b,#ef4444)",
|
|
"linear-gradient(135deg,#8b5cf6,#d946ef)", "linear-gradient(135deg,#14b8a6,#0ea5e9)",
|
|
];
|
|
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
|
|
const initialsOf = (name: string) =>
|
|
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
|
|
|
/* ---- be-crm DTO types (subset used here) -------------------------------- */
|
|
|
|
interface RoleRef { id: string; slug: string; name: string; color: string | null; isSystem: boolean; isOwnerRole: boolean }
|
|
interface MemberDTO { id: string; principalId: string; jobTitle: string | null; joinedAt: string; status: "active" | "deactivated"; roles: RoleRef[]; openDeals: number }
|
|
interface RoleDTO { id: string; slug: string; name: string; description: string | null; color: string | null; isSystem: boolean; isOwnerRole: boolean; permissions: string[]; memberCount: number }
|
|
interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string; token?: string }
|
|
|
|
const relTime = (iso: string): string => {
|
|
const then = Date.parse(iso);
|
|
if (Number.isNaN(then)) return iso;
|
|
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
};
|
|
|
|
/* ---- mock → UI ---------------------------------------------------------- */
|
|
|
|
const mockRole = (r: SeedRole): UiRole => ({
|
|
id: r.id, slug: r.id, name: r.name, color: r.color, description: r.description,
|
|
system: r.system, isOwner: r.id === "owner", perms: r.perms, attire: r.attire, overlap: r.overlap,
|
|
});
|
|
const mockMember = (m: SeedMember): UiMember => ({
|
|
id: m.id, name: m.name, initials: m.initials, email: m.email, title: m.title,
|
|
roleIds: [m.roleId], gradient: m.gradient, status: m.status, lastActive: m.lastActive,
|
|
deals: m.deals, joined: m.joined, isOwner: m.roleId === "owner", isYou: m.roleId === "owner",
|
|
});
|
|
const mockInvite = (i: SeedInvite): UiInvite => ({
|
|
id: i.id, email: i.email, roleIds: [i.roleId], invitedBy: i.invitedBy, sentAt: i.sentAt,
|
|
});
|
|
|
|
/* ======================================================================== */
|
|
/* Mock implementation (no Shell configured) */
|
|
/* ======================================================================== */
|
|
|
|
function useMockTeam(): TeamData {
|
|
const [members, setMembers] = useState<UiMember[]>(() => seedMembers.map(mockMember));
|
|
const [roles, setRoles] = useState<UiRole[]>(() => seedRoles.map(mockRole));
|
|
const [invites, setInvites] = useState<UiInvite[]>(() => seedInvites.map(mockInvite));
|
|
|
|
const setMemberRoles = useCallback(async (id: string, roleIds: string[]) => {
|
|
setMembers((l) => l.map((m) => (m.id === id ? { ...m, roleIds } : m)));
|
|
}, []);
|
|
const updateMember = useCallback(async (id: string, patch: { title?: string; roleIds?: string[] }) => {
|
|
setMembers((l) => l.map((m) => (m.id === id
|
|
? { ...m, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}) }
|
|
: m)));
|
|
}, []);
|
|
const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []);
|
|
const invite = useCallback(async (email: string, roleIds: string[]) => {
|
|
setInvites((l) => [{ id: `inv_${Date.now()}`, email, roleIds, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
|
|
return {};
|
|
}, []);
|
|
const resendInvite = useCallback(async () => ({}), []);
|
|
const revokeInvite = useCallback(async (id: string) => { setInvites((l) => l.filter((i) => i.id !== id)); }, []);
|
|
const setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => {
|
|
setRoles((l) => l.map((r) => (r.id !== roleId ? r : {
|
|
...r, perms: granted ? [...new Set([...r.perms, permId])] : r.perms.filter((p) => p !== permId),
|
|
})));
|
|
}, []);
|
|
|
|
return { live: false, loading: false, error: null, members, roles, invites,
|
|
setMemberRoles, updateMember, removeMember, invite, resendInvite, revokeInvite, setPermission, refetch: () => {} };
|
|
}
|
|
|
|
/* ======================================================================== */
|
|
/* Live implementation (be-crm data door) */
|
|
/* ======================================================================== */
|
|
|
|
function useLiveTeam(): TeamData {
|
|
const { sdk } = useAppShell();
|
|
const { user } = useAuth();
|
|
const meId = user?.id;
|
|
|
|
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
|
|
const rolesQ = useQuery<{ items: RoleDTO[] }>("crm.team.role.list", { includeEmpty: true });
|
|
const invitesQ = useQuery<{ items: InvitationDTO[] }>("crm.team.invitation.list", { status: "pending" });
|
|
|
|
const refetch = useCallback(() => { membersQ.refetch(); rolesQ.refetch(); invitesQ.refetch(); }, [membersQ, rolesQ, invitesQ]);
|
|
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
|
|
await sdk.command(action, variables);
|
|
refetch();
|
|
}, [sdk, refetch]);
|
|
|
|
const roles: UiRole[] = useMemo(() => (rolesQ.data?.items ?? []).map((r) => ({
|
|
id: r.id, slug: r.slug, name: r.name, color: r.color ?? "var(--text-2)",
|
|
description: r.description ?? "", system: r.isSystem, isOwner: r.isOwnerRole,
|
|
perms: r.permissions, attire: [], overlap: "", memberCount: r.memberCount,
|
|
})), [rolesQ.data]);
|
|
|
|
const members: UiMember[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
|
|
const isYou = !!meId && m.principalId === meId;
|
|
const name = isYou && user?.displayName
|
|
? user.displayName
|
|
: (m.jobTitle?.trim() || `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`);
|
|
return {
|
|
id: m.id, principalId: m.principalId, name, initials: initialsOf(name),
|
|
email: isYou && user?.email ? user.email : m.principalId,
|
|
title: m.jobTitle ?? "", roleIds: m.roles.map((r) => r.id), gradient: gradientFor(m.id),
|
|
status: (m.status === "active" ? "active" : "offline") as UiStatus,
|
|
lastActive: m.status === "active" ? "Active" : "—",
|
|
deals: m.openDeals, joined: relTime(m.joinedAt),
|
|
isOwner: m.roles.some((r) => r.isOwnerRole), isYou,
|
|
};
|
|
}), [membersQ.data, meId, user]);
|
|
|
|
const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({
|
|
id: i.id, email: i.email, roleIds: i.roles.map((r) => r.id),
|
|
invitedBy: i.invitedBy, sentAt: relTime(i.createdAt), token: i.token,
|
|
})), [invitesQ.data]);
|
|
|
|
return {
|
|
live: true,
|
|
loading: membersQ.loading || rolesQ.loading || invitesQ.loading,
|
|
error: (membersQ.error ?? rolesQ.error ?? invitesQ.error)?.message ?? null,
|
|
members, roles, invites,
|
|
setMemberRoles: (id, roleIds) => cmd("crm.team.member.setRoles", { id, roleIds }),
|
|
updateMember: (id, patch) => cmd("crm.team.member.update", {
|
|
id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}),
|
|
}),
|
|
removeMember: (id) => cmd("crm.team.member.remove", { id }),
|
|
invite: async (email, roleIds) => {
|
|
const dto = (await sdk.command("crm.team.invitation.create", { email, roleIds })) as { token?: string } | undefined;
|
|
refetch();
|
|
return { token: dto?.token };
|
|
},
|
|
resendInvite: async (id) => {
|
|
const res = (await sdk.command("crm.team.invitation.resend", { id })) as { token?: string } | undefined;
|
|
refetch();
|
|
return { token: res?.token };
|
|
},
|
|
revokeInvite: (id) => cmd("crm.team.invitation.revoke", { id }),
|
|
setPermission: (roleId, permId, granted) =>
|
|
cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }),
|
|
refetch,
|
|
};
|
|
}
|
|
|
|
/* ---- public hook: pick the implementation at module-config time --------- */
|
|
|
|
const SHELL = isShellConfigured();
|
|
|
|
export function useTeamData(): TeamData {
|
|
// `SHELL` is constant for the life of the bundle (NEXT_PUBLIC_* is build-time),
|
|
// so the same hook path runs every render — Rules-of-Hooks safe.
|
|
return SHELL ? useLiveTeam() : useMockTeam();
|
|
}
|