304 lines
8.6 KiB
TypeScript
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;
|