export interface VisibilityWhere { clause: string; params: unknown[]; } export interface VisibilityOptions { /** * 行がスペースに属する場合の space_id 列名(例 `'space_id'`、spaces 自身は `'id'`)。 * 指定かつ非 admin のとき、可視性節に「スペースのメンバーなら閲覧可」の OR ブランチを足す。 * **コード制御のリテラルのみ**(tableAlias と同じ信頼レベル)。ユーザー入力を渡してはならない。 */ spaceColumn?: string; } export function buildVisibilityWhere( user: Express.User, tableAlias: string, opts?: VisibilityOptions, ): VisibilityWhere { if (user.role === 'admin') { return { clause: '1=1', params: [] }; } // Synthetic users (no-auth 'local' user, internal callers) may omit orgIds — // treat as no org membership rather than throwing on `.length`. const orgIds = user.orgIds ?? []; const orgPlaceholders = orgIds.length > 0 ? orgIds.map(() => '?').join(',') : 'NULL'; // 既存の params 順は [user.id, ...user.orgIds]。membership の user.id は必ず末尾に足す(順序が重要)。 const params: unknown[] = [user.id, ...orgIds]; let membershipBranch = ''; if (opts?.spaceColumn) { // スペース行の閲覧資格 = 明示メンバー(space_members)OR スペースの根オーナー // (spaces.owner_id)。根オーナーは通常 space_members 行を持たないため、 // owner サブクエリを足さないと自分のスペースの「他メンバーの行」が見えない(gap-1)。 // user.id を clause 出現順(members → spaces)に 2 回 push する。 membershipBranch = ` OR (${tableAlias}.${opts.spaceColumn} IN (SELECT space_id FROM space_members WHERE user_id = ?)` + ` OR ${tableAlias}.${opts.spaceColumn} IN (SELECT id FROM spaces WHERE owner_id = ?))`; params.push(user.id, user.id); } return { clause: `( ${tableAlias}.owner_id = ? OR ${tableAlias}.visibility = 'public' OR (${tableAlias}.visibility = 'org' AND ${tableAlias}.visibility_scope_org_id IN (${orgPlaceholders}))${membershipBranch} )`.replace(/\s+/g, ' ').trim(), params, }; } /** * `spaces` テーブル専用の可視性 WHERE。通常の行可視性と決定的に違うのは * **個人スペース(kind='personal')は所有者本人にしか見えない(admin も例外でない)** * という点。これがないと admin の listSpaces が他ユーザーの個人スペースを拾い、 * UI が「最初に見つかった personal」を自分のワークスペースと誤認する(誤った * members-owner / AGENTS.md / MCP / 設定で動く root cause)。 * * - `kind='personal'` 行: `s.owner_id = ?`(viewer.id)のみ。role を問わない。 * - `kind='case'` 行: 従来の `buildVisibilityWhere`(admin=1=1 / owner / org / * public / space メンバーシップ OR ブランチ)をそのまま適用。 * * param 順は SQL の出現順に合わせる: 先頭に personal-owner の viewer.id、続いて * `buildVisibilityWhere` が返す params(admin のときは空)。`tableAlias` は呼び出し * 側のコード制御リテラルのみ(buildVisibilityWhere と同じ信頼レベル)。 */ export function buildSpaceVisibilityWhere( user: Express.User, tableAlias: string, ): VisibilityWhere { const base = buildVisibilityWhere(user, tableAlias, { spaceColumn: 'id' }); const clause = `( (${tableAlias}.kind = 'personal' AND ${tableAlias}.owner_id = ?) OR (${tableAlias}.kind != 'personal' AND ${base.clause}) )`.replace(/\s+/g, ' ').trim(); // 出現順: personal-owner の viewer.id → base の params。 return { clause, params: [user.id, ...base.params] }; } export function canEditEntity(user: Express.User, entity: { ownerId: string | null }): boolean { return user.role === 'admin' || entity.ownerId === user.id; } export type SpaceMemberRole = 'owner' | 'editor' | 'viewer'; /** * スペースの管理操作(メンバー管理・スペース設定変更・archive)を許可するか。 * admin / 根オーナー(space.ownerId)/ member.role==='owner' なら true。 * `memberRole` は Repository.getSpaceMemberRole で事前取得したロール(DB アクセスは Repository に置く)。 */ export function canManageSpace( user: Express.User, space: { ownerId: string | null }, memberRole?: SpaceMemberRole | null, ): boolean { return ( user.role === 'admin' || (space.ownerId !== null && space.ownerId === user.id) || memberRole === 'owner' ); } /** * スペース配下の行編集(タスク継続/削除・カレンダー CRUD・ファイル upload 等)を * スペースのメンバーシップ起点で許可するか。canManageSpace に加え editor も許可。 * viewer は read のみ(buildVisibilityWhere が扱う)で書き込み不可。 */ export function canEditInSpace( user: Express.User, space: { ownerId: string | null }, memberRole?: SpaceMemberRole | null, ): boolean { return canManageSpace(user, space, memberRole) || memberRole === 'editor'; } /** * 指定 user が指定タスクを閲覧できるか判定する。SQL ではなくロード済みオブジェクト * に対して使う。`buildVisibilityWhere` と同じセマンティクスをコード上で再現する。 * * - admin: 常に true * - owner: 常に true * - public: 常に true * - org: user.orgIds に visibilityScopeOrgId が含まれているかで判定 * - スペースメンバー/根オーナー: `spaceAccessible === true` のとき true * (呼び出し側が `repo.userCanViewSpace(task.spaceId, user)` で算出して渡す。 * この関数自体は DB を触らず純粋に保つ) * - private: 上記いずれにも該当しなければ false */ export function canUserSeeTask( user: Express.User, task: { ownerId: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null }, spaceAccessible?: boolean, ): boolean { if (user.role === 'admin') return true; if (task.ownerId !== null && task.ownerId === user.id) return true; if (task.visibility === 'public') return true; if (task.visibility === 'org' && task.visibilityScopeOrgId !== null) { if (user.orgIds.includes(task.visibilityScopeOrgId)) return true; } // スペースメンバーシップ起点の閲覧許可(buildVisibilityWhere の OR ブランチに対応)。 if (spaceAccessible === true) return true; return false; }