104 lines
3.1 KiB
TypeScript
104 lines
3.1 KiB
TypeScript
|
|
import { TRPCError } from "@trpc/server";
|
||
|
|
import { and, eq, or } from "drizzle-orm";
|
||
|
|
import { workspaces, workspaceMembers } from "@tasks/database/schema";
|
||
|
|
import { db as defaultDb } from "@tasks/database";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cheap UUID v4-ish detector. We only need to differentiate "this looks like a
|
||
|
|
* UUID" from "this looks like a slug" so the resolver can pick the right column.
|
||
|
|
*/
|
||
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
|
|
|
||
|
|
export type WorkspaceContext = {
|
||
|
|
id: string;
|
||
|
|
slug: string;
|
||
|
|
name: string;
|
||
|
|
ownerUserId: string;
|
||
|
|
/** Caller's role inside the workspace, or "owner" if they own it directly. */
|
||
|
|
role: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Resolve a workspace handle (UUID or slug) to a full workspace record AND
|
||
|
|
* authorize the caller against it. Throws NOT_FOUND if the handle doesn't
|
||
|
|
* resolve, FORBIDDEN if the user isn't a member or owner.
|
||
|
|
*
|
||
|
|
* Used by the `workspaceProcedure` middleware and by Server Components at the
|
||
|
|
* `app/(app)/[workspaceSlug]/...` layout boundary.
|
||
|
|
*/
|
||
|
|
export async function resolveWorkspace(args: {
|
||
|
|
handle: string;
|
||
|
|
userId: string;
|
||
|
|
db?: typeof defaultDb;
|
||
|
|
}): Promise<WorkspaceContext> {
|
||
|
|
const db = args.db ?? defaultDb;
|
||
|
|
const handle = args.handle.trim();
|
||
|
|
if (!handle) {
|
||
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace handle required" });
|
||
|
|
}
|
||
|
|
|
||
|
|
const lookupCondition = UUID_RE.test(handle)
|
||
|
|
? eq(workspaces.id, handle)
|
||
|
|
: eq(workspaces.slug, handle);
|
||
|
|
|
||
|
|
const [row] = await db
|
||
|
|
.select({
|
||
|
|
id: workspaces.id,
|
||
|
|
slug: workspaces.slug,
|
||
|
|
name: workspaces.name,
|
||
|
|
ownerUserId: workspaces.ownerUserId,
|
||
|
|
memberRole: workspaceMembers.role,
|
||
|
|
})
|
||
|
|
.from(workspaces)
|
||
|
|
.leftJoin(
|
||
|
|
workspaceMembers,
|
||
|
|
and(
|
||
|
|
eq(workspaceMembers.workspaceId, workspaces.id),
|
||
|
|
eq(workspaceMembers.userId, args.userId),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
.where(lookupCondition)
|
||
|
|
.limit(1);
|
||
|
|
|
||
|
|
if (!row) {
|
||
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found" });
|
||
|
|
}
|
||
|
|
|
||
|
|
const isOwner = row.ownerUserId === args.userId;
|
||
|
|
if (!isOwner && !row.memberRole) {
|
||
|
|
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this workspace" });
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: row.id,
|
||
|
|
slug: row.slug,
|
||
|
|
name: row.name,
|
||
|
|
ownerUserId: row.ownerUserId,
|
||
|
|
role: isOwner ? "owner" : (row.memberRole ?? "member"),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Look up a workspace by either UUID or slug WITHOUT authorizing the caller.
|
||
|
|
* Used for the public form-fill flow and for routes that explicitly want to
|
||
|
|
* peek at workspace existence (e.g. URL backcompat redirects).
|
||
|
|
*/
|
||
|
|
export async function findWorkspaceByHandle(
|
||
|
|
handle: string,
|
||
|
|
db = defaultDb,
|
||
|
|
): Promise<{ id: string; slug: string; name: string } | null> {
|
||
|
|
const cleaned = handle.trim();
|
||
|
|
if (!cleaned) return null;
|
||
|
|
const cond = UUID_RE.test(cleaned)
|
||
|
|
? eq(workspaces.id, cleaned)
|
||
|
|
: eq(workspaces.slug, cleaned);
|
||
|
|
const [row] = await db
|
||
|
|
.select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name })
|
||
|
|
.from(workspaces)
|
||
|
|
.where(cond)
|
||
|
|
.limit(1);
|
||
|
|
return row ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export { UUID_RE as WORKSPACE_HANDLE_UUID_RE };
|