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 { 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;