Files
zpan/server/usecases/team.ts
T
agent-kanban-local[bot]andAlex Chen 00f48cf355 feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode (#467)
* feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode

Host user avatars and org logos on the ZPan Cloud avatar service
(zpan-cloud-sdk ^2.4.0) instead of a public S3/R2 bucket, then remove the
now-dead storages.mode / public-bucket concept entirely (#456 parts 2-3).

- image-upload gateway: upload/delete via SDK uploadAvatar/deleteAvatar against
  a bound Cloud client; validate mime (AVATAR_CONTENT_TYPES) + size
  (MAX_AVATAR_BYTES) before the call; map cloud error codes to 400/403/413/500;
  unbound instance returns 503 cloud_required (delete is a best-effort no-op).
- licensing-cloud: createAvatarUploadClient builds the client with a plain-object
  bearer header so both the image content-type and Authorization survive hono's
  per-request header merge (a Headers instance would be dropped).
- drop storages.mode (migration via drizzle-kit), StorageRepo.select() no longer
  takes a mode, remove StorageMode / Storage.mode / mode schema+audit+UI+i18n and
  the PUBLIC_IMAGES bucket + PUBLIC_IMAGES_URL wiring.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a

* ci(deploy): drop dead PUBLIC_IMAGES R2 provisioning from CF deploy

The Cloud avatar migration removed the PUBLIC_IMAGES binding from
wrangler.toml, so the deploy workflow's R2 public-images steps are dead and
must go too — otherwise every CF deploy keeps re-provisioning a public-read
zpan-public-images bucket (the footgun #456 eliminates) and sets an unused
PUBLIC_IMAGES_URL secret. Removes the bucket-create, managed-public-URL, and
secret steps (steps.r2 was only consumed by the secret step). Also drops a
stale storage-modes line from the v2.0 roadmap.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a

---------

Co-authored-by: Alex Chen <alex-chen@mails.agent-kanban.dev>
2026-06-20 00:16:07 -04:00

325 lines
12 KiB
TypeScript

// The team resource usecase. Owns every business decision behind both team
// surfaces — the user-facing /api/teams routes (invite links, joining, the
// activity feed, the org logo) and the admin /api/admin/teams routes (team
// listing/detail and per-team quota entitlements) — plus the activity logging
// that accompanies the mutating operations, so the http handlers only validate
// input, call these functions, and serialize the result.
//
// Owner/admin/member role checks and the personal-org public-read carve-out for
// the activity feed live here; the http layer maps each outcome to its status.
//
// Two failure shapes appear, mirroring the ports:
// - The user-facing flows reject with a literal `reason` (forbidden,
// invite_invalid/expired/already_member, image_upload).
// - The admin entitlement flows defer to UserAdminRepo, which hands back a
// UserOperationFailure ({ error, status }); those outcomes thread that
// failure outward unchanged so the http layer maps {status} directly.
import type { Platform } from '../platform/interface'
import {
type ActivityEventWithUser,
type ActivityRepo,
type EntitlementResult,
type ImageUpload,
type ImageUploadResult,
type InviteLinkInfo,
LOGO_PREFIX,
type OrgRepo,
type PendingInvitation,
type QuotaEntitlementItem,
type TeamInviteRepo,
type TeamRepo,
type TeamSummary,
type UserAdminRepo,
type UserOperationFailure,
} from './ports'
export type TeamDeps = {
teams: TeamRepo
teamInvites: TeamInviteRepo
org: OrgRepo
activity: ActivityRepo
imageUpload: ImageUpload
userAdmin: UserAdminRepo
}
// Admin entitlement operations defer to the repo for the rule that may reject
// them; when it does it hands back a UserOperationFailure carrying the exact http
// status. The handler reads `failure` and re-serializes { error } with that status.
export type RepoFailure = { ok: false; failure: UserOperationFailure }
// ─── User-facing: invite links ───────────────────────────────────────────────
export function getInviteLinkInfo(deps: Pick<TeamDeps, 'teamInvites'>, token: string): Promise<InviteLinkInfo | null> {
return deps.teamInvites.getInviteLinkInfo(token)
}
export type CreateInviteLinkOutcome = { ok: true; token: string; expiresAt: Date } | { ok: false; reason: 'forbidden' }
export async function createInviteLink(
deps: Pick<TeamDeps, 'teamInvites' | 'org' | 'activity'>,
params: { teamId: string; userId: string; role: 'editor' | 'viewer'; expiresIn?: number },
): Promise<CreateInviteLinkOutcome> {
const { teamId, userId, role, expiresIn } = params
const memberRole = await deps.org.getMemberRole(teamId, userId)
if (memberRole !== 'owner') return { ok: false, reason: 'forbidden' }
const link = await deps.teamInvites.createInviteLink(teamId, userId, role, expiresIn)
await deps.activity.record({
orgId: teamId,
userId,
action: 'team_invite_link_create',
targetType: 'team',
targetId: teamId,
targetName: teamId,
metadata: { role, expiresIn },
})
return { ok: true, token: link.token, expiresAt: link.expiresAt }
}
export type ListInvitationsOutcome = { ok: true; invitations: PendingInvitation[] } | { ok: false; reason: 'forbidden' }
export async function listInvitations(
deps: Pick<TeamDeps, 'teamInvites' | 'org'>,
params: { teamId: string; userId: string },
): Promise<ListInvitationsOutcome> {
const memberRole = await deps.org.getMemberRole(params.teamId, params.userId)
if (memberRole !== 'owner') return { ok: false, reason: 'forbidden' }
const invitations = await deps.teamInvites.listPendingInvitations(params.teamId)
return { ok: true, invitations }
}
// ─── User-facing: joining a team ─────────────────────────────────────────────
export type JoinTeamOutcome = { ok: true } | { ok: false; reason: 'invalid' | 'expired' | 'already_member' }
export async function joinTeam(
deps: Pick<TeamDeps, 'teamInvites' | 'activity'>,
params: { teamId: string; userId: string; token: string },
): Promise<JoinTeamOutcome> {
const { teamId, userId, token } = params
const result = await deps.teamInvites.acceptInviteLink(token, userId)
if (result !== 'ok') return { ok: false, reason: result }
await deps.activity.record({
orgId: teamId,
userId,
action: 'team_member_join',
targetType: 'team',
targetId: teamId,
targetName: teamId,
})
return { ok: true }
}
// ─── User-facing: activity feed ──────────────────────────────────────────────
// Access rule: a member of any role may read; in addition, every authenticated
// user may read a *personal* org's feed (personal orgs are public to auth users).
// A non-member of a non-personal org is forbidden.
export type ListActivityOutcome =
| { ok: true; result: { items: ActivityEventWithUser[]; total: number } }
| { ok: false; reason: 'forbidden' }
export async function listActivity(
deps: Pick<TeamDeps, 'org' | 'activity'>,
params: { teamId: string; userId: string; page: number; pageSize: number },
): Promise<ListActivityOutcome> {
const { teamId, userId, page, pageSize } = params
const role = await deps.org.getMemberRole(teamId, userId)
if (role === null && !(await deps.org.isPersonalOrg(teamId))) {
return { ok: false, reason: 'forbidden' }
}
const result = await deps.activity.list(teamId, { page, pageSize })
return { ok: true, result }
}
// ─── User-facing: org logo ───────────────────────────────────────────────────
// Logo writes require owner or admin. The MIME/size validation (and the unbound
// instance case) is owned by imageUpload, which returns { ok:false, status } for
// the 400/403/413/500/503 outcomes; setTeamLogo threads that status outward
// unchanged. A failed role check is the only 403 it raises itself.
export type SetTeamLogoOutcome =
| { ok: true; url: string }
| { ok: false; reason: 'forbidden' }
| { ok: false; reason: 'upload_failed'; status: 400 | 403 | 413 | 500 | 503; error: string }
export async function setTeamLogo(
deps: Pick<TeamDeps, 'org' | 'teams' | 'imageUpload' | 'activity'>,
params: { platform: Platform; teamId: string; userId: string; file: File },
): Promise<SetTeamLogoOutcome> {
const { platform, teamId, userId, file } = params
const role = await deps.org.getMemberRole(teamId, userId)
if (role !== 'owner' && role !== 'admin') return { ok: false, reason: 'forbidden' }
const result: ImageUploadResult = await deps.imageUpload.uploadPublicImage(platform, LOGO_PREFIX, teamId, file)
if (!result.ok) return { ok: false, reason: 'upload_failed', status: result.status, error: result.error }
await deps.teams.setLogo(teamId, result.url)
await deps.activity.record({
orgId: teamId,
userId,
action: 'team_logo_update',
targetType: 'team',
targetId: teamId,
targetName: teamId,
})
return { ok: true, url: result.url }
}
export type DeleteTeamLogoOutcome = { ok: true } | { ok: false; reason: 'forbidden' }
export async function deleteTeamLogo(
deps: Pick<TeamDeps, 'org' | 'teams' | 'imageUpload' | 'activity'>,
params: { platform: Platform; teamId: string; userId: string },
): Promise<DeleteTeamLogoOutcome> {
const { platform, teamId, userId } = params
const role = await deps.org.getMemberRole(teamId, userId)
if (role !== 'owner' && role !== 'admin') return { ok: false, reason: 'forbidden' }
await deps.teams.setLogo(teamId, null)
await deps.imageUpload.deletePublicImageVariants(platform, LOGO_PREFIX, teamId)
await deps.activity.record({
orgId: teamId,
userId,
action: 'team_logo_delete',
targetType: 'team',
targetId: teamId,
targetName: teamId,
})
return { ok: true }
}
// ─── Admin: team listing / detail ────────────────────────────────────────────
export function listTeams(deps: Pick<TeamDeps, 'teams'>): Promise<{ items: TeamSummary[]; total: number }> {
return deps.teams.listTeams().then((items) => ({ items, total: items.length }))
}
export function getTeam(deps: Pick<TeamDeps, 'teams'>, orgId: string): Promise<TeamSummary | null> {
return deps.teams.getTeam(orgId)
}
// ─── Admin: per-team quota entitlements ──────────────────────────────────────
export type ListTeamEntitlementsOutcome =
| { ok: true; result: { orgId: string; items: QuotaEntitlementItem[] } }
| RepoFailure
export async function listTeamEntitlements(
deps: Pick<TeamDeps, 'userAdmin'>,
orgId: string,
): Promise<ListTeamEntitlementsOutcome> {
const result = await deps.userAdmin.listOrgEntitlements(orgId)
if ('error' in result) return { ok: false, failure: result }
return { ok: true, result }
}
export type TeamEntitlementOutcome = { ok: true; result: EntitlementResult } | RepoFailure
export async function grantTeamEntitlement(
deps: Pick<TeamDeps, 'userAdmin' | 'activity'>,
params: {
adminUserId: string
adminOrgId: string
targetOrgId: string
resourceType: 'storage'
bytes: number
expiresAt?: Date | null
note?: string | null
},
): Promise<TeamEntitlementOutcome> {
const { adminUserId, adminOrgId, targetOrgId, resourceType, bytes, expiresAt, note } = params
const result = await deps.userAdmin.grantOrgEntitlement({
adminUserId,
orgId: targetOrgId,
resourceType,
bytes,
expiresAt,
note,
})
if ('error' in result) return { ok: false, failure: result }
await deps.activity.record({
orgId: adminOrgId,
userId: adminUserId,
action: 'quota_entitlement_grant',
targetType: 'quota',
targetId: targetOrgId,
targetName: targetOrgId,
metadata: {
targetOrgId,
entitlementId: result.entitlement.id,
resourceType: result.entitlement.resourceType,
bytes: result.entitlement.bytes,
expiresAt: result.entitlement.expiresAt?.toISOString() ?? null,
},
})
return { ok: true, result }
}
export async function updateTeamEntitlement(
deps: Pick<TeamDeps, 'userAdmin' | 'activity'>,
params: {
adminUserId: string
adminOrgId: string
targetOrgId: string
entitlementId: string
bytes?: number
expiresAt?: Date | null
note?: string | null
},
): Promise<TeamEntitlementOutcome> {
const { adminUserId, adminOrgId, targetOrgId, entitlementId, bytes, expiresAt, note } = params
const result = await deps.userAdmin.updateOrgEntitlement({
adminUserId,
orgId: targetOrgId,
entitlementId,
bytes,
expiresAt,
note,
})
if ('error' in result) return { ok: false, failure: result }
await deps.activity.record({
orgId: adminOrgId,
userId: adminUserId,
action: 'quota_entitlement_update',
targetType: 'quota',
targetId: targetOrgId,
targetName: targetOrgId,
metadata: {
targetOrgId,
entitlementId: result.entitlement.id,
bytes: result.entitlement.bytes,
expiresAt: result.entitlement.expiresAt?.toISOString() ?? null,
},
})
return { ok: true, result }
}
export async function revokeTeamEntitlement(
deps: Pick<TeamDeps, 'userAdmin' | 'activity'>,
params: { adminUserId: string; adminOrgId: string; targetOrgId: string; entitlementId: string },
): Promise<TeamEntitlementOutcome> {
const { adminUserId, adminOrgId, targetOrgId, entitlementId } = params
const result = await deps.userAdmin.revokeOrgEntitlement({ adminUserId, orgId: targetOrgId, entitlementId })
if ('error' in result) return { ok: false, failure: result }
await deps.activity.record({
orgId: adminOrgId,
userId: adminUserId,
action: 'quota_entitlement_revoke',
targetType: 'quota',
targetId: targetOrgId,
targetName: targetOrgId,
metadata: {
targetOrgId,
entitlementId: result.entitlement.id,
bytes: result.entitlement.bytes,
},
})
return { ok: true, result }
}