alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
162 lines (151 loc) • 5.62 kB
text/typescript
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,
};
}
}