Files
zpan/server/usecases/user.ts
T
agent-kanban-local[bot] 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

198 lines
6.9 KiB
TypeScript

// The users resource usecase. Owns the front-of-house business decisions behind
// the surviving /api/users routes — the authenticated user's own avatar
// (PUT/DELETE /api/users/me/avatar), the read-only public profile lookup
// (GET /api/users/:username), and the admin personal-org storage-entitlement
// grants (with their activity logging) — so the http handlers only validate
// input, call these functions, and serialize the result.
//
// Admin user management (list / disable-enable / delete) is no longer served
// here: the frontend calls better-auth's /api/auth/admin/* endpoints directly.
//
// The deeper entitlement rules (admin-grant-only source guard, personal-org-
// not-found) live in the UserAdminRepo adapter and surface here as a
// UserOperationFailure ({ error, status }); these functions thread that failure
// outward unchanged so the http layer maps {status} directly.
import type { Platform } from '../platform/interface'
import {
type ActivityRepo,
AVATAR_PREFIX,
type EntitlementResult,
type ImageUpload,
type ProfileRepo,
type PublicUser,
type QuotaEntitlementItem,
type UserAdminRepo,
type UserOperationFailure,
} from './ports'
export type UserDeps = {
userAdmin: UserAdminRepo
activity: ActivityRepo
}
// 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 }
export type ListUserEntitlementsOutcome =
| { ok: true; result: { orgId: string; items: QuotaEntitlementItem[] } }
| RepoFailure
export type EntitlementOutcome = { ok: true; result: EntitlementResult } | RepoFailure
export function listUserEntitlements(
deps: Pick<UserDeps, 'userAdmin'>,
userId: string,
): Promise<ListUserEntitlementsOutcome> {
return deps.userAdmin.listUserPersonalEntitlements(userId).then((result) => {
if ('error' in result) return { ok: false, failure: result } as const
return { ok: true, result } as const
})
}
export async function grantUserEntitlement(
deps: UserDeps,
params: {
adminUserId: string
adminOrgId: string
targetUserId: string
resourceType: 'storage'
bytes: number
expiresAt?: Date | null
note?: string | null
},
): Promise<EntitlementOutcome> {
const { adminUserId, adminOrgId, targetUserId, resourceType, bytes, expiresAt, note } = params
const result = await deps.userAdmin.grantUserPersonalEntitlement({
adminUserId,
targetUserId,
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: result.orgId,
targetName: targetUserId,
metadata: {
targetUserId,
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 updateUserEntitlement(
deps: UserDeps,
params: {
adminUserId: string
adminOrgId: string
targetUserId: string
entitlementId: string
bytes?: number
expiresAt?: Date | null
note?: string | null
},
): Promise<EntitlementOutcome> {
const { adminUserId, adminOrgId, targetUserId, entitlementId, bytes, expiresAt, note } = params
const result = await deps.userAdmin.updateUserPersonalEntitlement({
adminUserId,
targetUserId,
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: result.orgId,
targetName: targetUserId,
metadata: {
targetUserId,
entitlementId: result.entitlement.id,
bytes: result.entitlement.bytes,
expiresAt: result.entitlement.expiresAt?.toISOString() ?? null,
},
})
return { ok: true, result }
}
export async function revokeUserEntitlement(
deps: UserDeps,
params: { adminUserId: string; adminOrgId: string; targetUserId: string; entitlementId: string },
): Promise<EntitlementOutcome> {
const { adminUserId, adminOrgId, targetUserId, entitlementId } = params
const result = await deps.userAdmin.revokeUserPersonalEntitlement({ adminUserId, targetUserId, 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: result.orgId,
targetName: targetUserId,
metadata: {
targetUserId,
entitlementId: result.entitlement.id,
bytes: result.entitlement.bytes,
},
})
return { ok: true, result }
}
// ── self: the authenticated user's own avatar ────────────────────────────────
export type AvatarDeps = {
imageUpload: ImageUpload
profiles: ProfileRepo
}
// The image gateway can reject with a status (400 bad mime, 413 too large, 403
// license inactive, 500 cloud error, 503 instance not paired to Cloud). The http
// layer turns this into the error body + status; the decision of *which* status
// lives in the gateway, surfaced verbatim here.
export type UpdateAvatarOutcome =
| { ok: true; url: string }
| { ok: false; status: 400 | 403 | 413 | 500 | 503; error: string }
// `platform` is a request-bound capability (the active license binding + cloud
// base URL are request-scoped), so it is a plain function param — not stored on
// AvatarDeps.
export async function updateAvatar(
deps: AvatarDeps,
params: { platform: Platform; userId: string; file: File },
): Promise<UpdateAvatarOutcome> {
const { platform, userId, file } = params
const result = await deps.imageUpload.uploadPublicImage(platform, AVATAR_PREFIX, userId, file)
if (!result.ok) return result
await deps.profiles.setAvatar(userId, result.url)
return { ok: true, url: result.url }
}
export async function removeAvatar(deps: AvatarDeps, params: { platform: Platform; userId: string }): Promise<void> {
const { platform, userId } = params
// Clear DB first (authoritative); storage cleanup below is best-effort.
await deps.profiles.setAvatar(userId, null)
await deps.imageUpload.deletePublicImageVariants(platform, AVATAR_PREFIX, userId)
}
// ── public: read-only profile lookup by username ─────────────────────────────
export function getPublicProfile(deps: { profiles: ProfileRepo }, username: string): Promise<PublicUser | null> {
return deps.profiles.getUserByUsername(username)
}