40227-vm/backend/src/services/content_catalog.ts
2026-06-18 10:09:11 +02:00

304 lines
8.6 KiB
TypeScript

import { Op } from 'sequelize';
import db from '@/db/models';
import { withTransaction } from '@/db/with-transaction';
import { resolvePagination } from '@/shared/constants/pagination';
import ForbiddenError from '@/shared/errors/forbidden';
import ValidationError from '@/shared/errors/validation';
import {
getRoleScope,
getOwnTenant,
getOrganizationId,
getSchoolId,
hasFeaturePermission,
tenantExactWhere,
tenantStamp,
} from '@/services/shared/access';
import {
CLASSROOM_SUPPORT_CONTENT_TYPE,
SAFETY_QUIZ_CONTENT_TYPE,
PER_TENANT_CONTENT_TYPES,
SCHOOL_SCOPED_CONTENT_TYPES,
ORG_SCOPED_CONTENT_TYPES,
TENANT_SCOPED_CONTENT_TYPES,
} from '@/shared/constants/content-catalog';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
import { ROLE_SCOPES } from '@/shared/constants/roles';
import type { ContentCatalog } from '@/db/models/content_catalog';
import type { CurrentUser } from '@/db/api/types';
/** Scope where for tenant-scoped content types; `{}` for shared/global types. */
function tenantWhereFor(
contentType: string,
currentUser?: CurrentUser,
): Record<string, unknown> {
if (PER_TENANT_CONTENT_TYPES.has(contentType)) {
return tenantExactWhere(getOwnTenant(currentUser));
}
if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) {
// Everyone in the school reads their school's row (the user's school is
// resolved from their own school/campus chain).
return { schoolId: getSchoolId(currentUser) ?? null };
}
if (ORG_SCOPED_CONTENT_TYPES.has(contentType)) {
return { organizationId: getOrganizationId(currentUser) ?? null };
}
return {};
}
/** Owning ids to stamp on a tenant-scoped content row; all null for shared. */
function tenantStampFor(contentType: string, currentUser?: CurrentUser) {
if (PER_TENANT_CONTENT_TYPES.has(contentType)) {
return tenantStamp(getOwnTenant(currentUser));
}
if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) {
return {
organizationId: getOrganizationId(currentUser),
schoolId: getSchoolId(currentUser),
campusId: null,
classId: null,
};
}
if (ORG_SCOPED_CONTENT_TYPES.has(contentType)) {
return {
organizationId: getOrganizationId(currentUser),
schoolId: null,
campusId: null,
classId: null,
};
}
return { organizationId: null, schoolId: null, campusId: null, classId: null };
}
interface ContentCatalogInput {
content_type?: unknown;
payload?: unknown;
active?: boolean;
importHash?: string | null;
}
function toContentCatalogDto(record: ContentCatalog) {
const plain = record.get({ plain: true });
return {
id: plain.id,
content_type: plain.content_type,
payload: plain.payload,
updatedAt: plain.updatedAt,
};
}
function assertCanManageContentCatalog(currentUser?: CurrentUser): void {
if (
hasFeaturePermission(
currentUser,
FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG,
)
) {
return;
}
throw new ForbiddenError();
}
/**
* Edit authorization per content type. Tenant scoping constrains the row; the
* right to edit content catalog data is a single effective permission.
*/
function assertCanManageType(contentType: string, currentUser?: CurrentUser): void {
if (
!hasFeaturePermission(
currentUser,
FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG,
)
) {
throw new ForbiddenError();
}
if (
(
contentType === CLASSROOM_SUPPORT_CONTENT_TYPE
|| contentType === SAFETY_QUIZ_CONTENT_TYPE
)
&& getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION
) {
throw new ForbiddenError();
}
}
function assertValidContentType(contentType: unknown): string {
if (typeof contentType !== 'string' || contentType.trim().length === 0) {
throw new ValidationError();
}
return contentType.trim();
}
function assertValidPayload(payload: unknown): unknown {
if (payload === undefined) {
throw new ValidationError();
}
return payload;
}
class ContentCatalogService {
static async list(
filter: { limit?: number | string; page?: number | string } = {},
currentUser?: CurrentUser,
) {
assertCanManageContentCatalog(currentUser);
const { limit, offset } = resolvePagination(filter.limit, filter.page);
// Per-tenant content types are managed via their dedicated editor (by type +
// tenant), so they are excluded from the shared management list.
const result = await db.content_catalog.findAndCountAll({
where: { content_type: { [Op.notIn]: [...TENANT_SCOPED_CONTENT_TYPES] } },
order: [['content_type', 'asc']],
limit,
offset,
});
return {
rows: result.rows.map(toContentCatalogDto),
count: result.count,
};
}
static async findByType(contentType: unknown, currentUser?: CurrentUser) {
const normalizedContentType = assertValidContentType(contentType);
// Tenant-scoped content is never served on the unauthenticated public path.
if (TENANT_SCOPED_CONTENT_TYPES.has(normalizedContentType) && !currentUser?.id) {
throw new ValidationError('contentCatalogNotFound');
}
const record = await db.content_catalog.findOne({
where: {
content_type: normalizedContentType,
active: true,
...tenantWhereFor(normalizedContentType, currentUser),
},
});
if (!record) {
throw new ValidationError('contentCatalogNotFound');
}
return toContentCatalogDto(record);
}
static async findManagedByType(contentType: unknown, currentUser?: CurrentUser) {
const normalizedContentType = assertValidContentType(contentType);
assertCanManageType(normalizedContentType, currentUser);
return this.findByType(normalizedContentType, currentUser);
}
static async create(data: ContentCatalogInput, currentUser?: CurrentUser) {
const contentType = assertValidContentType(data?.content_type);
assertCanManageType(contentType, currentUser);
const payload = assertValidPayload(data?.payload);
const tenantWhere = tenantWhereFor(contentType, currentUser);
const stamp = tenantStampFor(contentType, currentUser);
const existingRecord = await db.content_catalog.findOne({
where: { content_type: contentType, ...tenantWhere },
paranoid: false,
});
if (existingRecord && !existingRecord.deletedAt) {
throw new ValidationError();
}
return withTransaction(async (transaction) => {
let record: ContentCatalog;
if (existingRecord) {
await existingRecord.restore({ transaction });
record = await existingRecord.update(
{
payload,
active: data.active !== false,
importHash: data.importHash || null,
},
{ transaction },
);
} else {
record = await db.content_catalog.create(
{
content_type: contentType,
payload,
active: data.active !== false,
importHash: data.importHash || null,
...stamp,
},
{ transaction },
);
}
return toContentCatalogDto(record);
});
}
static async update(
contentType: unknown,
data: ContentCatalogInput,
currentUser?: CurrentUser,
) {
const normalizedContentType = assertValidContentType(contentType);
assertCanManageType(normalizedContentType, currentUser);
const payload = assertValidPayload(data?.payload);
return withTransaction(async (transaction) => {
const record = await db.content_catalog.findOne({
where: {
content_type: normalizedContentType,
...tenantWhereFor(normalizedContentType, currentUser),
},
transaction,
});
if (!record) {
throw new ValidationError('contentCatalogNotFound');
}
await record.update(
{
payload,
active: data.active !== false,
},
{ transaction },
);
return toContentCatalogDto(record);
});
}
static async delete(contentType: unknown, currentUser?: CurrentUser) {
const normalizedContentType = assertValidContentType(contentType);
assertCanManageType(normalizedContentType, currentUser);
return withTransaction(async (transaction) => {
const record = await db.content_catalog.findOne({
where: {
content_type: normalizedContentType,
...tenantWhereFor(normalizedContentType, currentUser),
},
transaction,
});
if (!record) {
throw new ValidationError('contentCatalogNotFound');
}
await record.update({ active: false }, { transaction });
await record.destroy({ transaction });
return true;
});
}
}
export default ContentCatalogService;