UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

162 lines (151 loc) 5.62 kB
import { $inject, Alepha, z } from "alepha"; import { jobExecutionEntity } from "alepha/api/jobs"; import { $repository } from "alepha/orm"; import { $secure, currentTenantAtom } from "alepha/security"; import { $action, NotFoundError, okSchema } from "alepha/server"; import { NotificationJobs } from "../jobs/NotificationJobs.ts"; import { notificationDetailResourceSchema } from "../schemas/notificationDetailResourceSchema.ts"; import { notificationQuerySchema } from "../schemas/notificationQuerySchema.ts"; import { notificationResourceSchema } from "../schemas/notificationResourceSchema.ts"; export class AdminNotificationController { protected readonly url: string = "/notifications"; protected readonly group: string = "admin:notifications"; protected readonly alepha = $inject(Alepha); protected readonly notificationJobs = $inject(NotificationJobs); protected readonly executions = $repository(jobExecutionEntity); protected get jobName(): string { return this.notificationJobs.sendNotification.name; } /** * The tenant this request is acting in, when multi-tenant. The notification * outbox (`job_executions`) is shared across tenants in a pooled worker, so * every read/delete here is scoped to this org to prevent cross-tenant access. * Undefined in single-tenant apps → no extra filter (all rows are this app's). */ protected get organizationId(): string | undefined { return this.alepha.store.get(currentTenantAtom)?.id; } /** True when `exec` belongs to the acting tenant (or the app is single-tenant). */ protected sameTenant(exec: { organizationId?: string | null }): boolean { const org = this.organizationId; return !org || exec.organizationId === org; } public readonly findNotifications = $action({ path: this.url, group: this.group, use: [$secure({ permissions: ["admin:notification:read"] })], schema: { query: notificationQuerySchema, response: z.page(notificationResourceSchema), }, handler: async ({ query }) => { query.sort ??= "-createdAt"; const where = this.executions.createQueryWhere(); where.jobName = { eq: this.jobName }; const org = this.organizationId; if (org) { where.organizationId = { eq: org }; } const page = await this.executions.paginate( query, { where }, { count: true }, ); return { ...page, content: page.content.map((exec) => this.toResource(exec)), } as any; }, }); public readonly getNotification = $action({ path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:notification:read"] })], schema: { params: z.object({ id: z.uuid(), }), response: notificationDetailResourceSchema, }, handler: async ({ params }) => { const exec = await this.executions.findById(params.id); if (!exec || exec.jobName !== this.jobName || !this.sameTenant(exec)) { throw new NotFoundError(`Notification not found: ${params.id}`); } return this.toDetailResource(exec) as any; }, }); public readonly deleteNotification = $action({ method: "DELETE", path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:notification:delete"] })], description: "Delete a notification record", schema: { params: z.object({ id: z.uuid(), }), response: okSchema, }, handler: async ({ params }) => { const exec = await this.executions.findById(params.id); if (!exec || exec.jobName !== this.jobName || !this.sameTenant(exec)) { throw new NotFoundError(`Notification not found: ${params.id}`); } await this.executions.deleteById(params.id); return { ok: true, id: params.id }; }, }); public readonly deleteNotifications = $action({ method: "POST", path: `${this.url}/delete`, group: this.group, use: [$secure({ permissions: ["admin:notification:delete"] })], description: "Delete many notification records in one call", schema: { body: z.object({ ids: z.array(z.uuid()).min(1).max(1000), }), response: z.object({ deleted: z.array(z.uuid()), }), }, handler: async ({ body }) => { // Constrain to this job's executions so an admin can't delete arbitrary // job rows through this endpoint — and, when multi-tenant, to this org so // one club can't delete another club's notification records. const org = this.organizationId; const deleted = await this.executions.deleteMany({ id: { inArray: body.ids }, jobName: { eq: this.jobName }, ...(org ? { organizationId: { eq: org } } : {}), }); return { deleted: deleted.map(String) }; }, }); protected toResource(exec: Record<string, unknown>) { const payload = (exec.payload ?? {}) as Record<string, unknown>; return { id: exec.id, createdAt: exec.createdAt, status: exec.status, template: payload.template, type: payload.type, contact: payload.contact, category: payload.category, critical: payload.critical, sensitive: payload.sensitive, startedAt: exec.startedAt, completedAt: exec.completedAt, error: exec.error, }; } protected toDetailResource(exec: Record<string, unknown>) { const payload = (exec.payload ?? {}) as Record<string, unknown>; return { ...this.toResource(exec), variables: payload.variables, logs: exec.logs, }; } }