mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
* 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>
325 lines
12 KiB
TypeScript
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 }
|
|
}
|