mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
500 lines
17 KiB
TypeScript
500 lines
17 KiB
TypeScript
import { OpenAPIHono, z } from '@hono/zod-openapi'
|
|
import { AuthorizationScope } from '@shared/authorization'
|
|
import { pageQuerySchema, pageSchema } from '@shared/schemas'
|
|
import type { Env } from '../middleware/platform'
|
|
import {
|
|
type AuditEventWithUser,
|
|
badRequest,
|
|
conflict,
|
|
expired,
|
|
forbidden,
|
|
type InviteLinkInfo,
|
|
internalError,
|
|
noStorage,
|
|
notFound,
|
|
type PendingInvitation,
|
|
payloadTooLarge,
|
|
unsupportedMediaType,
|
|
} from '../usecases/ports'
|
|
import {
|
|
createInviteLink,
|
|
deleteTeamLogo,
|
|
getInviteLinkInfo,
|
|
getTeam,
|
|
grantTeamEntitlement,
|
|
joinTeam,
|
|
listActivity,
|
|
listInvitations,
|
|
listTeamEntitlements,
|
|
listTeams,
|
|
revokeTeamEntitlement,
|
|
setTeamLogo,
|
|
updateTeamEntitlement,
|
|
} from '../usecases/team'
|
|
import {
|
|
entitlementListSchema,
|
|
entitlementResultSchema,
|
|
toEntitlementResultDTO,
|
|
toQuotaEntitlementDTO,
|
|
} from './entitlements'
|
|
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
|
|
|
|
const inviteLinkInfoSchema = z
|
|
.object({
|
|
organizationId: z.string(),
|
|
organizationName: z.string(),
|
|
role: z.string(),
|
|
expiresAt: z.string().nullable(),
|
|
})
|
|
.openapi('TeamInviteLinkInfo')
|
|
|
|
function toInviteLinkInfoDTO(i: InviteLinkInfo): z.infer<typeof inviteLinkInfoSchema> {
|
|
return { ...i, expiresAt: i.expiresAt ? i.expiresAt.toISOString() : null }
|
|
}
|
|
|
|
const inviteLinkCreatedSchema = z.object({ token: z.string(), expiresAt: z.string() }).openapi('TeamInviteLinkCreated')
|
|
|
|
const pendingInvitationSchema = z
|
|
.object({
|
|
id: z.string(),
|
|
email: z.string(),
|
|
role: z.string(),
|
|
expiresAt: z.string().nullable(),
|
|
createdAt: z.string(),
|
|
})
|
|
.openapi('PendingInvitation')
|
|
|
|
function toPendingInvitationDTO(p: PendingInvitation): z.infer<typeof pendingInvitationSchema> {
|
|
return { ...p, expiresAt: p.expiresAt ? p.expiresAt.toISOString() : null, createdAt: p.createdAt.toISOString() }
|
|
}
|
|
|
|
const pendingInvitationListSchema = pageSchema(pendingInvitationSchema, 'TeamInvitationList')
|
|
|
|
const activityEventSchema = z
|
|
.object({
|
|
id: z.string(),
|
|
orgId: z.string(),
|
|
userId: z.string().nullable(),
|
|
actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']),
|
|
actorRef: z.string().nullable(),
|
|
actorIssuer: z.string().nullable(),
|
|
action: z.string(),
|
|
targetType: z.string(),
|
|
targetId: z.string().nullable(),
|
|
targetName: z.string(),
|
|
metadata: z.string().nullable(),
|
|
createdAt: z.string(),
|
|
user: z.object({ id: z.string().nullable(), name: z.string(), image: z.string().nullable() }),
|
|
})
|
|
.openapi('AuditEvent')
|
|
|
|
function toAuditEventDTO(e: AuditEventWithUser): z.infer<typeof activityEventSchema> {
|
|
return { ...e, createdAt: e.createdAt.toISOString() }
|
|
}
|
|
|
|
const activityPageSchema = pageSchema(activityEventSchema, 'ActivityPage')
|
|
|
|
const teamSummarySchema = z
|
|
.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
slug: z.string(),
|
|
logo: z.string().nullable(),
|
|
memberCount: z.number().int(),
|
|
ownerName: z.string().nullable(),
|
|
quotaUsed: z.number().int(),
|
|
quotaTotal: z.number().int(),
|
|
createdAt: z.number().int(),
|
|
})
|
|
.openapi('TeamSummary')
|
|
|
|
const teamListSchema = pageSchema(teamSummarySchema, 'TeamList')
|
|
|
|
const createLinkSchema = z.object({
|
|
role: z.enum(['editor', 'viewer']).default('viewer'),
|
|
expiresIn: z.number().int().min(1).optional(),
|
|
})
|
|
|
|
const joinSchema = z.object({ token: z.string().min(1) })
|
|
|
|
const grantEntitlementSchema = z.object({
|
|
resourceType: z.literal('storage'),
|
|
bytes: z.number().int().positive(),
|
|
expiresAt: z.string().datetime().nullable().optional(),
|
|
note: z.string().max(500).nullable().optional(),
|
|
})
|
|
|
|
const updateEntitlementSchema = z.object({
|
|
bytes: z.number().int().positive().optional(),
|
|
expiresAt: z.string().datetime().nullable().optional(),
|
|
note: z.string().max(500).nullable().optional(),
|
|
})
|
|
|
|
// Maps a UserOperationFailure ({ error, status }) threaded from the UserAdminRepo
|
|
// to the matching error factory — the http boundary the rule keeps the sub-usecase
|
|
// status out of.
|
|
function failureError(failure: { status: 400 | 404; error: string }) {
|
|
return failure.status === 404 ? notFound(failure.error) : badRequest(failure.error)
|
|
}
|
|
|
|
// Maps the image-upload gateway outcome ({ status, error }) to its error factory.
|
|
function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
|
|
if (status === 413) return payloadTooLarge(error)
|
|
if (status === 503) return noStorage(error)
|
|
if (status === 403) return forbidden(error)
|
|
if (status === 500) return internalError(error)
|
|
return badRequest(error)
|
|
}
|
|
|
|
// ── publicTeams ──────────────────────────────────────────────────────────────
|
|
const inviteLinkInfoRoute = authRoute(
|
|
{ public: true },
|
|
{
|
|
operationId: 'getTeamInviteLink',
|
|
summary: 'Get team invite link info',
|
|
tags: ['Teams'],
|
|
method: 'get',
|
|
path: '/invite-links/{token}',
|
|
request: { params: z.object({ token: z.string() }) },
|
|
responses: {
|
|
200: jsonContent(inviteLinkInfoSchema, 'Invite link info'),
|
|
404: errorResponse('Invalid or expired invite link'),
|
|
},
|
|
},
|
|
)
|
|
|
|
export const publicTeams = new OpenAPIHono<Env>().openapi(inviteLinkInfoRoute, async (c) => {
|
|
const info = await getInviteLinkInfo(c.get('deps'), c.req.valid('param').token)
|
|
if (!info) throw notFound('Invalid or expired invite link')
|
|
return c.json(toInviteLinkInfoDTO(info), 200)
|
|
})
|
|
|
|
// ── teams (member-scoped) ────────────────────────────────────────────────────
|
|
const createInviteLinkRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAM_INVITATIONS_CREATE] },
|
|
{
|
|
operationId: 'createTeamInviteLink',
|
|
summary: 'Create a team invite link',
|
|
tags: ['Teams'],
|
|
method: 'post',
|
|
path: '/{teamId}/invite-links',
|
|
request: { params: z.object({ teamId: z.string() }), ...jsonBody(createLinkSchema) },
|
|
responses: {
|
|
201: jsonContent(inviteLinkCreatedSchema, 'Created invite link'),
|
|
403: errorResponse('Forbidden'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const listInvitationsRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAM_INVITATIONS_READ] },
|
|
{
|
|
operationId: 'listTeamInvitations',
|
|
summary: 'List pending team invitations',
|
|
tags: ['Teams'],
|
|
method: 'get',
|
|
path: '/{teamId}/invitations',
|
|
request: { params: z.object({ teamId: z.string() }) },
|
|
responses: {
|
|
200: jsonContent(pendingInvitationListSchema, 'Pending invitations'),
|
|
403: errorResponse('Forbidden'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const joinTeamRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAM_MEMBERS_CREATE] },
|
|
{
|
|
operationId: 'joinTeam',
|
|
summary: 'Join a team with an invite token',
|
|
tags: ['Teams'],
|
|
method: 'post',
|
|
path: '/{teamId}/members',
|
|
request: { params: z.object({ teamId: z.string() }), ...jsonBody(joinSchema) },
|
|
responses: {
|
|
200: jsonContent(z.object({ ok: z.literal(true) }), 'Joined'),
|
|
404: errorResponse('Invalid invite link'),
|
|
409: errorResponse('Already a member'),
|
|
410: errorResponse('Invite link expired'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const activityRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAMS_READ] },
|
|
{
|
|
operationId: 'listTeamActivity',
|
|
summary: 'List team activity',
|
|
tags: ['Teams'],
|
|
method: 'get',
|
|
path: '/{teamId}/activity',
|
|
request: {
|
|
params: z.object({ teamId: z.string() }),
|
|
query: pageQuerySchema,
|
|
},
|
|
responses: {
|
|
200: jsonContent(activityPageSchema, 'Activity'),
|
|
403: errorResponse('Forbidden'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const setLogoRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAMS_UPDATE] },
|
|
{
|
|
operationId: 'setTeamLogo',
|
|
summary: 'Set team logo',
|
|
tags: ['Teams'],
|
|
method: 'put',
|
|
path: '/{teamId}/logo',
|
|
// Body is multipart/form-data (a `file` field); parsed directly in the handler
|
|
// rather than via a request schema (the form validator conflicts with formData()).
|
|
request: { params: z.object({ teamId: z.string() }) },
|
|
responses: {
|
|
200: jsonContent(z.object({ url: z.string() }), 'Logo URL'),
|
|
400: errorResponse('Bad request'),
|
|
403: errorResponse('Forbidden'),
|
|
413: errorResponse('File too large'),
|
|
415: errorResponse('Expected multipart/form-data'),
|
|
503: errorResponse('No public storage configured'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const deleteLogoRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAMS_UPDATE] },
|
|
{
|
|
operationId: 'deleteTeamLogo',
|
|
summary: 'Delete team logo',
|
|
tags: ['Teams'],
|
|
method: 'delete',
|
|
path: '/{teamId}/logo',
|
|
request: { params: z.object({ teamId: z.string() }) },
|
|
responses: {
|
|
204: { description: 'Deleted' },
|
|
403: errorResponse('Forbidden'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const teamsApp = new OpenAPIHono<Env>()
|
|
|
|
export const teams = teamsApp
|
|
.openapi(createInviteLinkRoute, async (c) => {
|
|
const { role, expiresIn } = c.req.valid('json')
|
|
const result = await createInviteLink(c.get('deps'), {
|
|
teamId: c.req.valid('param').teamId,
|
|
userId: c.get('userId')!,
|
|
role,
|
|
expiresIn,
|
|
})
|
|
if (!result.ok) throw forbidden()
|
|
return c.json({ token: result.token, expiresAt: result.expiresAt.toISOString() }, 201)
|
|
})
|
|
.openapi(listInvitationsRoute, async (c) => {
|
|
const result = await listInvitations(c.get('deps'), {
|
|
teamId: c.req.valid('param').teamId,
|
|
userId: c.get('userId')!,
|
|
})
|
|
if (!result.ok) throw forbidden()
|
|
const items = result.invitations.map(toPendingInvitationDTO)
|
|
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
|
})
|
|
.openapi(joinTeamRoute, async (c) => {
|
|
const result = await joinTeam(c.get('deps'), {
|
|
teamId: c.req.valid('param').teamId,
|
|
userId: c.get('userId')!,
|
|
token: c.req.valid('json').token,
|
|
})
|
|
if (result.ok) return c.json({ ok: true as const }, 200)
|
|
if (result.reason === 'invalid') throw notFound('Invalid invite link')
|
|
if (result.reason === 'expired') throw expired('Invite link has expired')
|
|
throw conflict('Already a member of this team')
|
|
})
|
|
.openapi(activityRoute, async (c) => {
|
|
const { page, pageSize } = c.req.valid('query')
|
|
const result = await listActivity(c.get('deps'), {
|
|
teamId: c.req.valid('param').teamId,
|
|
userId: c.get('userId')!,
|
|
page,
|
|
pageSize,
|
|
})
|
|
if (!result.ok) throw forbidden()
|
|
return c.json({ items: result.result.items.map(toAuditEventDTO), total: result.result.total, page, pageSize }, 200)
|
|
})
|
|
.openapi(setLogoRoute, async (c) => {
|
|
const teamId = c.req.valid('param').teamId
|
|
const form = await c.req.formData().catch(() => null)
|
|
if (!form) throw unsupportedMediaType('Expected multipart/form-data with a file field')
|
|
const file = form.get('file')
|
|
if (!(file instanceof File)) throw badRequest('file field is required')
|
|
|
|
const result = await setTeamLogo(c.get('deps'), {
|
|
platform: c.get('platform'),
|
|
teamId,
|
|
userId: c.get('userId') as string,
|
|
file,
|
|
})
|
|
if (result.ok) return c.json({ url: result.url }, 200)
|
|
if (result.reason === 'forbidden') throw forbidden()
|
|
throw imageUploadError(result.status, result.error)
|
|
})
|
|
.openapi(deleteLogoRoute, async (c) => {
|
|
const result = await deleteTeamLogo(c.get('deps'), {
|
|
platform: c.get('platform'),
|
|
teamId: c.req.valid('param').teamId,
|
|
userId: c.get('userId') as string,
|
|
})
|
|
if (!result.ok) throw forbidden()
|
|
return c.body(null, 204)
|
|
})
|
|
|
|
// ── adminTeams ───────────────────────────────────────────────────────────────
|
|
const listTeamsRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAMS_READ], siteRole: 'admin' },
|
|
{
|
|
operationId: 'listTeams',
|
|
summary: 'List teams',
|
|
tags: ['Teams'],
|
|
method: 'get',
|
|
path: '/',
|
|
responses: { 200: jsonContent(teamListSchema, 'Teams') },
|
|
},
|
|
)
|
|
|
|
const getTeamRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAMS_READ], siteRole: 'admin' },
|
|
{
|
|
operationId: 'getTeam',
|
|
summary: 'Get a team',
|
|
tags: ['Teams'],
|
|
method: 'get',
|
|
path: '/{teamId}',
|
|
request: { params: z.object({ teamId: z.string() }) },
|
|
responses: {
|
|
200: jsonContent(teamSummarySchema, 'Team'),
|
|
404: errorResponse('Team not found'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const listEntitlementsRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAM_ENTITLEMENTS_READ], siteRole: 'admin' },
|
|
{
|
|
operationId: 'listTeamEntitlements',
|
|
summary: 'List team quota entitlements',
|
|
tags: ['Teams'],
|
|
method: 'get',
|
|
path: '/{teamId}/entitlements',
|
|
request: { params: z.object({ teamId: z.string() }) },
|
|
responses: {
|
|
200: jsonContent(entitlementListSchema, 'Entitlements'),
|
|
400: errorResponse('Bad request'),
|
|
404: errorResponse('Not found'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const grantEntitlementRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAM_ENTITLEMENTS_CREATE], siteRole: 'admin' },
|
|
{
|
|
operationId: 'grantTeamEntitlement',
|
|
summary: 'Grant a team entitlement',
|
|
tags: ['Teams'],
|
|
method: 'post',
|
|
path: '/{teamId}/entitlements',
|
|
request: { params: z.object({ teamId: z.string() }), ...jsonBody(grantEntitlementSchema) },
|
|
responses: {
|
|
201: jsonContent(entitlementResultSchema, 'Granted entitlement'),
|
|
400: errorResponse('Bad request'),
|
|
404: errorResponse('Not found'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const updateEntitlementRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAM_ENTITLEMENTS_UPDATE], siteRole: 'admin' },
|
|
{
|
|
operationId: 'updateTeamEntitlement',
|
|
summary: 'Update a team entitlement',
|
|
tags: ['Teams'],
|
|
method: 'patch',
|
|
path: '/{teamId}/entitlements/{eid}',
|
|
request: { params: z.object({ teamId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) },
|
|
responses: {
|
|
200: jsonContent(entitlementResultSchema, 'Updated entitlement'),
|
|
400: errorResponse('Bad request'),
|
|
404: errorResponse('Not found'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const revokeEntitlementRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.TEAM_ENTITLEMENTS_DELETE], siteRole: 'admin' },
|
|
{
|
|
operationId: 'revokeTeamEntitlement',
|
|
summary: 'Revoke a team entitlement',
|
|
tags: ['Teams'],
|
|
method: 'delete',
|
|
path: '/{teamId}/entitlements/{eid}',
|
|
request: { params: z.object({ teamId: z.string(), eid: z.string() }) },
|
|
responses: {
|
|
204: { description: 'Revoked entitlement' },
|
|
400: errorResponse('Bad request'),
|
|
404: errorResponse('Not found'),
|
|
},
|
|
},
|
|
)
|
|
|
|
export const adminTeams = new OpenAPIHono<Env>()
|
|
.openapi(listTeamsRoute, async (c) => {
|
|
const { items } = await listTeams(c.get('deps'))
|
|
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
|
})
|
|
.openapi(getTeamRoute, async (c) => {
|
|
const team = await getTeam(c.get('deps'), c.req.valid('param').teamId)
|
|
if (!team) throw notFound('Team not found')
|
|
return c.json(team, 200)
|
|
})
|
|
.openapi(listEntitlementsRoute, async (c) => {
|
|
const result = await listTeamEntitlements(c.get('deps'), c.req.valid('param').teamId)
|
|
if (!result.ok) throw failureError(result.failure)
|
|
const items = result.result.items.map(toQuotaEntitlementDTO)
|
|
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
|
})
|
|
.openapi(grantEntitlementRoute, async (c) => {
|
|
const body = c.req.valid('json')
|
|
const result = await grantTeamEntitlement(c.get('deps'), {
|
|
adminUserId: c.get('userId')!,
|
|
targetOrgId: c.req.valid('param').teamId,
|
|
resourceType: body.resourceType,
|
|
bytes: body.bytes,
|
|
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
|
|
note: body.note,
|
|
})
|
|
if (!result.ok) throw failureError(result.failure)
|
|
return c.json(toEntitlementResultDTO(result.result), 201)
|
|
})
|
|
.openapi(updateEntitlementRoute, async (c) => {
|
|
const body = c.req.valid('json')
|
|
const result = await updateTeamEntitlement(c.get('deps'), {
|
|
adminUserId: c.get('userId')!,
|
|
targetOrgId: c.req.valid('param').teamId,
|
|
entitlementId: c.req.valid('param').eid,
|
|
bytes: body.bytes,
|
|
expiresAt: 'expiresAt' in body ? (body.expiresAt ? new Date(body.expiresAt) : null) : undefined,
|
|
note: body.note,
|
|
})
|
|
if (!result.ok) throw failureError(result.failure)
|
|
return c.json(toEntitlementResultDTO(result.result), 200)
|
|
})
|
|
.openapi(revokeEntitlementRoute, async (c) => {
|
|
const result = await revokeTeamEntitlement(c.get('deps'), {
|
|
adminUserId: c.get('userId')!,
|
|
targetOrgId: c.req.valid('param').teamId,
|
|
entitlementId: c.req.valid('param').eid,
|
|
})
|
|
if (!result.ok) throw failureError(result.failure)
|
|
return c.body(null, 204)
|
|
})
|