History

Unify workspace membership model and tighten core types

Rhys Sullivan committed 9 months ago

Commit d03cf181 parent f0d800111 files · +125 −141Browse files

Unified · Split

11 files
  1. executor/apps/web/src/components/members-view.tsx +17 −16
  2. executor/apps/web/src/lib/session-context.tsx +25 −25
  3. executor/convex/auth.ts +16 −17
  4. executor/convex/database.ts +12 −13
  5. executor/convex/invites.ts +12 −11
  6. executor/convex/lib/identity.ts +0 −8
  7. executor/convex/organizationMembers.ts +8 −1
  8. executor/convex/organizations.ts +5 −7
  9. executor/convex/schema.ts +30 −40
  10. executor/convex/workspaces.ts +0 −2
  11. executor/packages/contracts/src/index.ts +0 −1
executor/apps/web/src/components/members-view.tsx+17−16
@@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; · 8 unmodified lines
9import { Input } from "@/components/ui/input";9import { Input } from "@/components/ui/input";
10import { useSession } from "@/lib/session-context";10import { useSession } from "@/lib/session-context";
11import { convexApi } from "@/lib/convex-api";11import { convexApi } from "@/lib/convex-api";
12import type { Id } from "../../../../convex/_generated/dataModel";
12 13
13type Role = "owner" | "admin" | "member" | "billing_admin";14type Role = "owner" | "admin" | "member" | "billing_admin";
14 15
@@ -52,14 +53,14 @@ export function MembersView() { · 37 unmodified lines
52 const [inviteRole, setInviteRole] = useState<Role>("member");53 const [inviteRole, setInviteRole] = useState<Role>("member");
53 const [inviteState, setInviteState] = useState<"idle" | "sending" | "sent" | "failed">("idle");54 const [inviteState, setInviteState] = useState<"idle" | "sending" | "sent" | "failed">("idle");
54 const [inviteMessage, setInviteMessage] = useState<string | null>(null);55 const [inviteMessage, setInviteMessage] = useState<string | null>(null);
55 const [busyMemberAccountId, setBusyMemberAccountId] = useState<string | null>(null);56 const [busyMemberAccountId, setBusyMemberAccountId] = useState<Id<"accounts"> | null>(null);
56 const [busyInviteId, setBusyInviteId] = useState<string | null>(null);57 const [busyInviteId, setBusyInviteId] = useState<Id<"invites"> | null>(null);
57 58
58 const memberItems = members?.items ?? [];59 const memberItems = members?.items ?? [];
59 const inviteItems = listInvites?.items ?? [];60 const inviteItems = listInvites?.items ?? [];
60 const pendingInviteItems = inviteItems.filter((invite) => invite.status === "pending" || invite.status === "failed");61 const pendingInviteItems = inviteItems.filter((invite) => invite.status === "pending" || invite.status === "failed");
61 const actorMembership = memberItems.find((member) =>62 const actorMembership = memberItems.find((member) =>
62 context?.accountId ? String(member.accountId) === context.accountId : false,63 context?.accountId ? member.accountId === context.accountId : false,
63 );64 );
64 const actorRole = actorMembership?.role ?? null;65 const actorRole = actorMembership?.role ?? null;
65 const canManageMembers = actorRole === "owner" || actorRole === "admin";66 const canManageMembers = actorRole === "owner" || actorRole === "admin";
@@ -276,20 +277,20 @@ export function MembersView() { · 210 unmodified lines
276 <p className="text-xs text-muted-foreground">Role: {invite.role}</p>277 <p className="text-xs text-muted-foreground">Role: {invite.role}</p>
277 <p className="text-xs text-muted-foreground">Status: {invite.status}</p>278 <p className="text-xs text-muted-foreground">Status: {invite.status}</p>
278 </div>279 </div>
279 <Button280 <Button
280 variant="outline"281 variant="outline"
281 size="sm"282 size="sm"
282 className="h-8 text-xs"283 className="h-8 text-xs"
283 disabled={!canManageMembers || busyInviteId === String(invite.id)}284 disabled={!canManageMembers || busyInviteId === invite.id}
284 onClick={async () => {285 onClick={async () => {
285 if (!typedOrganizationId) {286 if (!typedOrganizationId) {
286 return;287 return;
287 }288 }
288 289
289 setBusyInviteId(String(invite.id));290 setBusyInviteId(invite.id);
290 try {291 try {
291 await revokeInvite({292 await revokeInvite({
292 organizationId: typedOrganizationId,293 organizationId: typedOrganizationId,
293 inviteId: invite.id,294 inviteId: invite.id,
294 sessionId: context?.sessionId ?? undefined,295 sessionId: context?.sessionId ?? undefined,
295 });296 });
executor/apps/web/src/lib/session-context.tsx+25−25
@@ -30,20 +30,20 @@ interface SessionState { · 29 unmodified lines
30 } | null;30 } | null;
31 mode: "guest" | "workos";31 mode: "guest" | "workos";
32 organizations: Array<{32 organizations: Array<{
33 id: string;33 id: Id<"organizations">;
34 name: string;34 name: string;
35 slug: string;35 slug: string;
36 status: string;36 status: string;
37 role: string;37 role: string;
38 }>;38 }>;
39 workspaces: Array<{39 workspaces: Array<{
40 id: string;40 id: Id<"workspaces">;
41 docId: Id<"workspaces"> | null;41 docId: Id<"workspaces"> | null;
42 name: string;42 name: string;
43 organizationId: Id<"organizations"> | null;43 organizationId: Id<"organizations"> | null;
44 iconUrl?: string | null;44 iconUrl?: string | null;
45 }>;45 }>;
46 switchWorkspace: (workspaceId: string) => void;46 switchWorkspace: (workspaceId: Id<"workspaces">) => void;
47 creatingWorkspace: boolean;47 creatingWorkspace: boolean;
48 createWorkspace: (name: string, iconFile?: File | null) => Promise<void>;48 createWorkspace: (name: string, iconFile?: File | null) => Promise<void>;
49 isSignedInToWorkos: boolean;49 isSignedInToWorkos: boolean;
@@ -77,16 +77,16 @@ const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account"; · 27 unmodified lines
77 77
78function readWorkspaceByAccount() {78function readWorkspaceByAccount() {
79 const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY);79 const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY);
80 if (!raw) return {} as Record<string, string>;80 if (!raw) return {} as Record<string, Id<"workspaces">>;
81 try {81 try {
82 const parsed = JSON.parse(raw) as Record<string, string>;82 const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>;
83 return parsed ?? {};83 return parsed ?? {};
84 } catch {84 } catch {
85 return {};85 return {};
86 }86 }
87}87}
88 88
89function writeWorkspaceByAccount(value: Record<string, string>) {89function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) {
90 localStorage.setItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY, JSON.stringify(value));90 localStorage.setItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY, JSON.stringify(value));
91}91}
92 92
@@ -98,11 +98,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 5 unmodified lines
98 }98 }
99 return localStorage.getItem(SESSION_KEY);99 return localStorage.getItem(SESSION_KEY);
100 });100 });
101 const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(() => {101 const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>(() => {
102 if (typeof window === "undefined") {102 if (typeof window === "undefined") {
103 return null;103 return null;
104 }104 }
105 return localStorage.getItem(ACTIVE_WORKSPACE_KEY);105 const stored = localStorage.getItem(ACTIVE_WORKSPACE_KEY);
106 return stored as Id<"workspaces"> | null;
106 });107 });
107 const [runtimeError, setRuntimeError] = useState<string | null>(null);108 const [runtimeError, setRuntimeError] = useState<string | null>(null);
108 109
@@ -148,17 +149,17 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 39 unmodified lines
148 return activeWorkspaceId;149 return activeWorkspaceId;
149 }150 }
150 151
151 if (activeWorkspaceId && workspaces.some((workspace) => workspace.runtimeWorkspaceId === activeWorkspaceId)) {152 if (activeWorkspaceId && workspaces.some((workspace) => workspace._id === activeWorkspaceId)) {
152 return activeWorkspaceId;153 return activeWorkspaceId;
153 }154 }
154 155
155 const accountId = account?.provider === "workos" ? String(account._id) : null;156 const accountId = account?.provider === "workos" ? account._id : null;
156 const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null;157 const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null;
157 if (accountStoredWorkspace && workspaces.some((workspace) => workspace.runtimeWorkspaceId === accountStoredWorkspace)) {158 if (accountStoredWorkspace && workspaces.some((workspace) => workspace._id === accountStoredWorkspace)) {
158 return accountStoredWorkspace;159 return accountStoredWorkspace;
159 }160 }
160 161
161 return workspaces[0]?.runtimeWorkspaceId ?? null;162 return workspaces[0]?._id ?? null;
162 }, [workspaces, activeWorkspaceId, account]);163 }, [workspaces, activeWorkspaceId, account]);
163 164
164 const bootstrapWorkosAccountQuery = useTanstackQuery({165 const bootstrapWorkosAccountQuery = useTanstackQuery({
@@ -177,12 +178,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 12 unmodified lines
177 setRuntimeError(null);178 setRuntimeError(null);
178 }, []);179 }, []);
179 180
180 const switchWorkspace = useCallback((workspaceId: string) => {181 const switchWorkspace = useCallback((workspaceId: Id<"workspaces">) => {
181 setActiveWorkspaceId(workspaceId);182 setActiveWorkspaceId(workspaceId);
182 localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId);183 localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId);
183 184
184 if (account?.provider === "workos") {185 if (account?.provider === "workos") {
185 const accountId = String(account._id);186 const accountId = account._id;
186 const byAccount = readWorkspaceByAccount();187 const byAccount = readWorkspaceByAccount();
187 writeWorkspaceByAccount({188 writeWorkspaceByAccount({
188 ...byAccount,189 ...byAccount,
@@ -227,8 +228,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 38 unmodified lines
227 sessionId: storedSessionId ?? undefined,228 sessionId: storedSessionId ?? undefined,
228 });229 });
229 230
230 if (created?.runtimeWorkspaceId) {231 if (created?._id) {
231 switchWorkspace(created.runtimeWorkspaceId);232 switchWorkspace(created._id);
232 }233 }
233 } catch (cause) {234 } catch (cause) {
234 const message = cause instanceof Error ? cause.message : "Failed to create workspace";235 const message = cause instanceof Error ? cause.message : "Failed to create workspace";
@@ -250,7 +251,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 15 unmodified lines
250 }251 }
251 252
252 const activeWorkspace =253 const activeWorkspace =
253 workspaces.find((workspace) => workspace.runtimeWorkspaceId === resolvedActiveWorkspaceId)254 workspaces.find((workspace) => workspace._id === resolvedActiveWorkspaceId)
254 ?? workspaces[0]255 ?? workspaces[0]
255 ?? null;256 ?? null;
256 if (!activeWorkspace) {257 if (!activeWorkspace) {
@@ -258,13 +259,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 1 unmodified line
258 }259 }
259 260
260 return {261 return {
261 sessionId: `workos_${String(account._id)}`,262 sessionId: `workos_${account._id}`,
262 workspaceId: activeWorkspace.runtimeWorkspaceId,263 workspaceId: activeWorkspace._id,
263 actorId: String(activeWorkspace.userId),264 actorId: activeWorkspace.userId,
264 clientId: "web",265 clientId: "web",
265 accountId: String(account._id),266 accountId: account._id,
266 workspaceDocId: String(activeWorkspace._id),267 userId: activeWorkspace.userId,
267 userId: String(activeWorkspace.userId),
268 createdAt: Date.now(),268 createdAt: Date.now(),
269 lastSeenAt: Date.now(),269 lastSeenAt: Date.now(),
270 };270 };
@@ -301,7 +301,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 30 unmodified lines
301 const workspaceOptions = useMemo(() => {301 const workspaceOptions = useMemo(() => {
302 if (mode === "workos" && workspaces) {302 if (mode === "workos" && workspaces) {
303 return workspaces.map((workspace): SessionState["workspaces"][number] => ({303 return workspaces.map((workspace): SessionState["workspaces"][number] => ({
304 id: workspace.runtimeWorkspaceId,304 id: workspace._id,
305 docId: workspace._id,305 docId: workspace._id,
306 name: workspace.name,306 name: workspace.name,
307 organizationId: workspace.organizationId ?? null,307 organizationId: workspace.organizationId ?? null,
@@ -312,7 +312,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 4 unmodified lines
312 if (guestContext) {312 if (guestContext) {
313 return [313 return [
314 {314 {
315 id: guestContext.workspaceId,315 id: guestContext.workspaceId as Id<"workspaces">,
316 docId: null,316 docId: null,
317 name: "Guest Workspace",317 name: "Guest Workspace",
318 organizationId: null,318 organizationId: null,
executor/convex/auth.ts+16−17
@@ -9,6 +9,8 @@ import { ensureUniqueSlug } from "./lib/slug"; · 8 unmodified lines
9type DbCtx = Pick<MutationCtx, "db">;9type DbCtx = Pick<MutationCtx, "db">;
10type RunQueryCtx = Pick<MutationCtx, "runQuery">;10type RunQueryCtx = Pick<MutationCtx, "runQuery">;
11type WorkosEventCtx = Pick<MutationCtx, "db" | "runQuery">;11type WorkosEventCtx = Pick<MutationCtx, "db" | "runQuery">;
12type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
13type OrganizationMemberStatus = "active" | "pending" | "removed";
12 14
13const workosEnabled = Boolean(15const workosEnabled = Boolean(
14 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,16 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,
@@ -81,8 +83,8 @@ async function upsertOrganizationMembership( · 66 unmodified lines
81 args: {83 args: {
82 organizationId: Id<"organizations">;84 organizationId: Id<"organizations">;
83 accountId: Id<"accounts">;85 accountId: Id<"accounts">;
84 role: string;86 role: OrganizationRole;
85 status: string;87 status: OrganizationMemberStatus;
86 billable: boolean;88 billable: boolean;
87 invitedByAccountId?: Id<"accounts">;89 invitedByAccountId?: Id<"accounts">;
88 now: number;90 now: number;
@@ -153,7 +155,7 @@ async function ensurePersonalWorkspace( · 64 unmodified lines
153 opts: { email: string; firstName?: string; workosUserId: string; now: number; workspaceName?: string },155 opts: { email: string; firstName?: string; workosUserId: string; now: number; workspaceName?: string },
154) {156) {
155 const memberships = await ctx.db157 const memberships = await ctx.db
156 .query("users")158 .query("workspaceMembers")
157 .withIndex("by_account", (q) => q.eq("accountId", accountId))159 .withIndex("by_account", (q) => q.eq("accountId", accountId))
158 .collect();160 .collect();
159 161
@@ -204,7 +206,7 @@ async function ensurePersonalWorkspace( · 44 unmodified lines
204 now: opts.now,206 now: opts.now,
205 });207 });
206 208
207 const userId = await ctx.db.insert("users", {209 const userId = await ctx.db.insert("workspaceMembers", {
208 workspaceId,210 workspaceId,
209 accountId,211 accountId,
210 role: "owner",212 role: "owner",
@@ -305,7 +307,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 94 unmodified lines
305 if (!account) return;307 if (!account) return;
306 308
307 const memberships = await ctx.db309 const memberships = await ctx.db
308 .query("users")310 .query("workspaceMembers")
309 .withIndex("by_account", (q) => q.eq("accountId", account._id))311 .withIndex("by_account", (q) => q.eq("accountId", account._id))
310 .collect();312 .collect();
311 for (const membership of memberships) {313 for (const membership of memberships) {
@@ -394,7 +396,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 82 unmodified lines
394 if (!workspace) return;396 if (!workspace) return;
395 397
396 const members = await ctx.db398 const members = await ctx.db
397 .query("users")399 .query("workspaceMembers")
398 .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))400 .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
399 .collect();401 .collect();
400 for (const member of members) {402 for (const member of members) {
@@ -428,7 +430,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 27 unmodified lines
428 const organizationId = workspace.organizationId;430 const organizationId = workspace.organizationId;
429 431
430 const existing = await ctx.db432 const existing = await ctx.db
431 .query("users")433 .query("workspaceMembers")
432 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))434 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
433 .unique();435 .unique();
434 436
@@ -463,7 +465,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 28 unmodified lines
463 return;465 return;
464 }466 }
465 467
466 await ctx.db.insert("users", {468 await ctx.db.insert("workspaceMembers", {
467 workspaceId: workspace._id,469 workspaceId: workspace._id,
468 accountId: account._id,470 accountId: account._id,
469 workosOrgMembershipId: event.data.id,471 workosOrgMembershipId: event.data.id,
@@ -487,7 +489,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 17 unmodified lines
487 };489 };
488 490
489 let membership = await ctx.db491 let membership = await ctx.db
490 .query("users")492 .query("workspaceMembers")
491 .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id))493 .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id))
492 .unique();494 .unique();
493 495
@@ -506,7 +508,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 12 unmodified lines
506 const workspaceId = workspace._id;508 const workspaceId = workspace._id;
507 const accountId = account._id;509 const accountId = account._id;
508 membership = await ctx.db510 membership = await ctx.db
509 .query("users")511 .query("workspaceMembers")
510 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))512 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))
511 .unique();513 .unique();
512 if (!membership) return;514 if (!membership) return;
@@ -551,7 +553,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 38 unmodified lines
551 "organization_membership.deleted": async (ctx, event) => {553 "organization_membership.deleted": async (ctx, event) => {
552 const now = Date.now();554 const now = Date.now();
553 const membership = await ctx.db555 const membership = await ctx.db
554 .query("users")556 .query("workspaceMembers")
555 .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id))557 .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id))
556 .unique();558 .unique();
557 if (!membership) return;559 if (!membership) return;
@@ -723,7 +725,7 @@ export const createWorkspace = mutation({ · 165 unmodified lines
723 now,725 now,
724 });726 });
725 727
726 const userId = await ctx.db.insert("users", {728 const userId = await ctx.db.insert("workspaceMembers", {
727 workspaceId,729 workspaceId,
728 accountId: account._id,730 accountId: account._id,
729 role: "owner",731 role: "owner",
@@ -750,7 +752,6 @@ export const createWorkspace = mutation({ · 20 unmodified lines
750 userId: user._id,752 userId: user._id,
751 role: user.role,753 role: user.role,
752 status: user.status,754 status: user.status,
753 runtimeWorkspaceId: workspace._id,
754 };755 };
755 },756 },
756});757});
@@ -804,7 +805,7 @@ export const getMyWorkspaces = query({ · 47 unmodified lines
804 if (!account) return [];805 if (!account) return [];
805 806
806 const memberships = await ctx.db807 const memberships = await ctx.db
807 .query("users")808 .query("workspaceMembers")
808 .withIndex("by_account", (q) => q.eq("accountId", account._id))809 .withIndex("by_account", (q) => q.eq("accountId", account._id))
809 .collect();810 .collect();
810 811
@@ -823,7 +824,6 @@ export const getMyWorkspaces = query({ · 12 unmodified lines
823 userId: membership._id,824 userId: membership._id,
824 role: membership.role,825 role: membership.role,
825 status: membership.status,826 status: membership.status,
826 runtimeWorkspaceId: workspace._id,
827 };827 };
828 }),828 }),
829 );829 );
@@ -839,7 +839,7 @@ export const getMyAccountsWithWorkspaces = query({ · 9 unmodified lines
839 if (!account) return [];839 if (!account) return [];
840 840
841 const memberships = await ctx.db841 const memberships = await ctx.db
842 .query("users")842 .query("workspaceMembers")
843 .withIndex("by_account", (q) => q.eq("accountId", account._id))843 .withIndex("by_account", (q) => q.eq("accountId", account._id))
844 .collect();844 .collect();
845 845
@@ -858,7 +858,6 @@ export const getMyAccountsWithWorkspaces = query({ · 12 unmodified lines
858 userId: membership._id,858 userId: membership._id,
859 role: membership.role,859 role: membership.role,
860 status: membership.status,860 status: membership.status,
861 runtimeWorkspaceId: workspace._id,
862 };861 };
863 }),862 }),
864 );863 );
executor/convex/database.ts+12−13
@@ -5,6 +5,8 @@ import { mutation, query } from "./_generated/server"; · 4 unmodified lines
5import { ensureUniqueSlug } from "./lib/slug";5import { ensureUniqueSlug } from "./lib/slug";
6 6
7const DEFAULT_TIMEOUT_MS = 15_000;7const DEFAULT_TIMEOUT_MS = 15_000;
8type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
9type OrganizationMemberStatus = "active" | "pending" | "removed";
8 10
9function normalizeOptional(value?: string): string {11function normalizeOptional(value?: string): string {
10 if (typeof value !== "string") {12 if (typeof value !== "string") {
@@ -45,8 +47,8 @@ async function upsertOrganizationMembership( · 34 unmodified lines
45 args: {47 args: {
46 organizationId: Doc<"organizations">["_id"];48 organizationId: Doc<"organizations">["_id"];
47 accountId: Doc<"accounts">["_id"];49 accountId: Doc<"accounts">["_id"];
48 role: string;50 role: OrganizationRole;
49 status: string;51 status: OrganizationMemberStatus;
50 billable: boolean;52 billable: boolean;
51 now: number;53 now: number;
52 },54 },
@@ -168,7 +170,7 @@ async function ensureAnonymousIdentity( · 115 unmodified lines
168 ctx: MutationCtx,170 ctx: MutationCtx,
169 params: {171 params: {
170 sessionId: string;172 sessionId: string;
171 workspaceDocId?: Doc<"workspaces">["_id"];173 workspaceId?: Doc<"workspaces">["_id"];
172 actorId: string;174 actorId: string;
173 timestamp: number;175 timestamp: number;
174 },176 },
@@ -199,7 +201,7 @@ async function ensureAnonymousIdentity( · 24 unmodified lines
199 await ctx.db.patch(account._id, { updatedAt: now, lastLoginAt: now });201 await ctx.db.patch(account._id, { updatedAt: now, lastLoginAt: now });
200 }202 }
201 203
202 let workspace = params.workspaceDocId ? await ctx.db.get(params.workspaceDocId) : null;204 let workspace = params.workspaceId ? await ctx.db.get(params.workspaceId) : null;
203 205
204 let organizationId: Doc<"organizations">["_id"];206 let organizationId: Doc<"organizations">["_id"];
205 207
@@ -241,12 +243,12 @@ async function ensureAnonymousIdentity( · 35 unmodified lines
241 });243 });
242 244
243 let user = await ctx.db245 let user = await ctx.db
244 .query("users")246 .query("workspaceMembers")
245 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))247 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
246 .unique();248 .unique();
247 249
248 if (!user) {250 if (!user) {
249 const userId = await ctx.db.insert("users", {251 const userId = await ctx.db.insert("workspaceMembers", {
250 workspaceId: workspace._id,252 workspaceId: workspace._id,
251 accountId: account._id,253 accountId: account._id,
252 role: "owner",254 role: "owner",
@@ -264,7 +266,7 @@ async function ensureAnonymousIdentity( · 11 unmodified lines
264 266
265 return {267 return {
266 accountId: account._id,268 accountId: account._id,
267 workspaceDocId: workspace._id,269 workspaceId: workspace._id,
268 userId: user._id,270 userId: user._id,
269 };271 };
270}272}
@@ -276,7 +278,6 @@ function mapAnonymousContext(doc: Doc<"anonymousSessions">) { · 5 unmodified lines
276 actorId: doc.actorId,278 actorId: doc.actorId,
277 clientId: doc.clientId,279 clientId: doc.clientId,
278 accountId: doc.accountId,280 accountId: doc.accountId,
279 workspaceDocId: doc.workspaceDocId,
280 userId: doc.userId,281 userId: doc.userId,
281 createdAt: doc.createdAt,282 createdAt: doc.createdAt,
282 lastSeenAt: doc.lastSeenAt,283 lastSeenAt: doc.lastSeenAt,
@@ -708,15 +709,14 @@ export const bootstrapAnonymousSession = mutation({ · 425 unmodified lines
708 if (existing) {709 if (existing) {
709 const identity = await ensureAnonymousIdentity(ctx, {710 const identity = await ensureAnonymousIdentity(ctx, {
710 sessionId,711 sessionId,
711 workspaceDocId: existing.workspaceDocId,712 workspaceId: existing.workspaceId,
712 actorId: existing.actorId,713 actorId: existing.actorId,
713 timestamp: now,714 timestamp: now,
714 });715 });
715 716
716 await ctx.db.patch(existing._id, {717 await ctx.db.patch(existing._id, {
717 workspaceId: identity.workspaceDocId,718 workspaceId: identity.workspaceId,
718 accountId: identity.accountId,719 accountId: identity.accountId,
719 workspaceDocId: identity.workspaceDocId,
720 userId: identity.userId,720 userId: identity.userId,
721 lastSeenAt: now,721 lastSeenAt: now,
722 });722 });
@@ -744,11 +744,10 @@ export const bootstrapAnonymousSession = mutation({ · 21 unmodified lines
744 744
745 await ctx.db.insert("anonymousSessions", {745 await ctx.db.insert("anonymousSessions", {
746 sessionId,746 sessionId,
747 workspaceId: identity.workspaceDocId,747 workspaceId: identity.workspaceId,
748 actorId,748 actorId,
749 clientId,749 clientId,
750 accountId: identity.accountId,750 accountId: identity.accountId,
751 workspaceDocId: identity.workspaceDocId,
752 userId: identity.userId,751 userId: identity.userId,
753 createdAt: now,752 createdAt: now,
754 lastSeenAt: now,753 lastSeenAt: now,
executor/convex/invites.ts+12−11
@@ -7,6 +7,14 @@ import { authedMutation, organizationMutation, organizationQuery } from "./lib/f · 6 unmodified lines
7 7
8const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);8const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);
9const workosClient = process.env.WORKOS_API_KEY ? new WorkOS(process.env.WORKOS_API_KEY) : null;9const workosClient = process.env.WORKOS_API_KEY ? new WorkOS(process.env.WORKOS_API_KEY) : null;
10type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
11
12const organizationRoleValidator = v.union(
13 v.literal("owner"),
14 v.literal("admin"),
15 v.literal("member"),
16 v.literal("billing_admin"),
17);
10 18
11async function sha256Hex(value: string): Promise<string> {19async function sha256Hex(value: string): Promise<string> {
12 const bytes = new TextEncoder().encode(value);20 const bytes = new TextEncoder().encode(value);
@@ -65,7 +73,7 @@ async function revokeWorkosInvitation(invitationId: string): Promise<void> { · 52 unmodified lines
65 await workos.userManagement.revokeInvitation(invitationId);73 await workos.userManagement.revokeInvitation(invitationId);
66}74}
67 75
68function mapRoleToWorkosRoleSlug(role: string): string | undefined {76function mapRoleToWorkosRoleSlug(role: OrganizationRole): string | undefined {
69 if (role === "admin" || role === "owner") {77 if (role === "admin" || role === "owner") {
70 return "admin";78 return "admin";
71 }79 }
@@ -103,7 +111,7 @@ export const create = organizationMutation({ · 31 unmodified lines
103 requireAdmin: true,111 requireAdmin: true,
104 args: {112 args: {
105 email: v.string(),113 email: v.string(),
106 role: v.string(),114 role: organizationRoleValidator,
107 workspaceId: v.optional(v.id("workspaces")),115 workspaceId: v.optional(v.id("workspaces")),
108 expiresInDays: v.optional(v.number()),116 expiresInDays: v.optional(v.number()),
109 },117 },
@@ -123,17 +131,10 @@ export const create = organizationMutation({ · 13 unmodified lines
123 }131 }
124 }132 }
125 133
126 const inviterWorkosUserId = ctx.account.provider === "workos"134 if (ctx.account.provider !== "workos") {
127 ? ctx.account.providerAccountId
128 : (await ctx.db
129 .query("accountIdentities")
130 .withIndex("by_account", (q) => q.eq("accountId", ctx.account._id))
131 .filter((q) => q.eq(q.field("provider"), "workos"))
132 .first())?.providerUserId;
133
134 if (!inviterWorkosUserId) {
135 throw new Error("Inviter is not linked to WorkOS");135 throw new Error("Inviter is not linked to WorkOS");
136 }136 }
137 const inviterWorkosUserId = ctx.account.providerAccountId;
137 138
138 const token = `invite_${crypto.randomUUID()}`;139 const token = `invite_${crypto.randomUUID()}`;
139 const tokenHash = await sha256Hex(token);140 const tokenHash = await sha256Hex(token);
executor/convex/lib/identity.ts+0−8
@@ -25,14 +25,6 @@ export async function resolveAccountForRequest( · 24 unmodified lines
25 if (fromAccounts) {25 if (fromAccounts) {
26 return fromAccounts;26 return fromAccounts;
27 }27 }
28
29 const fromIdentity = await ctx.db
30 .query("accountIdentities")
31 .withIndex("by_provider_user", (q) => q.eq("provider", "workos").eq("providerUserId", identity.subject))
32 .unique();
33 if (fromIdentity?.accountId) {
34 return await ctx.db.get(fromIdentity.accountId);
35 }
36 }28 }
37 29
38 if (!sessionId) {30 if (!sessionId) {
executor/convex/organizationMembers.ts+8−1
@@ -3,6 +3,13 @@ import { internal } from "./_generated/api"; · 2 unmodified lines
3import { getOrganizationMembership } from "./lib/identity";3import { getOrganizationMembership } from "./lib/identity";
4import { organizationMutation, organizationQuery } from "./lib/functionBuilders";4import { organizationMutation, organizationQuery } from "./lib/functionBuilders";
5 5
6const organizationRoleValidator = v.union(
7 v.literal("owner"),
8 v.literal("admin"),
9 v.literal("member"),
10 v.literal("billing_admin"),
11);
12
6export const list = organizationQuery({13export const list = organizationQuery({
7 args: {},14 args: {},
8 handler: async (ctx) => {15 handler: async (ctx) => {
@@ -36,7 +43,7 @@ export const list = organizationQuery({ · 27 unmodified lines
36export const updateRole = organizationMutation({43export const updateRole = organizationMutation({
37 args: {44 args: {
38 accountId: v.id("accounts"),45 accountId: v.id("accounts"),
39 role: v.string(),46 role: organizationRoleValidator,
40 },47 },
41 requireAdmin: true,48 requireAdmin: true,
42 handler: async (ctx, args) => {49 handler: async (ctx, args) => {
executor/convex/organizations.ts+5−7
@@ -11,7 +11,6 @@ type WorkspaceSummary = { · 10 unmodified lines
11 name: string;11 name: string;
12 slug: string;12 slug: string;
13 iconUrl: string | null;13 iconUrl: string | null;
14 runtimeWorkspaceId: string;
15};14};
16 15
17async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {16async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {
@@ -36,7 +35,6 @@ async function mapWorkspaceWithIcon( · 18 unmodified lines
36 name: workspace.name,35 name: workspace.name,
37 slug: workspace.slug,36 slug: workspace.slug,
38 iconUrl,37 iconUrl,
39 runtimeWorkspaceId: workspace._id,
40 };38 };
41}39}
42 40
@@ -152,8 +150,8 @@ export const getNavigationState = optionalAccountQuery({ · 109 unmodified lines
152 .query("anonymousSessions")150 .query("anonymousSessions")
153 .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))151 .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
154 .unique();152 .unique();
155 if (anonymousSession?.workspaceDocId) {153 if (anonymousSession?.workspaceId) {
156 const workspace = await ctx.db.get(anonymousSession.workspaceDocId);154 const workspace = await ctx.db.get(anonymousSession.workspaceId);
157 if (workspace) {155 if (workspace) {
158 workspaces.push(await mapWorkspaceWithIcon(ctx, workspace));156 workspaces.push(await mapWorkspaceWithIcon(ctx, workspace));
159 }157 }
@@ -162,7 +160,7 @@ export const getNavigationState = optionalAccountQuery({ · 2 unmodified lines
162 160
163 return {161 return {
164 currentOrganizationId: workspaces[0]?.organizationId ?? null,162 currentOrganizationId: workspaces[0]?.organizationId ?? null,
165 currentWorkspaceId: workspaces[0]?.runtimeWorkspaceId ?? null,163 currentWorkspaceId: workspaces[0]?.id ?? null,
166 organizations,164 organizations,
167 workspaces,165 workspaces,
168 };166 };
@@ -199,12 +197,12 @@ export const getNavigationState = optionalAccountQuery({ · 30 unmodified lines
199 }197 }
200 198
201 const uniqueWorkspaces = Array.from(199 const uniqueWorkspaces = Array.from(
202 new Map(workspaces.map((workspace) => [workspace.runtimeWorkspaceId, workspace])).values(),200 new Map(workspaces.map((workspace) => [workspace.id, workspace])).values(),
203 );201 );
204 202
205 return {203 return {
206 currentOrganizationId: organizations[0]?.id ?? null,204 currentOrganizationId: organizations[0]?.id ?? null,
207 currentWorkspaceId: uniqueWorkspaces[0]?.runtimeWorkspaceId ?? null,205 currentWorkspaceId: uniqueWorkspaces[0]?.id ?? null,
208 organizations,206 organizations,
209 workspaces: uniqueWorkspaces,207 workspaces: uniqueWorkspaces,
210 };208 };
executor/convex/schema.ts+30−40
@@ -1,16 +1,32 @@
1import { defineSchema, defineTable } from "convex/server";1import { defineSchema, defineTable } from "convex/server";
2import { v } from "convex/values";2import { v } from "convex/values";
3 3
4const accountProvider = v.union(v.literal("workos"), v.literal("anonymous"));
5const accountStatus = v.union(v.literal("active"), v.literal("deleted"));
6const organizationStatus = v.union(v.literal("active"), v.literal("deleted"));
7const orgRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("member"), v.literal("billing_admin"));
8const orgMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed"));
9const workspaceMemberRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("member"));
10const workspaceMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed"));
11const inviteStatus = v.union(
12 v.literal("pending"),
13 v.literal("accepted"),
14 v.literal("expired"),
15 v.literal("revoked"),
16 v.literal("failed"),
17);
18const inviteProvider = v.literal("workos");
19
4export default defineSchema({20export default defineSchema({
5 accounts: defineTable({21 accounts: defineTable({
6 provider: v.string(),22 provider: accountProvider,
7 providerAccountId: v.string(),23 providerAccountId: v.string(),
8 email: v.string(),24 email: v.string(),
9 name: v.string(),25 name: v.string(),
10 firstName: v.optional(v.string()),26 firstName: v.optional(v.string()),
11 lastName: v.optional(v.string()),27 lastName: v.optional(v.string()),
12 avatarUrl: v.optional(v.string()),28 avatarUrl: v.optional(v.string()),
13 status: v.string(),29 status: accountStatus,
14 createdAt: v.number(),30 createdAt: v.number(),
15 updatedAt: v.number(),31 updatedAt: v.number(),
16 lastLoginAt: v.optional(v.number()),32 lastLoginAt: v.optional(v.number()),
@@ -39,7 +55,7 @@ export default defineSchema({ · 22 unmodified lines
39 workosOrgId: v.optional(v.string()),55 workosOrgId: v.optional(v.string()),
40 slug: v.string(),56 slug: v.string(),
41 name: v.string(),57 name: v.string(),
42 status: v.string(),58 status: organizationStatus,
43 createdByAccountId: v.optional(v.id("accounts")),59 createdByAccountId: v.optional(v.id("accounts")),
44 createdAt: v.number(),60 createdAt: v.number(),
45 updatedAt: v.number(),61 updatedAt: v.number(),
@@ -51,8 +67,8 @@ export default defineSchema({ · 5 unmodified lines
51 organizationMembers: defineTable({67 organizationMembers: defineTable({
52 organizationId: v.id("organizations"),68 organizationId: v.id("organizations"),
53 accountId: v.id("accounts"),69 accountId: v.id("accounts"),
54 role: v.string(),70 role: orgRole,
55 status: v.string(),71 status: orgMemberStatus,
56 billable: v.boolean(),72 billable: v.boolean(),
57 invitedByAccountId: v.optional(v.id("accounts")),73 invitedByAccountId: v.optional(v.id("accounts")),
58 joinedAt: v.optional(v.number()),74 joinedAt: v.optional(v.number()),
@@ -68,37 +84,26 @@ export default defineSchema({ · 9 unmodified lines
68 workspaceMembers: defineTable({84 workspaceMembers: defineTable({
69 workspaceId: v.id("workspaces"),85 workspaceId: v.id("workspaces"),
70 accountId: v.id("accounts"),86 accountId: v.id("accounts"),
71 role: v.string(),87 workosOrgMembershipId: v.optional(v.string()),
72 status: v.string(),88 role: workspaceMemberRole,
89 status: workspaceMemberStatus,
73 createdAt: v.number(),90 createdAt: v.number(),
74 updatedAt: v.number(),91 updatedAt: v.number(),
75 })92 })
76 .index("by_workspace", ["workspaceId"])93 .index("by_workspace", ["workspaceId"])
77 .index("by_workspace_account", ["workspaceId", "accountId"])94 .index("by_workspace_account", ["workspaceId", "accountId"])
78 .index("by_account", ["accountId"])95 .index("by_account", ["accountId"])
79 .index("by_workspace_status", ["workspaceId", "status"]),96 .index("by_workspace_status", ["workspaceId", "status"])
80 97 .index("by_workos_membership_id", ["workosOrgMembershipId"]),
81 accountIdentities: defineTable({
82 accountId: v.id("accounts"),
83 provider: v.string(),
84 providerUserId: v.string(),
85 providerEmail: v.optional(v.string()),
86 rawClaims: v.optional(v.any()),
87 lastLoginAt: v.optional(v.number()),
88 createdAt: v.number(),
89 updatedAt: v.number(),
90 })
91 .index("by_account", ["accountId"])
92 .index("by_provider_user", ["provider", "providerUserId"]),
93 98
94 invites: defineTable({99 invites: defineTable({
95 organizationId: v.id("organizations"),100 organizationId: v.id("organizations"),
96 workspaceId: v.optional(v.id("workspaces")),101 workspaceId: v.optional(v.id("workspaces")),
97 email: v.string(),102 email: v.string(),
98 role: v.string(),103 role: orgRole,
99 status: v.string(),104 status: inviteStatus,
100 tokenHash: v.string(),105 tokenHash: v.string(),
101 provider: v.string(),106 provider: inviteProvider,
102 providerInviteId: v.optional(v.string()),107 providerInviteId: v.optional(v.string()),
103 invitedByAccountId: v.id("accounts"),108 invitedByAccountId: v.id("accounts"),
104 expiresAt: v.number(),109 expiresAt: v.number(),
@@ -146,20 +151,6 @@ export default defineSchema({ · 41 unmodified lines
146 updatedAt: v.number(),151 updatedAt: v.number(),
147 }).index("by_org", ["organizationId"]),152 }).index("by_org", ["organizationId"]),
148 153
149 users: defineTable({
150 workspaceId: v.id("workspaces"),
151 accountId: v.id("accounts"),
152 workosOrgMembershipId: v.optional(v.string()),
153 role: v.string(),
154 status: v.string(),
155 createdAt: v.number(),
156 updatedAt: v.number(),
157 })
158 .index("by_workspace_account", ["workspaceId", "accountId"])
159 .index("by_account", ["accountId"])
160 .index("by_workspace", ["workspaceId"])
161 .index("by_workos_membership_id", ["workosOrgMembershipId"]),
162
163 tasks: defineTable({154 tasks: defineTable({
164 taskId: v.string(),155 taskId: v.string(),
165 code: v.string(),156 code: v.string(),
@@ -277,8 +268,7 @@ export default defineSchema({ · 111 unmodified lines
277 actorId: v.string(),268 actorId: v.string(),
278 clientId: v.string(),269 clientId: v.string(),
279 accountId: v.optional(v.id("accounts")),270 accountId: v.optional(v.id("accounts")),
280 workspaceDocId: v.optional(v.id("workspaces")),271 userId: v.optional(v.id("workspaceMembers")),
281 userId: v.optional(v.id("users")),
282 createdAt: v.number(),272 createdAt: v.number(),
283 lastSeenAt: v.number(),273 lastSeenAt: v.number(),
284 })274 })
executor/convex/workspaces.ts+0−2
@@ -11,7 +11,6 @@ type WorkspaceResult = { · 10 unmodified lines
11 name: string;11 name: string;
12 slug: string;12 slug: string;
13 iconUrl: string | null;13 iconUrl: string | null;
14 runtimeWorkspaceId: string;
15 createdAt: number;14 createdAt: number;
16};15};
17 16
@@ -52,7 +51,6 @@ async function toWorkspaceResult( · 34 unmodified lines
52 name: workspace.name,51 name: workspace.name,
53 slug: workspace.slug,52 slug: workspace.slug,
54 iconUrl,53 iconUrl,
55 runtimeWorkspaceId: workspace._id,
56 createdAt: workspace.createdAt,54 createdAt: workspace.createdAt,
57 };55 };
58}56}
executor/packages/contracts/src/index.ts+0−1
@@ -118,7 +118,6 @@ export interface AnonymousContext { · 117 unmodified lines
118 actorId: string;118 actorId: string;
119 clientId: string;119 clientId: string;
120 accountId?: string;120 accountId?: string;
121 workspaceDocId?: string;
122 userId?: string;121 userId?: string;
123 createdAt: number;122 createdAt: number;
124 lastSeenAt: number;123 lastSeenAt: number;