Files
backend/src/instructions/instructions.service.ts
T
Alireza HassaniandCursor aaf7a83742 Add Schema.org JSON-LD, SEO meta overrides, and resilient bulk SMS.
Expose Organization/LocalBusiness and Product/BlogPosting jsonLd, optional seoMeta fields on content/categories, and continue mass SMS after per-recipient failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 23:30:24 +03:30

861 lines
25 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
ContentStatus,
MediaEntityType,
Prisma,
VideoProvider,
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import {
applySeoMetaToMetadata,
normalizeSeoMetaInput,
readSeoMetaFromMetadata,
} from '../common/seo-meta.util';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
CreateInstructionCommentDto,
CreateInstructionDto,
ListPublicInstructionsDto,
ListInstructionsDto,
UpdateInstructionDto,
} from './dto/instruction.dto';
function slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'instruction'
);
}
type InstructionWithRelations = Prisma.instructionsGetPayload<{
include: {
media: true;
author: {
select: { id: true; firstName: true; lastName: true; email: true };
};
};
}>;
const instructionInclude = {
media: true,
author: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
} satisfies Prisma.instructionsInclude;
@Injectable()
export class InstructionsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
private readonly businessSettings: BusinessSettingsService,
) {}
async list(businessIdRaw: string, query: ListInstructionsDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, query);
const [items, total] = await Promise.all([
this.prisma.instructions.findMany({
where,
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
skip,
take: pageSize,
include: instructionInclude,
}),
this.prisma.instructions.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) => this.serializeInstruction(item, { includeComments: true })),
);
return { items: serialized, total, page, pageSize };
}
async getOne(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const instructionId = BigInt(instructionIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.read');
const video = await this.findInstructionOrThrow(businessId, instructionId);
return {
instruction: await this.serializeInstruction(video, { includeComments: true }),
};
}
async create(businessIdRaw: string, dto: CreateInstructionDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.create');
const titleFa = this.resolveTitleFa(dto.titleFa, dto.title);
const titleEn = dto.titleEn?.trim() || null;
if (!titleFa) {
throw new BadRequestException('titleFa is required');
}
const slug = await this.ensureUniqueInstructionSlug(
businessId,
dto.slug ?? slugify(titleEn || titleFa),
);
const status = dto.status ?? ContentStatus.published;
const featuredMediaId = dto.featuredMediaId
? BigInt(dto.featuredMediaId)
: null;
if (featuredMediaId) {
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id;
await this.assertAuthorBelongsToBusiness(businessId, authorId, actor);
if (dto.categoryId) {
await this.assertCategoryBelongsToBusiness(
businessId,
BigInt(dto.categoryId),
);
}
const embedCode = dto.embedCode?.trim() || null;
const videoProvider = embedCode ? dto.videoProvider : null;
if (embedCode && !videoProvider) {
throw new BadRequestException(
'videoProvider is required when embedCode is set',
);
}
if (!embedCode && !(dto.descriptionHtml ?? '').trim()) {
throw new BadRequestException(
'Provide descriptionHtml or embedCode',
);
}
const created = await this.prisma.$transaction(async (tx) => {
const item = await tx.instructions.create({
data: {
business_id: businessId,
author_id: authorId,
title: titleFa,
title_fa: titleFa,
title_en: titleEn,
slug,
excerpt: dto.abstract?.trim() || null,
content: this.buildContent(dto.descriptionHtml) as Prisma.InputJsonValue,
video_provider: videoProvider,
embed_code: embedCode,
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
metadata: applySeoMetaToMetadata(
this.buildMetadata(dto.tags),
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
) as Prisma.InputJsonValue,
},
include: instructionInclude,
});
if (dto.categoryId) {
await tx.categoryAssignment.create({
data: {
businessId,
categoryId: BigInt(dto.categoryId),
entityType: MediaEntityType.instruction,
entityId: item.id,
},
});
}
return item;
});
return {
message: 'Instruction created successfully',
instruction: await this.serializeInstruction(created),
};
}
async update(
businessIdRaw: string,
instructionIdRaw: string,
dto: UpdateInstructionDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const instructionId = BigInt(instructionIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.update');
const existing = await this.prisma.instructions.findFirst({
where: { id: instructionId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Instruction not found');
}
const nextTitleFa =
dto.titleFa !== undefined || dto.title !== undefined
? this.resolveTitleFa(dto.titleFa, dto.title)
: undefined;
if (
(dto.titleFa !== undefined || dto.title !== undefined) &&
!nextTitleFa
) {
throw new BadRequestException('titleFa is required');
}
let slug = existing.slug;
if (dto.slug) {
slug = await this.ensureUniqueInstructionSlug(businessId, dto.slug, instructionId);
} else if (nextTitleFa && nextTitleFa !== (existing.title_fa ?? existing.title)) {
const slugBase =
(dto.titleEn !== undefined
? dto.titleEn?.trim() || null
: existing.title_en) || nextTitleFa;
slug = await this.ensureUniqueInstructionSlug(businessId, slugify(slugBase), instructionId);
}
let featuredMediaId: bigint | null | undefined = undefined;
if (dto.featuredMediaId !== undefined) {
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
featuredMediaId = null;
} else {
featuredMediaId = BigInt(dto.featuredMediaId);
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
}
let authorId: bigint | null | undefined = undefined;
if (dto.authorId !== undefined) {
if (dto.authorId === null || dto.authorId === '') {
authorId = null;
} else {
authorId = BigInt(dto.authorId);
await this.assertAuthorBelongsToBusiness(businessId, authorId, actor);
}
}
const existingContent = this.asRecord(existing.content);
const existingMetadata = this.asRecord(existing.metadata);
const nextContent = { ...existingContent };
if (dto.descriptionHtml !== undefined) {
nextContent.html = dto.descriptionHtml ?? '';
}
const nextMetadata = applySeoMetaToMetadata(
{
...existingMetadata,
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
},
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
);
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
if (
dto.status === ContentStatus.published &&
existing.status !== ContentStatus.published
) {
publishedAt = new Date();
}
if (dto.status !== ContentStatus.published) {
publishedAt = null;
}
}
const updated = await this.prisma.$transaction(async (tx) => {
const nextEmbed =
dto.embedCode !== undefined ? dto.embedCode?.trim() || null : undefined;
let nextProvider: VideoProvider | null | undefined = undefined;
if (dto.videoProvider !== undefined) {
nextProvider = dto.videoProvider ?? null;
} else if (nextEmbed !== undefined && !nextEmbed) {
nextProvider = null;
}
const video = await tx.instructions.update({
where: { id: instructionId },
data: {
...(nextTitleFa !== undefined
? { title: nextTitleFa, title_fa: nextTitleFa }
: {}),
...(dto.titleEn !== undefined
? { title_en: dto.titleEn?.trim() || null }
: {}),
...(dto.abstract !== undefined
? { excerpt: dto.abstract?.trim() || null }
: {}),
...(nextProvider !== undefined ? { video_provider: nextProvider } : {}),
...(nextEmbed !== undefined ? { embed_code: nextEmbed } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(featuredMediaId !== undefined
? { featured_media_id: featuredMediaId }
: {}),
...(authorId !== undefined ? { author_id: authorId } : {}),
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
slug,
content: nextContent as Prisma.InputJsonValue,
metadata: nextMetadata as Prisma.InputJsonValue,
},
include: instructionInclude,
});
if (dto.categoryId !== undefined) {
await tx.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
});
if (dto.categoryId) {
const categoryId = BigInt(dto.categoryId);
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
await tx.categoryAssignment.create({
data: {
businessId,
categoryId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
});
}
}
return video;
});
return {
message: 'Instruction updated successfully',
instruction: await this.serializeInstruction(updated),
};
}
async remove(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const instructionId = BigInt(instructionIdRaw);
await this.assertPermission(businessId, actor.id, 'instructions.delete');
const existing = await this.prisma.instructions.findFirst({
where: { id: instructionId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Instruction not found');
}
await this.prisma.$transaction([
this.prisma.comment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
}),
this.prisma.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
}),
this.prisma.instructions.delete({ where: { id: instructionId } }),
]);
return { message: 'Instruction deleted successfully' };
}
async listPublic(host: string, query: ListPublicInstructionsDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, {
...query,
status: ContentStatus.published,
});
const [items, total] = await Promise.all([
this.prisma.instructions.findMany({
where,
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
skip,
take: pageSize,
include: instructionInclude,
}),
this.prisma.instructions.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) =>
this.serializeInstruction(item, {
includeComments: true,
approvedCommentsOnly: true,
}),
),
);
return { items: serialized, total, page, pageSize };
}
async getPublicBySlug(host: string, slug: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const video = await this.prisma.instructions.findFirst({
where: {
business_id: businessId,
slug,
status: ContentStatus.published,
},
include: instructionInclude,
});
if (!video) {
throw new NotFoundException('Instruction not found');
}
return {
instruction: await this.serializeInstruction(video, {
includeComments: true,
approvedCommentsOnly: true,
}),
};
}
async listCommentsPublic(host: string, instructionIdRaw: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const instructionId = BigInt(instructionIdRaw);
await this.assertPublishedInstructionExists(businessId, instructionId);
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
isApproved: true,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
async createCommentPublic(
host: string,
instructionIdRaw: string,
dto: CreateInstructionCommentDto,
) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const instructionId = BigInt(instructionIdRaw);
await this.assertPublishedInstructionExists(businessId, instructionId);
const autoApprove =
await this.businessSettings.isCommentsAutoApprove(businessId);
const approvedAt = autoApprove ? new Date() : null;
const created = await this.prisma.comment.create({
data: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
authorName: dto.authorName.trim(),
authorEmail: dto.authorEmail?.trim() || null,
text: dto.text.trim(),
isApproved: autoApprove,
approvedAt,
},
include: { approver: true },
});
return {
comment: this.serializeComment(created),
message: autoApprove
? 'Comment submitted and is approved'
: 'Comment submitted and is pending approval',
};
}
async listCommentsAdmin(
businessIdRaw: string,
instructionIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const instructionId = BigInt(instructionIdRaw);
await this.assertPermission(businessId, actor.id, 'comments.read');
const video = await this.prisma.instructions.findFirst({
where: { id: instructionId, business_id: businessId },
select: { id: true },
});
if (!video) {
throw new NotFoundException('Instruction not found');
}
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
private resolveTitleFa(titleFa?: string, title?: string): string {
return (titleFa ?? title ?? '').trim();
}
private async buildWhere(
businessId: bigint,
query: (ListInstructionsDto | ListPublicInstructionsDto) & { status?: ContentStatus },
): Promise<Prisma.instructionsWhereInput> {
let entityIds: bigint[] | undefined;
if (query.categoryId) {
const assignments = await this.prisma.categoryAssignment.findMany({
where: {
businessId,
categoryId: BigInt(query.categoryId),
entityType: MediaEntityType.instruction,
},
select: { entityId: true },
});
entityIds = assignments.map((item) => item.entityId);
if (entityIds.length === 0) {
return { id: { in: [] } };
}
}
return {
business_id: businessId,
...(query.status ? { status: query.status } : {}),
...(query.provider ? { video_provider: query.provider } : {}),
...(entityIds ? { id: { in: entityIds } } : {}),
...(query.title?.trim()
? {
OR: [
{
title_fa: {
contains: query.title.trim(),
mode: 'insensitive',
},
},
{
title_en: {
contains: query.title.trim(),
mode: 'insensitive',
},
},
{
title: { contains: query.title.trim(), mode: 'insensitive' },
},
],
}
: {}),
};
}
private async findInstructionOrThrow(businessId: bigint, instructionId: bigint) {
const video = await this.prisma.instructions.findFirst({
where: { id: instructionId, business_id: businessId },
include: instructionInclude,
});
if (!video) {
throw new NotFoundException('Instruction not found');
}
return video;
}
private async assertPublishedInstructionExists(
businessId: bigint,
instructionId: bigint,
) {
const video = await this.prisma.instructions.findFirst({
where: {
id: instructionId,
business_id: businessId,
status: ContentStatus.published,
},
select: { id: true },
});
if (!video) {
throw new NotFoundException('Instruction not found');
}
}
private async serializeInstruction(
video: InstructionWithRelations,
options: {
includeComments?: boolean;
approvedCommentsOnly?: boolean;
} = {},
) {
const content = this.asRecord(video.content);
const metadata = this.asRecord(video.metadata);
const [categoryAssignment, commentData] = await Promise.all([
this.prisma.categoryAssignment.findFirst({
where: {
businessId: video.business_id,
entityType: MediaEntityType.instruction,
entityId: video.id,
},
include: { category: true },
}),
options.includeComments
? this.loadComments(
video.business_id,
video.id,
options.approvedCommentsOnly,
)
: Promise.resolve({ commentCount: 0, comments: [] }),
]);
return {
id: video.id.toString(),
businessId: video.business_id.toString(),
title: video.title,
titleFa: video.title_fa ?? video.title,
titleEn: video.title_en,
slug: video.slug,
abstract: video.excerpt ?? '',
descriptionHtml: (content.html as string | undefined) ?? '',
videoProvider: video.video_provider ?? null,
embedCode: video.embed_code ?? '',
status: video.status,
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
...readSeoMetaFromMetadata(metadata),
authorId: video.author_id?.toString() ?? null,
author: video.author
? {
id: video.author.id.toString(),
firstName: video.author.firstName,
lastName: video.author.lastName,
email: video.author.email,
}
: null,
coverImageUrl: video.media?.publicUrl ?? null,
featuredMediaId: video.featured_media_id?.toString() ?? null,
commentCount: commentData.commentCount,
comments: commentData.comments,
publishedAt: video.published_at,
createdAt: video.created_at,
updatedAt: video.updated_at,
};
}
private async loadComments(
businessId: bigint,
instructionId: bigint,
approvedOnly?: boolean,
) {
const where: Prisma.CommentWhereInput = {
businessId,
entityType: MediaEntityType.instruction,
entityId: instructionId,
...(approvedOnly ? { isApproved: true } : {}),
};
const [commentCount, comments] = await Promise.all([
this.prisma.comment.count({ where }),
this.prisma.comment.findMany({
where,
orderBy: { createdAt: 'desc' },
take: approvedOnly ? 50 : undefined,
include: { approver: true },
}),
]);
return {
commentCount,
comments: comments.map((item) => this.serializeComment(item)),
};
}
private serializeComment(
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
) {
return {
id: comment.id.toString(),
businessId: comment.businessId.toString(),
entityType: comment.entityType,
entityId: comment.entityId.toString(),
authorName: comment.authorName,
authorEmail: comment.authorEmail,
text: comment.text,
isApproved: comment.isApproved,
approvedAt: comment.approvedAt,
approvedBy: comment.approvedBy?.toString() ?? null,
approver: comment.approver
? {
id: comment.approver.id.toString(),
firstName: comment.approver.firstName,
lastName: comment.approver.lastName,
}
: null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
private buildContent(descriptionHtml?: string) {
return {
html: descriptionHtml ?? '',
};
}
private buildMetadata(tags?: string[]) {
return {
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
};
}
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
private async assertMediaBelongsToBusiness(
businessId: bigint,
mediaId: bigint,
) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
});
if (!media) {
throw new BadRequestException('Media not found for this business');
}
}
private async assertCategoryBelongsToBusiness(
businessId: bigint,
categoryId: bigint,
) {
const category = await this.prisma.category.findFirst({
where: {
id: categoryId,
businessId,
entityType: MediaEntityType.instruction,
isActive: true,
},
});
if (!category) {
throw new BadRequestException('Instruction category not found for this business');
}
}
private async assertAuthorBelongsToBusiness(
businessId: bigint,
authorId: bigint,
actor: AuthUser,
) {
if (await this.permissions.isSuperAdmin(actor.id)) {
return;
}
const member = await this.prisma.businessUser.findFirst({
where: { businessId, userId: authorId },
});
if (!member) {
throw new BadRequestException(
'Author must be a team member of this business',
);
}
}
private async ensureUniqueInstructionSlug(
businessId: bigint,
baseSlug: string,
excludeId?: bigint,
) {
let slug = baseSlug;
let suffix = 1;
while (true) {
const existing = await this.prisma.instructions.findFirst({
where: {
business_id: businessId,
slug,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!existing) {
return slug;
}
suffix += 1;
slug = `${baseSlug}-${suffix}`;
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
}
}