@fonderie/workspaces 5.3.0 → 5.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +38 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +38 -18
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.js.map +1 -1
- package/package.json +5 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/middlewares/require-workspace.ts","../../src/middlewares/workspace-context.ts","../../src/services/members.ts","../../src/services/workspaces.ts"],"sourcesContent":["export { requireWorkspace } from './require-workspace';\nexport { withWorkspace } from './workspace-context';\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\n\n// Ensures ctx.workspace is set — use after withWorkspace\n\nexport const requireWorkspace: Middleware = async (ctx, next) => {\n\tif (!ctx.workspace) {\n\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t}\n\treturn next();\n};\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { getMember } from '../services/members';\nimport { findWorkspaceById, findPersonalWorkspace } from '../services/workspaces';\n\n// Resolves ctx.workspace from:\n// 1. Route param :workspaceId or :id (path-based admin routes)\n// 2. X-Workspace-ID request header (standard resource routes)\n// Validates the current user is an active member.\n// Must run after withSession.\n\nfunction makeHandler(store: IStoreAdapter): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\t\tconst workspaceId =\n\t\t\tparams?.['workspaceId'] ??\n\t\t\tparams?.['id'] ??\n\t\t\tctx.request.headers.get('x-workspace-id') ??\n\t\t\tundefined;\n\n\t\tif (!workspaceId) {\n\t\t\t// DMZ fallback — if the caller didn't specify a workspace, use their personal one.\n\t\t\t// No membership check needed: the user is always the sole owner.\n\t\t\tif (ctx.user) {\n\t\t\t\tconst personal = await findPersonalWorkspace(ctx.user.id, store);\n\t\t\t\tif (personal) Object.assign(ctx, { workspace: personal });\n\t\t\t}\n\t\t\treturn next();\n\t\t}\n\n\t\tconst workspace = await findWorkspaceById(workspaceId, store);\n\t\tif (!workspace) {\n\t\t\treturn setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'Workspace not found');\n\t\t}\n\n\t\tif (ctx.user) {\n\t\t\tconst member = await getMember(ctx.user.id, workspaceId, store);\n\t\t\tif (!member) {\n\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Not a member of this workspace');\n\t\t\t}\n\t\t}\n\n\t\tObject.assign(ctx, { workspace });\n\t\treturn next();\n\t};\n}\n\nexport function withWorkspace(store: IStoreAdapter): Middleware;\nexport function withWorkspace(\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function withWorkspace(\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMember, IRole } from '../types';\n\nconst SELECT_MEMBER = `\n\truw.user_id AS \"userId\",\n\truw.workspace_id AS \"workspaceId\",\n\truw.role_id AS \"roleId\",\n\tr.name AS \"roleName\",\n\truw.confirmed AS \"confirmed\",\n\truw.created_at AS \"createdAt\",\n\tu.first_name AS \"firstName\",\n\tu.last_name AS \"lastName\",\n\tu.email AS \"email\",\n\tu.profile_image_url AS \"profileImageUrl\"\n`;\n\nexport async function getMember(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMember | null> {\n\tconst [row] = await store.query<IMember>(\n\t\t`SELECT ${SELECT_MEMBER}\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t LEFT JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t LEFT JOIN fonderie_users u ON u.id = ruw.user_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function listMembers(workspaceId: string, store: IStoreAdapter): Promise<IMember[]> {\n\treturn store.query<IMember>(\n\t\t`SELECT ${SELECT_MEMBER}\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t LEFT JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t LEFT JOIN fonderie_users u ON u.id = ruw.user_id\n\t\t WHERE ruw.workspace_id = $1\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ORDER BY ruw.created_at ASC`,\n\t\t[workspaceId],\n\t);\n}\n\nexport async function addMember(\n\topts: { userId: string; workspaceId: string; roleId: string; confirmed?: boolean },\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`INSERT INTO fonderie_role_user_workspaces (user_id, workspace_id, role_id, confirmed)\n\t\t VALUES ($1, $2, $3, $4)\n\t\t ON CONFLICT (user_id, workspace_id, role_id) DO UPDATE\n\t\t SET confirmed = $4, removed = false, suspended = false`,\n\t\t[opts.userId, opts.workspaceId, opts.roleId, opts.confirmed ?? true],\n\t);\n}\n\nexport async function removeMember(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_role_user_workspaces\n\t\t SET removed = true\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2`,\n\t\t[userId, workspaceId],\n\t);\n}\n\nexport async function getUserRoles(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IRole[]> {\n\treturn store.query<IRole>(\n\t\t`SELECT\n\t\t r.id,\n\t\t r.name,\n\t\t r.is_system AS \"isSystem\",\n\t\t r.active,\n\t\t r.description,\n\t\t r.workspace_id AS \"workspaceId\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n}\n\nexport async function addRoleToMember(\n\tuserId: string,\n\tworkspaceId: string,\n\troleId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`INSERT INTO fonderie_role_user_workspaces (user_id, workspace_id, role_id, confirmed)\n\t\t VALUES ($1, $2, $3, true)\n\t\t ON CONFLICT (user_id, workspace_id, role_id) DO UPDATE\n\t\t SET confirmed = true, removed = false, suspended = false`,\n\t\t[userId, workspaceId, roleId],\n\t);\n}\n\nexport async function removeRoleFromMember(\n\tuserId: string,\n\tworkspaceId: string,\n\troleId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tconst remaining = await store.query<{ count: string }>(\n\t\t`SELECT COUNT(*) AS count\n\t\t FROM fonderie_role_user_workspaces\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2\n\t\t AND removed = false`,\n\t\t[userId, workspaceId],\n\t);\n\tconst count = parseInt(remaining[0]?.count ?? '0', 10);\n\tif (count <= 1) throw new Error('Cannot remove last role from member');\n\n\tawait store.query(\n\t\t`DELETE FROM fonderie_role_user_workspaces\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2\n\t\t AND role_id = $3`,\n\t\t[userId, workspaceId, roleId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IWorkspace, IWorkspaceAddress, IWorkspaceSettings } from '../types';\n\nconst SELECT_WS = `\n\tid,\n\tname,\n\tslug,\n\ttype,\n\tdescription,\n\tmotto,\n\tphone,\n\tbusiness_type AS \"businessType\",\n\taddress,\n\tplan,\n\towner_id AS \"ownerId\",\n\tis_personal AS \"isPersonal\",\n\tarchived_at AS \"archivedAt\",\n\tarchived_by AS \"archivedBy\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nconst SELECT_WS_W = `\n\tw.id,\n\tw.name,\n\tw.slug,\n\tw.type,\n\tw.description,\n\tw.motto,\n\tw.phone,\n\tw.business_type AS \"businessType\",\n\tw.address,\n\tw.plan,\n\tw.owner_id AS \"ownerId\",\n\tw.is_personal AS \"isPersonal\",\n\tw.archived_at AS \"archivedAt\",\n\tw.archived_by AS \"archivedBy\",\n\tw.created_at AS \"createdAt\",\n\tw.updated_at AS \"updatedAt\"\n`;\n\nexport async function findWorkspaceById(\n\tid: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS} FROM fonderie_workspaces WHERE id = $1`,\n\t\t[id],\n\t);\n\treturn row ?? null;\n}\n\nexport async function findWorkspacesByUserId(\n\tuserId: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace[]> {\n\treturn store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS_W}\n\t\t FROM fonderie_workspaces w\n\t\t JOIN fonderie_role_user_workspaces ruw ON ruw.workspace_id = w.id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t GROUP BY w.id\n\t\t ORDER BY w.created_at ASC`,\n\t\t[userId],\n\t);\n}\n\nexport async function createWorkspace(\n\topts: {\n\t\tname: string;\n\t\tslug: string;\n\t\townerId: string;\n\t\ttype?: string;\n\t\tdescription?: string;\n\t\tplan?: string;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWorkspace> {\n\tconst [workspace] = await store.query<IWorkspace>(\n\t\t`INSERT INTO fonderie_workspaces (name, slug, owner_id, type, description, plan)\n\t\t VALUES ($1, $2, $3, $4, $5, $6)\n\t\t RETURNING ${SELECT_WS}`,\n\t\t[\n\t\t\topts.name,\n\t\t\topts.slug,\n\t\t\topts.ownerId,\n\t\t\topts.type ?? 'ORGANIZATION',\n\t\t\topts.description ?? null,\n\t\t\topts.plan ?? 'free',\n\t\t],\n\t);\n\n\tif (!workspace) throw new Error('Failed to create workspace');\n\treturn workspace;\n}\n\n// Returns the new workspace, or null if the personal workspace already exists (idempotent).\nexport async function createPersonalWorkspace(\n\topts: { name: string; slug: string; ownerId: string },\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [workspace] = await store.query<IWorkspace>(\n\t\t`INSERT INTO fonderie_workspaces (name, slug, owner_id, type, is_personal)\n\t\t VALUES ($1, $2, $3, 'PERSONAL', true)\n\t\t ON CONFLICT (owner_id) WHERE is_personal = true DO NOTHING\n\t\t RETURNING ${SELECT_WS}`,\n\t\t[opts.name, opts.slug, opts.ownerId],\n\t);\n\treturn workspace ?? null;\n}\n\nexport async function findPersonalWorkspace(\n\tuserId: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS} FROM fonderie_workspaces\n\t\t WHERE owner_id = $1 AND is_personal = true\n\t\t LIMIT 1`,\n\t\t[userId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function updateWorkspace(\n\tid: string,\n\topts: {\n\t\tname?: string;\n\t\tdescription?: string | null;\n\t\tslug?: string;\n\t\tmotto?: string | null;\n\t\tphone?: string | null;\n\t\tbusinessType?: string | null;\n\t\taddress?: IWorkspaceAddress | null;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst sets: string[] = ['updated_at = now()'];\n\tconst params: unknown[] = [id];\n\n\tif (opts.name !== undefined) {\n\t\tparams.push(opts.name);\n\t\tsets.push(`name = $${params.length}`);\n\t}\n\tif (opts.description !== undefined) {\n\t\tparams.push(opts.description);\n\t\tsets.push(`description = $${params.length}`);\n\t}\n\tif (opts.slug !== undefined) {\n\t\tparams.push(opts.slug);\n\t\tsets.push(`slug = $${params.length}`);\n\t}\n\tif (opts.motto !== undefined) {\n\t\tparams.push(opts.motto);\n\t\tsets.push(`motto = $${params.length}`);\n\t}\n\tif (opts.phone !== undefined) {\n\t\tparams.push(opts.phone);\n\t\tsets.push(`phone = $${params.length}`);\n\t}\n\tif (opts.businessType !== undefined) {\n\t\tparams.push(opts.businessType);\n\t\tsets.push(`business_type = $${params.length}`);\n\t}\n\tif (opts.address !== undefined) {\n\t\tparams.push(JSON.stringify(opts.address ?? {}));\n\t\tsets.push(`address = $${params.length}::jsonb`);\n\t}\n\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET ${sets.join(', ')}\n\t\t WHERE id = $1\n\t\t RETURNING ${SELECT_WS}`,\n\t\tparams,\n\t);\n\treturn row ?? null;\n}\n\nexport async function archiveWorkspace(\n\tid: string,\n\tbyUser: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET archived_at = now(), archived_by = $2, updated_at = now()\n\t\t WHERE id = $1 AND archived_at IS NULL`,\n\t\t[id, byUser],\n\t);\n}\n\nexport async function restoreWorkspace(id: string, store: IStoreAdapter): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET archived_at = NULL, archived_by = NULL, updated_at = now()\n\t\t WHERE id = $1`,\n\t\t[id],\n\t);\n}\n\nconst SETTINGS_DEFAULTS: IWorkspaceSettings = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tcurrency: 'USD',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\nexport async function getWorkspaceSettings(\n\tid: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspaceSettings> {\n\tconst [row] = await store.query<{ settings: Record<string, unknown> }>(\n\t\t`SELECT settings FROM fonderie_workspaces WHERE id = $1`,\n\t\t[id],\n\t);\n\tconst raw = row?.settings ?? {};\n\tconst s = (raw['settings'] as Record<string, unknown> | undefined) ?? raw;\n\n\treturn {\n\t\tlocale: typeof s['locale'] === 'string' ? s['locale'] : SETTINGS_DEFAULTS.locale,\n\t\ttimezone: typeof s['timezone'] === 'string' ? s['timezone'] : SETTINGS_DEFAULTS.timezone,\n\t\tcurrency: typeof s['currency'] === 'string' ? s['currency'] : SETTINGS_DEFAULTS.currency,\n\t\tdateFormat:\n\t\t\ttypeof s['dateFormat'] === 'string' ? s['dateFormat'] : SETTINGS_DEFAULTS.dateFormat,\n\t\ttimeFormat:\n\t\t\ttypeof s['timeFormat'] === 'string' ? s['timeFormat'] : SETTINGS_DEFAULTS.timeFormat,\n\t};\n}\n\nexport async function updateWorkspaceSettings(\n\tid: string,\n\tsettings: Partial<IWorkspaceSettings>,\n\tstore: IStoreAdapter,\n): Promise<IWorkspaceSettings> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET settings = settings || jsonb_build_object('settings', $2::jsonb),\n\t\t updated_at = now()\n\t\t WHERE id = $1`,\n\t\t[id, JSON.stringify(settings)],\n\t);\n\treturn getWorkspaceSettings(id, store);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,kBAAqC;AAK9B,IAAM,mBAA+B,OAAO,KAAK,SAAS;AAChE,MAAI,CAAC,IAAI,WAAW;AACnB,eAAO,4BAAe,iBAAK,aAAa,sBAAsB,4BAA4B;AAAA,EAC3F;AACA,SAAO,KAAK;AACb;;;ACVA,IAAAA,eAAqC;;;ACIrC,IAAM,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAatB,eAAsB,UACrB,QACA,aACA,OAC0B;AAC1B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASvB,CAAC,QAAQ,WAAW;AAAA,EACrB;AACA,SAAO,OAAO;AACf;;;AC/BA,IAAM,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsClB,eAAsB,kBACrB,IACA,OAC6B;AAC7B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,SAAS;AAAA,IACnB,CAAC,EAAE;AAAA,EACJ;AACA,SAAO,OAAO;AACf;AA+DA,eAAsB,sBACrB,QACA,OAC6B;AAC7B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,SAAS;AAAA;AAAA;AAAA,IAGnB,CAAC,MAAM;AAAA,EACR;AACA,SAAO,OAAO;AACf;;;AF/GA,SAAS,YAAY,OAAkC;AACtD,SAAO,OAAO,KAAK,SAAS;AAC3B,UAAM,SAAS,IAAI,KAAK,QAAQ;AAChC,UAAM,cACL,SAAS,aAAa,KACtB,SAAS,IAAI,KACb,IAAI,QAAQ,QAAQ,IAAI,gBAAgB,KACxC;AAED,QAAI,CAAC,aAAa;AAGjB,UAAI,IAAI,MAAM;AACb,cAAM,WAAW,MAAM,sBAAsB,IAAI,KAAK,IAAI,KAAK;AAC/D,YAAI,SAAU,QAAO,OAAO,KAAK,EAAE,WAAW,SAAS,CAAC;AAAA,MACzD;AACA,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,YAAY,MAAM,kBAAkB,aAAa,KAAK;AAC5D,QAAI,CAAC,WAAW;AACf,iBAAO,6BAAe,kBAAK,WAAW,aAAa,qBAAqB;AAAA,IACzE;AAEA,QAAI,IAAI,MAAM;AACb,YAAM,SAAS,MAAM,UAAU,IAAI,KAAK,IAAI,aAAa,KAAK;AAC9D,UAAI,CAAC,QAAQ;AACZ,mBAAO,6BAAe,kBAAK,WAAW,aAAa,gCAAgC;AAAA,MACpF;AAAA,IACD;AAEA,WAAO,OAAO,KAAK,EAAE,UAAU,CAAC;AAChC,WAAO,KAAK;AAAA,EACb;AACD;AAQO,SAAS,cACf,OACA,KACA,MACiC;AACjC,QAAM,UAAU,YAAY,KAAK;AACjC,MAAI,QAAQ,UAAa,SAAS,OAAW,QAAO,QAAQ,KAAK,IAAI;AACrE,SAAO;AACR;","names":["import_core"]}
|
|
1
|
+
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/middlewares/require-workspace.ts","../../src/middlewares/workspace-context.ts","../../src/services/members.ts","../../src/services/workspaces.ts"],"sourcesContent":["export { requireWorkspace } from './require-workspace';\nexport { withWorkspace } from './workspace-context';\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\n\n// Ensures ctx.workspace is set — use after withWorkspace\n\nexport const requireWorkspace: Middleware = async (ctx, next) => {\n\tif (!ctx.workspace) {\n\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t}\n\treturn next();\n};\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { getMember } from '../services/members';\nimport { findWorkspaceById, findPersonalWorkspace } from '../services/workspaces';\n\n// Resolves ctx.workspace from:\n// 1. Route param :workspaceId or :id (path-based admin routes)\n// 2. X-Workspace-ID request header (standard resource routes)\n// Validates the current user is an active member.\n// Must run after withSession.\n\nfunction makeHandler(store: IStoreAdapter): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\t\tconst workspaceId =\n\t\t\tparams?.['workspaceId'] ??\n\t\t\tparams?.['id'] ??\n\t\t\tctx.request.headers.get('x-workspace-id') ??\n\t\t\tundefined;\n\n\t\tif (!workspaceId) {\n\t\t\t// DMZ fallback — if the caller didn't specify a workspace, use their personal one.\n\t\t\t// No membership check needed: the user is always the sole owner.\n\t\t\tif (ctx.user) {\n\t\t\t\tconst personal = await findPersonalWorkspace(ctx.user.id, store);\n\t\t\t\tif (personal) Object.assign(ctx, { workspace: personal });\n\t\t\t}\n\t\t\treturn next();\n\t\t}\n\n\t\tconst workspace = await findWorkspaceById(workspaceId, store);\n\t\tif (!workspace) {\n\t\t\treturn setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'Workspace not found');\n\t\t}\n\n\t\tif (ctx.user) {\n\t\t\tconst member = await getMember(ctx.user.id, workspaceId, store);\n\t\t\tif (!member) {\n\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Not a member of this workspace');\n\t\t\t}\n\t\t}\n\n\t\tObject.assign(ctx, { workspace });\n\t\treturn next();\n\t};\n}\n\nexport function withWorkspace(store: IStoreAdapter): Middleware;\nexport function withWorkspace(\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function withWorkspace(\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMember, IRole } from '../types';\n\nconst SELECT_MEMBER = `\n\truw.user_id AS \"userId\",\n\truw.workspace_id AS \"workspaceId\",\n\truw.role_id AS \"roleId\",\n\tr.name AS \"roleName\",\n\truw.confirmed AS \"confirmed\",\n\truw.created_at AS \"createdAt\",\n\tu.first_name AS \"firstName\",\n\tu.last_name AS \"lastName\",\n\tu.email AS \"email\",\n\tu.profile_image_url AS \"profileImageUrl\"\n`;\n\nexport async function getMember(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMember | null> {\n\tconst [row] = await store.query<IMember>(\n\t\t`SELECT ${SELECT_MEMBER}\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t LEFT JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t LEFT JOIN fonderie_users u ON u.id = ruw.user_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function listMembers(workspaceId: string, store: IStoreAdapter): Promise<IMember[]> {\n\treturn store.query<IMember>(\n\t\t`SELECT ${SELECT_MEMBER}\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t LEFT JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t LEFT JOIN fonderie_users u ON u.id = ruw.user_id\n\t\t WHERE ruw.workspace_id = $1\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ORDER BY ruw.created_at ASC`,\n\t\t[workspaceId],\n\t);\n}\n\nexport async function addMember(\n\topts: { userId: string; workspaceId: string; roleId: string; confirmed?: boolean },\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`INSERT INTO fonderie_role_user_workspaces (user_id, workspace_id, role_id, confirmed)\n\t\t VALUES ($1, $2, $3, $4)\n\t\t ON CONFLICT (user_id, workspace_id, role_id) DO UPDATE\n\t\t SET confirmed = $4, removed = false, suspended = false`,\n\t\t[opts.userId, opts.workspaceId, opts.roleId, opts.confirmed ?? true],\n\t);\n}\n\nexport async function removeMember(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_role_user_workspaces\n\t\t SET removed = true\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2`,\n\t\t[userId, workspaceId],\n\t);\n}\n\nexport async function getUserRoles(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IRole[]> {\n\treturn store.query<IRole>(\n\t\t`SELECT\n\t\t r.id,\n\t\t r.name,\n\t\t r.is_system AS \"isSystem\",\n\t\t r.active,\n\t\t r.description,\n\t\t r.workspace_id AS \"workspaceId\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n}\n\n/**\n * Assign an existing role to a member via the HTTP surface. Returns false when\n * the role is not assignable — the insert only proceeds for a role that belongs\n * to THIS workspace and is NOT a system role. This is the privilege-escalation\n * guard: system roles carry the super-role bypass (@fonderie/permissions), so a\n * member must never be able to self-grant one (e.g. the seeded ADMIN role) to\n * gain full access; cross-workspace role ids must not be assignable either.\n * Seeding of system/owner roles at workspace-creation / invitation time goes\n * through addMember(), not this path, so that flow is unaffected.\n */\nexport async function addRoleToMember(\n\tuserId: string,\n\tworkspaceId: string,\n\troleId: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst rows = await store.query<{ user_id: string }>(\n\t\t`INSERT INTO fonderie_role_user_workspaces (user_id, workspace_id, role_id, confirmed)\n\t\t SELECT $1, $2, $3, true\n\t\t WHERE EXISTS (\n\t\t SELECT 1 FROM fonderie_roles r\n\t\t WHERE r.id = $3\n\t\t AND r.workspace_id = $2\n\t\t AND r.is_system = false\n\t\t )\n\t\t ON CONFLICT (user_id, workspace_id, role_id) DO UPDATE\n\t\t SET confirmed = true, removed = false, suspended = false\n\t\t RETURNING user_id`,\n\t\t[userId, workspaceId, roleId],\n\t);\n\treturn rows.length > 0;\n}\n\nexport async function removeRoleFromMember(\n\tuserId: string,\n\tworkspaceId: string,\n\troleId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tconst remaining = await store.query<{ count: string }>(\n\t\t`SELECT COUNT(*) AS count\n\t\t FROM fonderie_role_user_workspaces\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2\n\t\t AND removed = false`,\n\t\t[userId, workspaceId],\n\t);\n\tconst count = parseInt(remaining[0]?.count ?? '0', 10);\n\tif (count <= 1) throw new Error('Cannot remove last role from member');\n\n\tawait store.query(\n\t\t`DELETE FROM fonderie_role_user_workspaces\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2\n\t\t AND role_id = $3`,\n\t\t[userId, workspaceId, roleId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IWorkspace, IWorkspaceAddress, IWorkspaceSettings } from '../types';\n\nconst SELECT_WS = `\n\tid,\n\tname,\n\tslug,\n\ttype,\n\tdescription,\n\tmotto,\n\tphone,\n\tbusiness_type AS \"businessType\",\n\taddress,\n\tplan,\n\towner_id AS \"ownerId\",\n\tis_personal AS \"isPersonal\",\n\tarchived_at AS \"archivedAt\",\n\tarchived_by AS \"archivedBy\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nconst SELECT_WS_W = `\n\tw.id,\n\tw.name,\n\tw.slug,\n\tw.type,\n\tw.description,\n\tw.motto,\n\tw.phone,\n\tw.business_type AS \"businessType\",\n\tw.address,\n\tw.plan,\n\tw.owner_id AS \"ownerId\",\n\tw.is_personal AS \"isPersonal\",\n\tw.archived_at AS \"archivedAt\",\n\tw.archived_by AS \"archivedBy\",\n\tw.created_at AS \"createdAt\",\n\tw.updated_at AS \"updatedAt\"\n`;\n\nexport async function findWorkspaceById(\n\tid: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS} FROM fonderie_workspaces WHERE id = $1`,\n\t\t[id],\n\t);\n\treturn row ?? null;\n}\n\nexport async function findWorkspacesByUserId(\n\tuserId: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace[]> {\n\treturn store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS_W}\n\t\t FROM fonderie_workspaces w\n\t\t JOIN fonderie_role_user_workspaces ruw ON ruw.workspace_id = w.id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t GROUP BY w.id\n\t\t ORDER BY w.created_at ASC`,\n\t\t[userId],\n\t);\n}\n\nexport async function createWorkspace(\n\topts: {\n\t\tname: string;\n\t\tslug: string;\n\t\townerId: string;\n\t\ttype?: string;\n\t\tdescription?: string;\n\t\tplan?: string;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWorkspace> {\n\tconst [workspace] = await store.query<IWorkspace>(\n\t\t`INSERT INTO fonderie_workspaces (name, slug, owner_id, type, description, plan)\n\t\t VALUES ($1, $2, $3, $4, $5, $6)\n\t\t RETURNING ${SELECT_WS}`,\n\t\t[\n\t\t\topts.name,\n\t\t\topts.slug,\n\t\t\topts.ownerId,\n\t\t\topts.type ?? 'ORGANIZATION',\n\t\t\topts.description ?? null,\n\t\t\topts.plan ?? 'free',\n\t\t],\n\t);\n\n\tif (!workspace) throw new Error('Failed to create workspace');\n\treturn workspace;\n}\n\n// Returns the new workspace, or null if the personal workspace already exists (idempotent).\nexport async function createPersonalWorkspace(\n\topts: { name: string; slug: string; ownerId: string },\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [workspace] = await store.query<IWorkspace>(\n\t\t`INSERT INTO fonderie_workspaces (name, slug, owner_id, type, is_personal)\n\t\t VALUES ($1, $2, $3, 'PERSONAL', true)\n\t\t ON CONFLICT (owner_id) WHERE is_personal = true DO NOTHING\n\t\t RETURNING ${SELECT_WS}`,\n\t\t[opts.name, opts.slug, opts.ownerId],\n\t);\n\treturn workspace ?? null;\n}\n\nexport async function findPersonalWorkspace(\n\tuserId: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS} FROM fonderie_workspaces\n\t\t WHERE owner_id = $1 AND is_personal = true\n\t\t LIMIT 1`,\n\t\t[userId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function updateWorkspace(\n\tid: string,\n\topts: {\n\t\tname?: string;\n\t\tdescription?: string | null;\n\t\tslug?: string;\n\t\tmotto?: string | null;\n\t\tphone?: string | null;\n\t\tbusinessType?: string | null;\n\t\taddress?: IWorkspaceAddress | null;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst sets: string[] = ['updated_at = now()'];\n\tconst params: unknown[] = [id];\n\n\tif (opts.name !== undefined) {\n\t\tparams.push(opts.name);\n\t\tsets.push(`name = $${params.length}`);\n\t}\n\tif (opts.description !== undefined) {\n\t\tparams.push(opts.description);\n\t\tsets.push(`description = $${params.length}`);\n\t}\n\tif (opts.slug !== undefined) {\n\t\tparams.push(opts.slug);\n\t\tsets.push(`slug = $${params.length}`);\n\t}\n\tif (opts.motto !== undefined) {\n\t\tparams.push(opts.motto);\n\t\tsets.push(`motto = $${params.length}`);\n\t}\n\tif (opts.phone !== undefined) {\n\t\tparams.push(opts.phone);\n\t\tsets.push(`phone = $${params.length}`);\n\t}\n\tif (opts.businessType !== undefined) {\n\t\tparams.push(opts.businessType);\n\t\tsets.push(`business_type = $${params.length}`);\n\t}\n\tif (opts.address !== undefined) {\n\t\tparams.push(JSON.stringify(opts.address ?? {}));\n\t\tsets.push(`address = $${params.length}::jsonb`);\n\t}\n\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET ${sets.join(', ')}\n\t\t WHERE id = $1\n\t\t RETURNING ${SELECT_WS}`,\n\t\tparams,\n\t);\n\treturn row ?? null;\n}\n\nexport async function archiveWorkspace(\n\tid: string,\n\tbyUser: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET archived_at = now(), archived_by = $2, updated_at = now()\n\t\t WHERE id = $1 AND archived_at IS NULL`,\n\t\t[id, byUser],\n\t);\n}\n\nexport async function restoreWorkspace(id: string, store: IStoreAdapter): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET archived_at = NULL, archived_by = NULL, updated_at = now()\n\t\t WHERE id = $1`,\n\t\t[id],\n\t);\n}\n\nconst SETTINGS_DEFAULTS: IWorkspaceSettings = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tcurrency: 'USD',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\nexport async function getWorkspaceSettings(\n\tid: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspaceSettings> {\n\tconst [row] = await store.query<{ settings: Record<string, unknown> }>(\n\t\t`SELECT settings FROM fonderie_workspaces WHERE id = $1`,\n\t\t[id],\n\t);\n\tconst raw = row?.settings ?? {};\n\tconst s = (raw['settings'] as Record<string, unknown> | undefined) ?? raw;\n\n\treturn {\n\t\tlocale: typeof s['locale'] === 'string' ? s['locale'] : SETTINGS_DEFAULTS.locale,\n\t\ttimezone: typeof s['timezone'] === 'string' ? s['timezone'] : SETTINGS_DEFAULTS.timezone,\n\t\tcurrency: typeof s['currency'] === 'string' ? s['currency'] : SETTINGS_DEFAULTS.currency,\n\t\tdateFormat:\n\t\t\ttypeof s['dateFormat'] === 'string' ? s['dateFormat'] : SETTINGS_DEFAULTS.dateFormat,\n\t\ttimeFormat:\n\t\t\ttypeof s['timeFormat'] === 'string' ? s['timeFormat'] : SETTINGS_DEFAULTS.timeFormat,\n\t};\n}\n\nexport async function updateWorkspaceSettings(\n\tid: string,\n\tsettings: Partial<IWorkspaceSettings>,\n\tstore: IStoreAdapter,\n): Promise<IWorkspaceSettings> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET settings = settings || jsonb_build_object('settings', $2::jsonb),\n\t\t updated_at = now()\n\t\t WHERE id = $1`,\n\t\t[id, JSON.stringify(settings)],\n\t);\n\treturn getWorkspaceSettings(id, store);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,kBAAqC;AAK9B,IAAM,mBAA+B,OAAO,KAAK,SAAS;AAChE,MAAI,CAAC,IAAI,WAAW;AACnB,eAAO,4BAAe,iBAAK,aAAa,sBAAsB,4BAA4B;AAAA,EAC3F;AACA,SAAO,KAAK;AACb;;;ACVA,IAAAA,eAAqC;;;ACIrC,IAAM,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAatB,eAAsB,UACrB,QACA,aACA,OAC0B;AAC1B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASvB,CAAC,QAAQ,WAAW;AAAA,EACrB;AACA,SAAO,OAAO;AACf;;;AC/BA,IAAM,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsClB,eAAsB,kBACrB,IACA,OAC6B;AAC7B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,SAAS;AAAA,IACnB,CAAC,EAAE;AAAA,EACJ;AACA,SAAO,OAAO;AACf;AA+DA,eAAsB,sBACrB,QACA,OAC6B;AAC7B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,SAAS;AAAA;AAAA;AAAA,IAGnB,CAAC,MAAM;AAAA,EACR;AACA,SAAO,OAAO;AACf;;;AF/GA,SAAS,YAAY,OAAkC;AACtD,SAAO,OAAO,KAAK,SAAS;AAC3B,UAAM,SAAS,IAAI,KAAK,QAAQ;AAChC,UAAM,cACL,SAAS,aAAa,KACtB,SAAS,IAAI,KACb,IAAI,QAAQ,QAAQ,IAAI,gBAAgB,KACxC;AAED,QAAI,CAAC,aAAa;AAGjB,UAAI,IAAI,MAAM;AACb,cAAM,WAAW,MAAM,sBAAsB,IAAI,KAAK,IAAI,KAAK;AAC/D,YAAI,SAAU,QAAO,OAAO,KAAK,EAAE,WAAW,SAAS,CAAC;AAAA,MACzD;AACA,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,YAAY,MAAM,kBAAkB,aAAa,KAAK;AAC5D,QAAI,CAAC,WAAW;AACf,iBAAO,6BAAe,kBAAK,WAAW,aAAa,qBAAqB;AAAA,IACzE;AAEA,QAAI,IAAI,MAAM;AACb,YAAM,SAAS,MAAM,UAAU,IAAI,KAAK,IAAI,aAAa,KAAK;AAC9D,UAAI,CAAC,QAAQ;AACZ,mBAAO,6BAAe,kBAAK,WAAW,aAAa,gCAAgC;AAAA,MACpF;AAAA,IACD;AAEA,WAAO,OAAO,KAAK,EAAE,UAAU,CAAC;AAChC,WAAO,KAAK;AAAA,EACb;AACD;AAQO,SAAS,cACf,OACA,KACA,MACiC;AACjC,QAAM,UAAU,YAAY,KAAK;AACjC,MAAI,QAAQ,UAAa,SAAS,OAAW,QAAO,QAAQ,KAAK,IAAI;AACrE,SAAO;AACR;","names":["import_core"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/middlewares/require-workspace.ts","../../src/middlewares/workspace-context.ts","../../src/services/members.ts","../../src/services/workspaces.ts"],"sourcesContent":["import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\n\n// Ensures ctx.workspace is set — use after withWorkspace\n\nexport const requireWorkspace: Middleware = async (ctx, next) => {\n\tif (!ctx.workspace) {\n\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t}\n\treturn next();\n};\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { getMember } from '../services/members';\nimport { findWorkspaceById, findPersonalWorkspace } from '../services/workspaces';\n\n// Resolves ctx.workspace from:\n// 1. Route param :workspaceId or :id (path-based admin routes)\n// 2. X-Workspace-ID request header (standard resource routes)\n// Validates the current user is an active member.\n// Must run after withSession.\n\nfunction makeHandler(store: IStoreAdapter): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\t\tconst workspaceId =\n\t\t\tparams?.['workspaceId'] ??\n\t\t\tparams?.['id'] ??\n\t\t\tctx.request.headers.get('x-workspace-id') ??\n\t\t\tundefined;\n\n\t\tif (!workspaceId) {\n\t\t\t// DMZ fallback — if the caller didn't specify a workspace, use their personal one.\n\t\t\t// No membership check needed: the user is always the sole owner.\n\t\t\tif (ctx.user) {\n\t\t\t\tconst personal = await findPersonalWorkspace(ctx.user.id, store);\n\t\t\t\tif (personal) Object.assign(ctx, { workspace: personal });\n\t\t\t}\n\t\t\treturn next();\n\t\t}\n\n\t\tconst workspace = await findWorkspaceById(workspaceId, store);\n\t\tif (!workspace) {\n\t\t\treturn setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'Workspace not found');\n\t\t}\n\n\t\tif (ctx.user) {\n\t\t\tconst member = await getMember(ctx.user.id, workspaceId, store);\n\t\t\tif (!member) {\n\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Not a member of this workspace');\n\t\t\t}\n\t\t}\n\n\t\tObject.assign(ctx, { workspace });\n\t\treturn next();\n\t};\n}\n\nexport function withWorkspace(store: IStoreAdapter): Middleware;\nexport function withWorkspace(\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function withWorkspace(\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMember, IRole } from '../types';\n\nconst SELECT_MEMBER = `\n\truw.user_id AS \"userId\",\n\truw.workspace_id AS \"workspaceId\",\n\truw.role_id AS \"roleId\",\n\tr.name AS \"roleName\",\n\truw.confirmed AS \"confirmed\",\n\truw.created_at AS \"createdAt\",\n\tu.first_name AS \"firstName\",\n\tu.last_name AS \"lastName\",\n\tu.email AS \"email\",\n\tu.profile_image_url AS \"profileImageUrl\"\n`;\n\nexport async function getMember(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMember | null> {\n\tconst [row] = await store.query<IMember>(\n\t\t`SELECT ${SELECT_MEMBER}\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t LEFT JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t LEFT JOIN fonderie_users u ON u.id = ruw.user_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function listMembers(workspaceId: string, store: IStoreAdapter): Promise<IMember[]> {\n\treturn store.query<IMember>(\n\t\t`SELECT ${SELECT_MEMBER}\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t LEFT JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t LEFT JOIN fonderie_users u ON u.id = ruw.user_id\n\t\t WHERE ruw.workspace_id = $1\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ORDER BY ruw.created_at ASC`,\n\t\t[workspaceId],\n\t);\n}\n\nexport async function addMember(\n\topts: { userId: string; workspaceId: string; roleId: string; confirmed?: boolean },\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`INSERT INTO fonderie_role_user_workspaces (user_id, workspace_id, role_id, confirmed)\n\t\t VALUES ($1, $2, $3, $4)\n\t\t ON CONFLICT (user_id, workspace_id, role_id) DO UPDATE\n\t\t SET confirmed = $4, removed = false, suspended = false`,\n\t\t[opts.userId, opts.workspaceId, opts.roleId, opts.confirmed ?? true],\n\t);\n}\n\nexport async function removeMember(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_role_user_workspaces\n\t\t SET removed = true\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2`,\n\t\t[userId, workspaceId],\n\t);\n}\n\nexport async function getUserRoles(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IRole[]> {\n\treturn store.query<IRole>(\n\t\t`SELECT\n\t\t r.id,\n\t\t r.name,\n\t\t r.is_system AS \"isSystem\",\n\t\t r.active,\n\t\t r.description,\n\t\t r.workspace_id AS \"workspaceId\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n}\n\nexport async function addRoleToMember(\n\tuserId: string,\n\tworkspaceId: string,\n\troleId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`INSERT INTO fonderie_role_user_workspaces (user_id, workspace_id, role_id, confirmed)\n\t\t VALUES ($1, $2, $3, true)\n\t\t ON CONFLICT (user_id, workspace_id, role_id) DO UPDATE\n\t\t SET confirmed = true, removed = false, suspended = false`,\n\t\t[userId, workspaceId, roleId],\n\t);\n}\n\nexport async function removeRoleFromMember(\n\tuserId: string,\n\tworkspaceId: string,\n\troleId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tconst remaining = await store.query<{ count: string }>(\n\t\t`SELECT COUNT(*) AS count\n\t\t FROM fonderie_role_user_workspaces\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2\n\t\t AND removed = false`,\n\t\t[userId, workspaceId],\n\t);\n\tconst count = parseInt(remaining[0]?.count ?? '0', 10);\n\tif (count <= 1) throw new Error('Cannot remove last role from member');\n\n\tawait store.query(\n\t\t`DELETE FROM fonderie_role_user_workspaces\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2\n\t\t AND role_id = $3`,\n\t\t[userId, workspaceId, roleId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IWorkspace, IWorkspaceAddress, IWorkspaceSettings } from '../types';\n\nconst SELECT_WS = `\n\tid,\n\tname,\n\tslug,\n\ttype,\n\tdescription,\n\tmotto,\n\tphone,\n\tbusiness_type AS \"businessType\",\n\taddress,\n\tplan,\n\towner_id AS \"ownerId\",\n\tis_personal AS \"isPersonal\",\n\tarchived_at AS \"archivedAt\",\n\tarchived_by AS \"archivedBy\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nconst SELECT_WS_W = `\n\tw.id,\n\tw.name,\n\tw.slug,\n\tw.type,\n\tw.description,\n\tw.motto,\n\tw.phone,\n\tw.business_type AS \"businessType\",\n\tw.address,\n\tw.plan,\n\tw.owner_id AS \"ownerId\",\n\tw.is_personal AS \"isPersonal\",\n\tw.archived_at AS \"archivedAt\",\n\tw.archived_by AS \"archivedBy\",\n\tw.created_at AS \"createdAt\",\n\tw.updated_at AS \"updatedAt\"\n`;\n\nexport async function findWorkspaceById(\n\tid: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS} FROM fonderie_workspaces WHERE id = $1`,\n\t\t[id],\n\t);\n\treturn row ?? null;\n}\n\nexport async function findWorkspacesByUserId(\n\tuserId: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace[]> {\n\treturn store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS_W}\n\t\t FROM fonderie_workspaces w\n\t\t JOIN fonderie_role_user_workspaces ruw ON ruw.workspace_id = w.id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t GROUP BY w.id\n\t\t ORDER BY w.created_at ASC`,\n\t\t[userId],\n\t);\n}\n\nexport async function createWorkspace(\n\topts: {\n\t\tname: string;\n\t\tslug: string;\n\t\townerId: string;\n\t\ttype?: string;\n\t\tdescription?: string;\n\t\tplan?: string;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWorkspace> {\n\tconst [workspace] = await store.query<IWorkspace>(\n\t\t`INSERT INTO fonderie_workspaces (name, slug, owner_id, type, description, plan)\n\t\t VALUES ($1, $2, $3, $4, $5, $6)\n\t\t RETURNING ${SELECT_WS}`,\n\t\t[\n\t\t\topts.name,\n\t\t\topts.slug,\n\t\t\topts.ownerId,\n\t\t\topts.type ?? 'ORGANIZATION',\n\t\t\topts.description ?? null,\n\t\t\topts.plan ?? 'free',\n\t\t],\n\t);\n\n\tif (!workspace) throw new Error('Failed to create workspace');\n\treturn workspace;\n}\n\n// Returns the new workspace, or null if the personal workspace already exists (idempotent).\nexport async function createPersonalWorkspace(\n\topts: { name: string; slug: string; ownerId: string },\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [workspace] = await store.query<IWorkspace>(\n\t\t`INSERT INTO fonderie_workspaces (name, slug, owner_id, type, is_personal)\n\t\t VALUES ($1, $2, $3, 'PERSONAL', true)\n\t\t ON CONFLICT (owner_id) WHERE is_personal = true DO NOTHING\n\t\t RETURNING ${SELECT_WS}`,\n\t\t[opts.name, opts.slug, opts.ownerId],\n\t);\n\treturn workspace ?? null;\n}\n\nexport async function findPersonalWorkspace(\n\tuserId: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS} FROM fonderie_workspaces\n\t\t WHERE owner_id = $1 AND is_personal = true\n\t\t LIMIT 1`,\n\t\t[userId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function updateWorkspace(\n\tid: string,\n\topts: {\n\t\tname?: string;\n\t\tdescription?: string | null;\n\t\tslug?: string;\n\t\tmotto?: string | null;\n\t\tphone?: string | null;\n\t\tbusinessType?: string | null;\n\t\taddress?: IWorkspaceAddress | null;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst sets: string[] = ['updated_at = now()'];\n\tconst params: unknown[] = [id];\n\n\tif (opts.name !== undefined) {\n\t\tparams.push(opts.name);\n\t\tsets.push(`name = $${params.length}`);\n\t}\n\tif (opts.description !== undefined) {\n\t\tparams.push(opts.description);\n\t\tsets.push(`description = $${params.length}`);\n\t}\n\tif (opts.slug !== undefined) {\n\t\tparams.push(opts.slug);\n\t\tsets.push(`slug = $${params.length}`);\n\t}\n\tif (opts.motto !== undefined) {\n\t\tparams.push(opts.motto);\n\t\tsets.push(`motto = $${params.length}`);\n\t}\n\tif (opts.phone !== undefined) {\n\t\tparams.push(opts.phone);\n\t\tsets.push(`phone = $${params.length}`);\n\t}\n\tif (opts.businessType !== undefined) {\n\t\tparams.push(opts.businessType);\n\t\tsets.push(`business_type = $${params.length}`);\n\t}\n\tif (opts.address !== undefined) {\n\t\tparams.push(JSON.stringify(opts.address ?? {}));\n\t\tsets.push(`address = $${params.length}::jsonb`);\n\t}\n\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET ${sets.join(', ')}\n\t\t WHERE id = $1\n\t\t RETURNING ${SELECT_WS}`,\n\t\tparams,\n\t);\n\treturn row ?? null;\n}\n\nexport async function archiveWorkspace(\n\tid: string,\n\tbyUser: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET archived_at = now(), archived_by = $2, updated_at = now()\n\t\t WHERE id = $1 AND archived_at IS NULL`,\n\t\t[id, byUser],\n\t);\n}\n\nexport async function restoreWorkspace(id: string, store: IStoreAdapter): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET archived_at = NULL, archived_by = NULL, updated_at = now()\n\t\t WHERE id = $1`,\n\t\t[id],\n\t);\n}\n\nconst SETTINGS_DEFAULTS: IWorkspaceSettings = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tcurrency: 'USD',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\nexport async function getWorkspaceSettings(\n\tid: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspaceSettings> {\n\tconst [row] = await store.query<{ settings: Record<string, unknown> }>(\n\t\t`SELECT settings FROM fonderie_workspaces WHERE id = $1`,\n\t\t[id],\n\t);\n\tconst raw = row?.settings ?? {};\n\tconst s = (raw['settings'] as Record<string, unknown> | undefined) ?? raw;\n\n\treturn {\n\t\tlocale: typeof s['locale'] === 'string' ? s['locale'] : SETTINGS_DEFAULTS.locale,\n\t\ttimezone: typeof s['timezone'] === 'string' ? s['timezone'] : SETTINGS_DEFAULTS.timezone,\n\t\tcurrency: typeof s['currency'] === 'string' ? s['currency'] : SETTINGS_DEFAULTS.currency,\n\t\tdateFormat:\n\t\t\ttypeof s['dateFormat'] === 'string' ? s['dateFormat'] : SETTINGS_DEFAULTS.dateFormat,\n\t\ttimeFormat:\n\t\t\ttypeof s['timeFormat'] === 'string' ? s['timeFormat'] : SETTINGS_DEFAULTS.timeFormat,\n\t};\n}\n\nexport async function updateWorkspaceSettings(\n\tid: string,\n\tsettings: Partial<IWorkspaceSettings>,\n\tstore: IStoreAdapter,\n): Promise<IWorkspaceSettings> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET settings = settings || jsonb_build_object('settings', $2::jsonb),\n\t\t updated_at = now()\n\t\t WHERE id = $1`,\n\t\t[id, JSON.stringify(settings)],\n\t);\n\treturn getWorkspaceSettings(id, store);\n}\n"],"mappings":";AAAA,SAAS,gBAAgB,YAAY;AAK9B,IAAM,mBAA+B,OAAO,KAAK,SAAS;AAChE,MAAI,CAAC,IAAI,WAAW;AACnB,WAAO,eAAe,KAAK,aAAa,sBAAsB,4BAA4B;AAAA,EAC3F;AACA,SAAO,KAAK;AACb;;;ACVA,SAAS,kBAAAA,iBAAgB,QAAAC,aAAY;;;ACIrC,IAAM,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAatB,eAAsB,UACrB,QACA,aACA,OAC0B;AAC1B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASvB,CAAC,QAAQ,WAAW;AAAA,EACrB;AACA,SAAO,OAAO;AACf;;;AC/BA,IAAM,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsClB,eAAsB,kBACrB,IACA,OAC6B;AAC7B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,SAAS;AAAA,IACnB,CAAC,EAAE;AAAA,EACJ;AACA,SAAO,OAAO;AACf;AA+DA,eAAsB,sBACrB,QACA,OAC6B;AAC7B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,SAAS;AAAA;AAAA;AAAA,IAGnB,CAAC,MAAM;AAAA,EACR;AACA,SAAO,OAAO;AACf;;;AF/GA,SAAS,YAAY,OAAkC;AACtD,SAAO,OAAO,KAAK,SAAS;AAC3B,UAAM,SAAS,IAAI,KAAK,QAAQ;AAChC,UAAM,cACL,SAAS,aAAa,KACtB,SAAS,IAAI,KACb,IAAI,QAAQ,QAAQ,IAAI,gBAAgB,KACxC;AAED,QAAI,CAAC,aAAa;AAGjB,UAAI,IAAI,MAAM;AACb,cAAM,WAAW,MAAM,sBAAsB,IAAI,KAAK,IAAI,KAAK;AAC/D,YAAI,SAAU,QAAO,OAAO,KAAK,EAAE,WAAW,SAAS,CAAC;AAAA,MACzD;AACA,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,YAAY,MAAM,kBAAkB,aAAa,KAAK;AAC5D,QAAI,CAAC,WAAW;AACf,aAAOC,gBAAeC,MAAK,WAAW,aAAa,qBAAqB;AAAA,IACzE;AAEA,QAAI,IAAI,MAAM;AACb,YAAM,SAAS,MAAM,UAAU,IAAI,KAAK,IAAI,aAAa,KAAK;AAC9D,UAAI,CAAC,QAAQ;AACZ,eAAOD,gBAAeC,MAAK,WAAW,aAAa,gCAAgC;AAAA,MACpF;AAAA,IACD;AAEA,WAAO,OAAO,KAAK,EAAE,UAAU,CAAC;AAChC,WAAO,KAAK;AAAA,EACb;AACD;AAQO,SAAS,cACf,OACA,KACA,MACiC;AACjC,QAAM,UAAU,YAAY,KAAK;AACjC,MAAI,QAAQ,UAAa,SAAS,OAAW,QAAO,QAAQ,KAAK,IAAI;AACrE,SAAO;AACR;","names":["setApiResponse","HTTP","setApiResponse","HTTP"]}
|
|
1
|
+
{"version":3,"sources":["../../src/middlewares/require-workspace.ts","../../src/middlewares/workspace-context.ts","../../src/services/members.ts","../../src/services/workspaces.ts"],"sourcesContent":["import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\n\n// Ensures ctx.workspace is set — use after withWorkspace\n\nexport const requireWorkspace: Middleware = async (ctx, next) => {\n\tif (!ctx.workspace) {\n\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t}\n\treturn next();\n};\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { getMember } from '../services/members';\nimport { findWorkspaceById, findPersonalWorkspace } from '../services/workspaces';\n\n// Resolves ctx.workspace from:\n// 1. Route param :workspaceId or :id (path-based admin routes)\n// 2. X-Workspace-ID request header (standard resource routes)\n// Validates the current user is an active member.\n// Must run after withSession.\n\nfunction makeHandler(store: IStoreAdapter): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\t\tconst workspaceId =\n\t\t\tparams?.['workspaceId'] ??\n\t\t\tparams?.['id'] ??\n\t\t\tctx.request.headers.get('x-workspace-id') ??\n\t\t\tundefined;\n\n\t\tif (!workspaceId) {\n\t\t\t// DMZ fallback — if the caller didn't specify a workspace, use their personal one.\n\t\t\t// No membership check needed: the user is always the sole owner.\n\t\t\tif (ctx.user) {\n\t\t\t\tconst personal = await findPersonalWorkspace(ctx.user.id, store);\n\t\t\t\tif (personal) Object.assign(ctx, { workspace: personal });\n\t\t\t}\n\t\t\treturn next();\n\t\t}\n\n\t\tconst workspace = await findWorkspaceById(workspaceId, store);\n\t\tif (!workspace) {\n\t\t\treturn setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'Workspace not found');\n\t\t}\n\n\t\tif (ctx.user) {\n\t\t\tconst member = await getMember(ctx.user.id, workspaceId, store);\n\t\t\tif (!member) {\n\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Not a member of this workspace');\n\t\t\t}\n\t\t}\n\n\t\tObject.assign(ctx, { workspace });\n\t\treturn next();\n\t};\n}\n\nexport function withWorkspace(store: IStoreAdapter): Middleware;\nexport function withWorkspace(\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function withWorkspace(\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMember, IRole } from '../types';\n\nconst SELECT_MEMBER = `\n\truw.user_id AS \"userId\",\n\truw.workspace_id AS \"workspaceId\",\n\truw.role_id AS \"roleId\",\n\tr.name AS \"roleName\",\n\truw.confirmed AS \"confirmed\",\n\truw.created_at AS \"createdAt\",\n\tu.first_name AS \"firstName\",\n\tu.last_name AS \"lastName\",\n\tu.email AS \"email\",\n\tu.profile_image_url AS \"profileImageUrl\"\n`;\n\nexport async function getMember(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMember | null> {\n\tconst [row] = await store.query<IMember>(\n\t\t`SELECT ${SELECT_MEMBER}\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t LEFT JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t LEFT JOIN fonderie_users u ON u.id = ruw.user_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function listMembers(workspaceId: string, store: IStoreAdapter): Promise<IMember[]> {\n\treturn store.query<IMember>(\n\t\t`SELECT ${SELECT_MEMBER}\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t LEFT JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t LEFT JOIN fonderie_users u ON u.id = ruw.user_id\n\t\t WHERE ruw.workspace_id = $1\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ORDER BY ruw.created_at ASC`,\n\t\t[workspaceId],\n\t);\n}\n\nexport async function addMember(\n\topts: { userId: string; workspaceId: string; roleId: string; confirmed?: boolean },\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`INSERT INTO fonderie_role_user_workspaces (user_id, workspace_id, role_id, confirmed)\n\t\t VALUES ($1, $2, $3, $4)\n\t\t ON CONFLICT (user_id, workspace_id, role_id) DO UPDATE\n\t\t SET confirmed = $4, removed = false, suspended = false`,\n\t\t[opts.userId, opts.workspaceId, opts.roleId, opts.confirmed ?? true],\n\t);\n}\n\nexport async function removeMember(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_role_user_workspaces\n\t\t SET removed = true\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2`,\n\t\t[userId, workspaceId],\n\t);\n}\n\nexport async function getUserRoles(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IRole[]> {\n\treturn store.query<IRole>(\n\t\t`SELECT\n\t\t r.id,\n\t\t r.name,\n\t\t r.is_system AS \"isSystem\",\n\t\t r.active,\n\t\t r.description,\n\t\t r.workspace_id AS \"workspaceId\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n}\n\n/**\n * Assign an existing role to a member via the HTTP surface. Returns false when\n * the role is not assignable — the insert only proceeds for a role that belongs\n * to THIS workspace and is NOT a system role. This is the privilege-escalation\n * guard: system roles carry the super-role bypass (@fonderie/permissions), so a\n * member must never be able to self-grant one (e.g. the seeded ADMIN role) to\n * gain full access; cross-workspace role ids must not be assignable either.\n * Seeding of system/owner roles at workspace-creation / invitation time goes\n * through addMember(), not this path, so that flow is unaffected.\n */\nexport async function addRoleToMember(\n\tuserId: string,\n\tworkspaceId: string,\n\troleId: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst rows = await store.query<{ user_id: string }>(\n\t\t`INSERT INTO fonderie_role_user_workspaces (user_id, workspace_id, role_id, confirmed)\n\t\t SELECT $1, $2, $3, true\n\t\t WHERE EXISTS (\n\t\t SELECT 1 FROM fonderie_roles r\n\t\t WHERE r.id = $3\n\t\t AND r.workspace_id = $2\n\t\t AND r.is_system = false\n\t\t )\n\t\t ON CONFLICT (user_id, workspace_id, role_id) DO UPDATE\n\t\t SET confirmed = true, removed = false, suspended = false\n\t\t RETURNING user_id`,\n\t\t[userId, workspaceId, roleId],\n\t);\n\treturn rows.length > 0;\n}\n\nexport async function removeRoleFromMember(\n\tuserId: string,\n\tworkspaceId: string,\n\troleId: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tconst remaining = await store.query<{ count: string }>(\n\t\t`SELECT COUNT(*) AS count\n\t\t FROM fonderie_role_user_workspaces\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2\n\t\t AND removed = false`,\n\t\t[userId, workspaceId],\n\t);\n\tconst count = parseInt(remaining[0]?.count ?? '0', 10);\n\tif (count <= 1) throw new Error('Cannot remove last role from member');\n\n\tawait store.query(\n\t\t`DELETE FROM fonderie_role_user_workspaces\n\t\t WHERE user_id = $1\n\t\t AND workspace_id = $2\n\t\t AND role_id = $3`,\n\t\t[userId, workspaceId, roleId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IWorkspace, IWorkspaceAddress, IWorkspaceSettings } from '../types';\n\nconst SELECT_WS = `\n\tid,\n\tname,\n\tslug,\n\ttype,\n\tdescription,\n\tmotto,\n\tphone,\n\tbusiness_type AS \"businessType\",\n\taddress,\n\tplan,\n\towner_id AS \"ownerId\",\n\tis_personal AS \"isPersonal\",\n\tarchived_at AS \"archivedAt\",\n\tarchived_by AS \"archivedBy\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nconst SELECT_WS_W = `\n\tw.id,\n\tw.name,\n\tw.slug,\n\tw.type,\n\tw.description,\n\tw.motto,\n\tw.phone,\n\tw.business_type AS \"businessType\",\n\tw.address,\n\tw.plan,\n\tw.owner_id AS \"ownerId\",\n\tw.is_personal AS \"isPersonal\",\n\tw.archived_at AS \"archivedAt\",\n\tw.archived_by AS \"archivedBy\",\n\tw.created_at AS \"createdAt\",\n\tw.updated_at AS \"updatedAt\"\n`;\n\nexport async function findWorkspaceById(\n\tid: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS} FROM fonderie_workspaces WHERE id = $1`,\n\t\t[id],\n\t);\n\treturn row ?? null;\n}\n\nexport async function findWorkspacesByUserId(\n\tuserId: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace[]> {\n\treturn store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS_W}\n\t\t FROM fonderie_workspaces w\n\t\t JOIN fonderie_role_user_workspaces ruw ON ruw.workspace_id = w.id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t GROUP BY w.id\n\t\t ORDER BY w.created_at ASC`,\n\t\t[userId],\n\t);\n}\n\nexport async function createWorkspace(\n\topts: {\n\t\tname: string;\n\t\tslug: string;\n\t\townerId: string;\n\t\ttype?: string;\n\t\tdescription?: string;\n\t\tplan?: string;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWorkspace> {\n\tconst [workspace] = await store.query<IWorkspace>(\n\t\t`INSERT INTO fonderie_workspaces (name, slug, owner_id, type, description, plan)\n\t\t VALUES ($1, $2, $3, $4, $5, $6)\n\t\t RETURNING ${SELECT_WS}`,\n\t\t[\n\t\t\topts.name,\n\t\t\topts.slug,\n\t\t\topts.ownerId,\n\t\t\topts.type ?? 'ORGANIZATION',\n\t\t\topts.description ?? null,\n\t\t\topts.plan ?? 'free',\n\t\t],\n\t);\n\n\tif (!workspace) throw new Error('Failed to create workspace');\n\treturn workspace;\n}\n\n// Returns the new workspace, or null if the personal workspace already exists (idempotent).\nexport async function createPersonalWorkspace(\n\topts: { name: string; slug: string; ownerId: string },\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [workspace] = await store.query<IWorkspace>(\n\t\t`INSERT INTO fonderie_workspaces (name, slug, owner_id, type, is_personal)\n\t\t VALUES ($1, $2, $3, 'PERSONAL', true)\n\t\t ON CONFLICT (owner_id) WHERE is_personal = true DO NOTHING\n\t\t RETURNING ${SELECT_WS}`,\n\t\t[opts.name, opts.slug, opts.ownerId],\n\t);\n\treturn workspace ?? null;\n}\n\nexport async function findPersonalWorkspace(\n\tuserId: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`SELECT ${SELECT_WS} FROM fonderie_workspaces\n\t\t WHERE owner_id = $1 AND is_personal = true\n\t\t LIMIT 1`,\n\t\t[userId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function updateWorkspace(\n\tid: string,\n\topts: {\n\t\tname?: string;\n\t\tdescription?: string | null;\n\t\tslug?: string;\n\t\tmotto?: string | null;\n\t\tphone?: string | null;\n\t\tbusinessType?: string | null;\n\t\taddress?: IWorkspaceAddress | null;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWorkspace | null> {\n\tconst sets: string[] = ['updated_at = now()'];\n\tconst params: unknown[] = [id];\n\n\tif (opts.name !== undefined) {\n\t\tparams.push(opts.name);\n\t\tsets.push(`name = $${params.length}`);\n\t}\n\tif (opts.description !== undefined) {\n\t\tparams.push(opts.description);\n\t\tsets.push(`description = $${params.length}`);\n\t}\n\tif (opts.slug !== undefined) {\n\t\tparams.push(opts.slug);\n\t\tsets.push(`slug = $${params.length}`);\n\t}\n\tif (opts.motto !== undefined) {\n\t\tparams.push(opts.motto);\n\t\tsets.push(`motto = $${params.length}`);\n\t}\n\tif (opts.phone !== undefined) {\n\t\tparams.push(opts.phone);\n\t\tsets.push(`phone = $${params.length}`);\n\t}\n\tif (opts.businessType !== undefined) {\n\t\tparams.push(opts.businessType);\n\t\tsets.push(`business_type = $${params.length}`);\n\t}\n\tif (opts.address !== undefined) {\n\t\tparams.push(JSON.stringify(opts.address ?? {}));\n\t\tsets.push(`address = $${params.length}::jsonb`);\n\t}\n\n\tconst [row] = await store.query<IWorkspace>(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET ${sets.join(', ')}\n\t\t WHERE id = $1\n\t\t RETURNING ${SELECT_WS}`,\n\t\tparams,\n\t);\n\treturn row ?? null;\n}\n\nexport async function archiveWorkspace(\n\tid: string,\n\tbyUser: string,\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET archived_at = now(), archived_by = $2, updated_at = now()\n\t\t WHERE id = $1 AND archived_at IS NULL`,\n\t\t[id, byUser],\n\t);\n}\n\nexport async function restoreWorkspace(id: string, store: IStoreAdapter): Promise<void> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET archived_at = NULL, archived_by = NULL, updated_at = now()\n\t\t WHERE id = $1`,\n\t\t[id],\n\t);\n}\n\nconst SETTINGS_DEFAULTS: IWorkspaceSettings = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tcurrency: 'USD',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\nexport async function getWorkspaceSettings(\n\tid: string,\n\tstore: IStoreAdapter,\n): Promise<IWorkspaceSettings> {\n\tconst [row] = await store.query<{ settings: Record<string, unknown> }>(\n\t\t`SELECT settings FROM fonderie_workspaces WHERE id = $1`,\n\t\t[id],\n\t);\n\tconst raw = row?.settings ?? {};\n\tconst s = (raw['settings'] as Record<string, unknown> | undefined) ?? raw;\n\n\treturn {\n\t\tlocale: typeof s['locale'] === 'string' ? s['locale'] : SETTINGS_DEFAULTS.locale,\n\t\ttimezone: typeof s['timezone'] === 'string' ? s['timezone'] : SETTINGS_DEFAULTS.timezone,\n\t\tcurrency: typeof s['currency'] === 'string' ? s['currency'] : SETTINGS_DEFAULTS.currency,\n\t\tdateFormat:\n\t\t\ttypeof s['dateFormat'] === 'string' ? s['dateFormat'] : SETTINGS_DEFAULTS.dateFormat,\n\t\ttimeFormat:\n\t\t\ttypeof s['timeFormat'] === 'string' ? s['timeFormat'] : SETTINGS_DEFAULTS.timeFormat,\n\t};\n}\n\nexport async function updateWorkspaceSettings(\n\tid: string,\n\tsettings: Partial<IWorkspaceSettings>,\n\tstore: IStoreAdapter,\n): Promise<IWorkspaceSettings> {\n\tawait store.query(\n\t\t`UPDATE fonderie_workspaces\n\t\t SET settings = settings || jsonb_build_object('settings', $2::jsonb),\n\t\t updated_at = now()\n\t\t WHERE id = $1`,\n\t\t[id, JSON.stringify(settings)],\n\t);\n\treturn getWorkspaceSettings(id, store);\n}\n"],"mappings":";AAAA,SAAS,gBAAgB,YAAY;AAK9B,IAAM,mBAA+B,OAAO,KAAK,SAAS;AAChE,MAAI,CAAC,IAAI,WAAW;AACnB,WAAO,eAAe,KAAK,aAAa,sBAAsB,4BAA4B;AAAA,EAC3F;AACA,SAAO,KAAK;AACb;;;ACVA,SAAS,kBAAAA,iBAAgB,QAAAC,aAAY;;;ACIrC,IAAM,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAatB,eAAsB,UACrB,QACA,aACA,OAC0B;AAC1B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASvB,CAAC,QAAQ,WAAW;AAAA,EACrB;AACA,SAAO,OAAO;AACf;;;AC/BA,IAAM,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsClB,eAAsB,kBACrB,IACA,OAC6B;AAC7B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,SAAS;AAAA,IACnB,CAAC,EAAE;AAAA,EACJ;AACA,SAAO,OAAO;AACf;AA+DA,eAAsB,sBACrB,QACA,OAC6B;AAC7B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,UAAU,SAAS;AAAA;AAAA;AAAA,IAGnB,CAAC,MAAM;AAAA,EACR;AACA,SAAO,OAAO;AACf;;;AF/GA,SAAS,YAAY,OAAkC;AACtD,SAAO,OAAO,KAAK,SAAS;AAC3B,UAAM,SAAS,IAAI,KAAK,QAAQ;AAChC,UAAM,cACL,SAAS,aAAa,KACtB,SAAS,IAAI,KACb,IAAI,QAAQ,QAAQ,IAAI,gBAAgB,KACxC;AAED,QAAI,CAAC,aAAa;AAGjB,UAAI,IAAI,MAAM;AACb,cAAM,WAAW,MAAM,sBAAsB,IAAI,KAAK,IAAI,KAAK;AAC/D,YAAI,SAAU,QAAO,OAAO,KAAK,EAAE,WAAW,SAAS,CAAC;AAAA,MACzD;AACA,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,YAAY,MAAM,kBAAkB,aAAa,KAAK;AAC5D,QAAI,CAAC,WAAW;AACf,aAAOC,gBAAeC,MAAK,WAAW,aAAa,qBAAqB;AAAA,IACzE;AAEA,QAAI,IAAI,MAAM;AACb,YAAM,SAAS,MAAM,UAAU,IAAI,KAAK,IAAI,aAAa,KAAK;AAC9D,UAAI,CAAC,QAAQ;AACZ,eAAOD,gBAAeC,MAAK,WAAW,aAAa,gCAAgC;AAAA,MACpF;AAAA,IACD;AAEA,WAAO,OAAO,KAAK,EAAE,UAAU,CAAC;AAChC,WAAO,KAAK;AAAA,EACb;AACD;AAQO,SAAS,cACf,OACA,KACA,MACiC;AACjC,QAAM,UAAU,YAAY,KAAK;AACjC,MAAI,QAAQ,UAAa,SAAS,OAAW,QAAO,QAAQ,KAAK,IAAI;AACrE,SAAO;AACR;","names":["setApiResponse","HTTP","setApiResponse","HTTP"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/workspaces",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.2",
|
|
4
4
|
"description": "Multi-tenant team layer — create workspaces, manage members, define custom roles, handle token-based invitations, and scope any route to a specific workspace.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fonderiejs",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"check": "biome check --write src"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@fonderie/core": "^0.
|
|
55
|
+
"@fonderie/core": "^0.9.0",
|
|
56
56
|
"@fonderie/events": "^5.0.0",
|
|
57
57
|
"@fonderie/store": "^0.2.0"
|
|
58
58
|
},
|
|
@@ -66,9 +66,9 @@
|
|
|
66
66
|
"@fonderie/core": "../core",
|
|
67
67
|
"@fonderie/events": "../events",
|
|
68
68
|
"@fonderie/store": "../store",
|
|
69
|
-
"@types/node": "^26.4.
|
|
69
|
+
"@types/node": "^26.4.1",
|
|
70
70
|
"tsup": "^8.5.1",
|
|
71
|
-
"tsx": "^4.23.
|
|
71
|
+
"tsx": "^4.23.13",
|
|
72
72
|
"typescript": "^6.0.3"
|
|
73
73
|
},
|
|
74
74
|
"publishConfig": {
|
|
@@ -90,6 +90,6 @@
|
|
|
90
90
|
"url": "https://github.com/fonderiejs/fonderie/issues"
|
|
91
91
|
},
|
|
92
92
|
"dependencies": {
|
|
93
|
-
"zod": "^4.5.
|
|
93
|
+
"zod": "^4.5.4"
|
|
94
94
|
}
|
|
95
95
|
}
|