Files
zpan/server/usecases/user.ts
T
saltbo e55ee53496 refactor(stats): unify audit and fact pipelines
Centralize request audit recording, preserve immutable download-task history, and derive hourly statistics and backfills from the same authoritative sources. Add durable user registration facts so admin deletion no longer destroys signup history.
2026-07-21 23:02:52 -04:00

152 lines
5.5 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 — 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 {
AVATAR_PREFIX,
type EntitlementResult,
type ImageUpload,
type ProfileRepo,
type PublicUser,
type QuotaEntitlementItem,
type UserAdminRepo,
type UserOperationFailure,
} from './ports'
export type UserDeps = {
userAdmin: UserAdminRepo
}
// 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
targetUserId: string
resourceType: 'storage'
bytes: number
expiresAt?: Date | null
note?: string | null
},
): Promise<EntitlementOutcome> {
const { adminUserId, 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 }
return { ok: true, result }
}
export async function updateUserEntitlement(
deps: UserDeps,
params: {
adminUserId: string
targetUserId: string
entitlementId: string
bytes?: number
expiresAt?: Date | null
note?: string | null
},
): Promise<EntitlementOutcome> {
const { adminUserId, 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 }
return { ok: true, result }
}
export async function revokeUserEntitlement(
deps: UserDeps,
params: { adminUserId: string; targetUserId: string; entitlementId: string },
): Promise<EntitlementOutcome> {
const { adminUserId, targetUserId, entitlementId } = params
const result = await deps.userAdmin.revokeUserPersonalEntitlement({ adminUserId, targetUserId, entitlementId })
if ('error' in result) return { ok: false, failure: result }
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)
}