From c7d5204b49992399445fb9ef0e21b608dbc65092 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 22 Aug 2026 12:22:24 +0330 Subject: [PATCH] Add workshops CMS API with schedule, registrations, and public storefront routes. Includes SQL migrations, Prisma models, dashboard CRUD, registration status workflow, and website OpenAPI updates for workshop listing and signup. Co-authored-by: Cursor --- database/migrations/064_workshops.sql | 88 ++ ..._workshop_schedule_registration_status.sql | 13 + .../migrations/066_workshop_duration_text.sql | 11 + docs/website-api/openapi.json | 56 + prisma/schema.prisma | 63 + src/app.module.ts | 2 + src/blogs/blogs.service.ts | 14 +- src/business-admin/business-admin.service.ts | 10 + .../business-settings.types.ts | 15 +- src/customers/customers.service.ts | 25 +- src/instructions/instructions.service.ts | 9 +- src/store/dto/store-specials.dto.ts | 12 + src/store/store-specials.service.ts | 84 +- src/videos/videos.service.ts | 9 +- src/website-docs/static/openapi.json | 56 + src/workshops/dto/workshop.dto.ts | 219 ++++ src/workshops/workshops.controller.ts | 172 +++ src/workshops/workshops.module.ts | 15 + src/workshops/workshops.service.ts | 1065 +++++++++++++++++ 19 files changed, 1919 insertions(+), 19 deletions(-) create mode 100644 database/migrations/064_workshops.sql create mode 100644 database/migrations/065_workshop_schedule_registration_status.sql create mode 100644 database/migrations/066_workshop_duration_text.sql create mode 100644 src/workshops/dto/workshop.dto.ts create mode 100644 src/workshops/workshops.controller.ts create mode 100644 src/workshops/workshops.module.ts create mode 100644 src/workshops/workshops.service.ts diff --git a/database/migrations/064_workshops.sql b/database/migrations/064_workshops.sql new file mode 100644 index 0000000..ecee913 --- /dev/null +++ b/database/migrations/064_workshops.sql @@ -0,0 +1,88 @@ +-- Workshops module (rich text + image gallery + customer registration) + +ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'workshop'; + +CREATE TABLE workshops ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + author_id BIGINT, + title VARCHAR(255) NOT NULL, + title_fa VARCHAR(255), + title_en VARCHAR(255), + slug VARCHAR(255) NOT NULL, + excerpt TEXT, + content JSONB NOT NULL DEFAULT '{}', + status content_status NOT NULL DEFAULT 'draft', + featured_media_id BIGINT, + published_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT workshops_business_slug_unique UNIQUE (business_id, slug), + CONSTRAINT workshops_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT workshops_author_id_fkey + FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT workshops_featured_media_id_fkey + FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL +); + +CREATE INDEX idx_workshops_business_id ON workshops (business_id); +CREATE INDEX idx_workshops_business_status ON workshops (business_id, status); +CREATE INDEX idx_workshops_business_published ON workshops (business_id, published_at DESC); +CREATE INDEX idx_workshops_author_id ON workshops (author_id); + +CREATE TRIGGER workshops_set_updated_at + BEFORE UPDATE ON workshops + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE workshop_registrations ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + workshop_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT workshop_registrations_workshop_user_unique UNIQUE (workshop_id, user_id), + CONSTRAINT workshop_registrations_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT workshop_registrations_workshop_id_fkey + FOREIGN KEY (workshop_id) REFERENCES workshops (id) ON DELETE CASCADE, + CONSTRAINT workshop_registrations_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_workshop_registrations_business_id ON workshop_registrations (business_id); +CREATE INDEX idx_workshop_registrations_workshop_id ON workshop_registrations (workshop_id); +CREATE INDEX idx_workshop_registrations_user_id ON workshop_registrations (user_id); + +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View workshops', 'workshops.read', 'workshops', 'View workshops'), + ('Create workshops', 'workshops.create', 'workshops', 'Create workshops'), + ('Update workshops', 'workshops.update', 'workshops', 'Edit workshops'), + ('Delete workshops', 'workshops.delete', 'workshops', 'Delete workshops'), + ('Publish workshops', 'workshops.publish', 'workshops', 'Publish and unpublish workshops') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'workshops.%' +WHERE r.slug IN ('business_owner', 'owner', 'admin') +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('workshops.read', 'workshops.create', 'workshops.update', 'workshops.publish') +WHERE r.slug = 'editor' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'workshops.read' +WHERE r.slug = 'viewer' +ON CONFLICT DO NOTHING; diff --git a/database/migrations/065_workshop_schedule_registration_status.sql b/database/migrations/065_workshop_schedule_registration_status.sql new file mode 100644 index 0000000..8f936ad --- /dev/null +++ b/database/migrations/065_workshop_schedule_registration_status.sql @@ -0,0 +1,13 @@ +-- Workshop schedule fields + registration approval status + +CREATE TYPE workshop_registration_status AS ENUM ('pending', 'approved', 'rejected'); + +ALTER TABLE workshops + ADD COLUMN event_date DATE, + ADD COLUMN event_time VARCHAR(5), + ADD COLUMN duration_minutes INTEGER; + +ALTER TABLE workshop_registrations + ADD COLUMN status workshop_registration_status NOT NULL DEFAULT 'pending'; + +CREATE INDEX idx_workshop_registrations_status ON workshop_registrations (workshop_id, status); diff --git a/database/migrations/066_workshop_duration_text.sql b/database/migrations/066_workshop_duration_text.sql new file mode 100644 index 0000000..3913954 --- /dev/null +++ b/database/migrations/066_workshop_duration_text.sql @@ -0,0 +1,11 @@ +-- Replace fixed minute duration with free-text duration label + +ALTER TABLE workshops + ADD COLUMN duration VARCHAR(100); + +UPDATE workshops +SET duration = duration_minutes::text || ' minutes' +WHERE duration_minutes IS NOT NULL; + +ALTER TABLE workshops + DROP COLUMN duration_minutes; diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index caa31ef..23bd55e 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -2763,6 +2763,62 @@ } } }, + "/tenants/{domain}/workshops": { + "get": { + "tags": ["Workshops"], + "summary": "List published workshops", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "page", "in": "query", "schema": { "type": "integer" } }, + { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, + { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, + { "name": "title", "in": "query", "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "{ items, total, page, pageSize }" } + } + } + }, + "/tenants/{domain}/workshops/{slug}": { + "get": { + "tags": ["Workshops"], + "summary": "Workshop by slug", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "{ workshop } — includes eventDate, eventTime, duration" } + } + } + }, + "/tenants/{domain}/workshops/{workshopId}/register": { + "post": { + "tags": ["Workshops"], + "summary": "Register logged-in website customer for a workshop", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "workshopId", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "201": { "description": "{ registration: { id, status: pending|approved|rejected, ... }, message }" }, + "409": { "description": "Already registered" } + } + }, + "delete": { + "tags": ["Workshops"], + "summary": "Cancel workshop registration", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "workshopId", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "{ message }" } + } + } + }, "/businesses/{businessId}/payments/methods": { "get": { "tags": [ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5957a3c..39ece41 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,6 +40,8 @@ model User { authoredPortfolios portfolios[] @relation("PortfolioAuthor") authoredVideos videos[] @relation("VideoAuthor") authoredInstructions instructions[] @relation("InstructionAuthor") + authoredWorkshops workshops[] @relation("WorkshopAuthor") + workshopRegistrations workshop_registrations[] shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator") shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer") transactionsCreated Transaction[] @relation("TransactionCreator") @@ -97,6 +99,8 @@ model Business { portfolios portfolios[] videos videos[] instructions instructions[] + workshops workshops[] + workshop_registrations workshop_registrations[] productTechnicalFieldValues ProductTechnicalFieldValue[] products Product[] shoppingCards ShoppingCard[] @@ -290,6 +294,7 @@ model Media { portfolios portfolios[] videos videos[] instructions instructions[] + workshops workshops[] featuredProducts Product[] @relation("ProductFeaturedMedia") featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia") website_image_slot_items website_image_slot_items[] @@ -741,6 +746,63 @@ model instructions { @@index([author_id], map: "idx_instructions_author_id") } +model workshops { + id BigInt @id @default(autoincrement()) + business_id BigInt + author_id BigInt? + title String @db.VarChar(255) + title_fa String? @db.VarChar(255) + title_en String? @db.VarChar(255) + slug String @db.VarChar(255) + excerpt String? + content Json @default("{}") + status ContentStatus @default(draft) + featured_media_id BigInt? + published_at DateTime? @db.Timestamptz(6) + metadata Json @default("{}") + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + author User? @relation("WorkshopAuthor", fields: [author_id], references: [id], onUpdate: NoAction) + businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction) + workshop_registrations workshop_registrations[] + + event_date DateTime? @db.Date + event_time String? @db.VarChar(5) + duration String? @db.VarChar(100) + + @@unique([business_id, slug], map: "workshops_business_slug_unique") + @@index([business_id], map: "idx_workshops_business_id") + @@index([business_id, published_at(sort: Desc)], map: "idx_workshops_business_published") + @@index([business_id, status], map: "idx_workshops_business_status") + @@index([author_id], map: "idx_workshops_author_id") +} + +enum WorkshopRegistrationStatus { + pending + approved + rejected + + @@map("workshop_registration_status") +} + +model workshop_registrations { + id BigInt @id @default(autoincrement()) + business_id BigInt + workshop_id BigInt + user_id BigInt + status WorkshopRegistrationStatus @default(pending) + created_at DateTime @default(now()) @db.Timestamptz(6) + business Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + workshop workshops @relation(fields: [workshop_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([workshop_id, user_id], map: "workshop_registrations_workshop_user_unique") + @@index([business_id], map: "idx_workshop_registrations_business_id") + @@index([workshop_id], map: "idx_workshop_registrations_workshop_id") + @@index([user_id], map: "idx_workshop_registrations_user_id") +} + /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. model Address { id BigInt @id @default(autoincrement()) @@ -1455,6 +1517,7 @@ enum MediaEntityType { user_product video instruction + workshop @@map("media_entity_type") } diff --git a/src/app.module.ts b/src/app.module.ts index 4e67888..2161327 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { BlogsModule } from './blogs/blogs.module'; import { PortfoliosModule } from './portfolios/portfolios.module'; import { VideosModule } from './videos/videos.module'; import { InstructionsModule } from './instructions/instructions.module'; +import { WorkshopsModule } from './workshops/workshops.module'; import { CommentsModule } from './comments/comments.module'; import { CitiesModule } from './cities/cities.module'; import { ExpertReviewsModule } from './expert-reviews/expert-reviews.module'; @@ -63,6 +64,7 @@ import { PaymentsModule } from './payments/payments.module'; PortfoliosModule, VideosModule, InstructionsModule, + WorkshopsModule, CommentsModule, ExpertReviewsModule, BusinessSettingsModule, diff --git a/src/blogs/blogs.service.ts b/src/blogs/blogs.service.ts index 3d74a48..b36cd2d 100644 --- a/src/blogs/blogs.service.ts +++ b/src/blogs/blogs.service.ts @@ -117,7 +117,7 @@ export class BlogsService { } const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id; - await this.assertAuthorBelongsToBusiness(businessId, authorId); + await this.assertAuthorBelongsToBusiness(businessId, authorId, actor); if (dto.categoryId) { await this.assertCategoryBelongsToBusiness(businessId, BigInt(dto.categoryId)); @@ -202,7 +202,7 @@ export class BlogsService { authorId = null; } else { authorId = BigInt(dto.authorId); - await this.assertAuthorBelongsToBusiness(businessId, authorId); + await this.assertAuthorBelongsToBusiness(businessId, authorId, actor); } } @@ -679,7 +679,15 @@ export class BlogsService { } } - private async assertAuthorBelongsToBusiness(businessId: bigint, authorId: bigint) { + 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 }, }); diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 3ed9753..a85206c 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -30,6 +30,7 @@ import { normalizeDashboardThemeMode, normalizeEnabledBusinessModules, normalizeHomeCharts, + toPrismaJson, } from '../business-settings/business-settings.util'; import type { BusinessModuleId, @@ -38,8 +39,10 @@ import type { HomeChartId, } from '../business-settings/business-settings.types'; import { + DEFAULT_BUSINESS_SETTINGS, DEFAULT_ENABLED_BUSINESS_MODULES, DEFAULT_HOME_CHARTS, + DEFAULT_NEW_BUSINESS_MODULES, } from '../business-settings/business-settings.types'; import { ArvanDnsService } from '../arvan-dns/arvan-dns.service'; import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service'; @@ -371,6 +374,13 @@ export class BusinessAdminService { slug, oldBusinessId: dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined, + settings: toPrismaJson({ + ...DEFAULT_BUSINESS_SETTINGS, + modules: { + enabled: [...DEFAULT_NEW_BUSINESS_MODULES], + charts: [...DEFAULT_HOME_CHARTS], + }, + }), }, }); diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index bfdbef9..896dd68 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -118,6 +118,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [ 'warehouse', 'videos', 'instructions', + 'workshops', 'finance', ] as const; @@ -188,13 +189,19 @@ export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [ }, ]; +/** Modules enabled when a new business is created (super-admin). */ +export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [ + 'products', + 'blog', + 'finance', +]; + /** * Existing tenants without `settings.modules` keep every business-dashboard module - * enabled. Customer modules stay opt-in. + * enabled except opt-in modules. Customer modules stay opt-in. */ -export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [ - ...BUSINESS_DASHBOARD_MODULE_IDS, -]; +export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = + BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops'); export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [ 'orders_30d', diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index e4eac09..487d702 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -182,7 +182,7 @@ export class CustomersService { actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); - await this.assertPermission(businessId, actor.id, 'orders.create'); + await this.assertCustomerSearchPermission(businessId, actor.id); const q = query.q.trim(); const limit = Math.min(Math.max(query.limit ?? 20, 1), 50); @@ -476,6 +476,29 @@ export class CustomersService { return user.email ? `${user.email} · ${user.cellNumber}` : user.cellNumber; } + private async assertCustomerSearchPermission( + businessId: bigint, + userId: bigint, + ) { + const allowed = + (await this.permissions.hasBusinessPermission( + userId, + businessId, + 'orders.create', + )) || + (await this.permissions.hasBusinessPermission( + userId, + businessId, + 'workshops.update', + )); + + if (!allowed) { + throw new ForbiddenException( + 'Missing permission to search customers for this business', + ); + } + } + private async assertPermission( businessId: bigint, userId: bigint, diff --git a/src/instructions/instructions.service.ts b/src/instructions/instructions.service.ts index 0a1db2f..c16c9f0 100644 --- a/src/instructions/instructions.service.ts +++ b/src/instructions/instructions.service.ts @@ -128,7 +128,7 @@ export class InstructionsService { } const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id; - await this.assertAuthorBelongsToBusiness(businessId, authorId); + await this.assertAuthorBelongsToBusiness(businessId, authorId, actor); if (dto.categoryId) { await this.assertCategoryBelongsToBusiness( @@ -248,7 +248,7 @@ export class InstructionsService { authorId = null; } else { authorId = BigInt(dto.authorId); - await this.assertAuthorBelongsToBusiness(businessId, authorId); + await this.assertAuthorBelongsToBusiness(businessId, authorId, actor); } } @@ -785,7 +785,12 @@ export class InstructionsService { 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 }, }); diff --git a/src/store/dto/store-specials.dto.ts b/src/store/dto/store-specials.dto.ts index a60ab61..d8b07fa 100644 --- a/src/store/dto/store-specials.dto.ts +++ b/src/store/dto/store-specials.dto.ts @@ -32,6 +32,12 @@ export class CreateStoreSpecialDto { @IsString({ each: true }) storeItemIds?: string[]; + /** Catalog products — resolved to store items (created if missing). */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + productIds?: string[]; + @IsOptional() @IsInt() sortOrder?: number; @@ -60,6 +66,12 @@ export class UpdateStoreSpecialDto { @IsString({ each: true }) storeItemIds?: string[]; + /** Catalog products — resolved to store items (created if missing). */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + productIds?: string[]; + @IsOptional() @IsInt() sortOrder?: number; diff --git a/src/store/store-specials.service.ts b/src/store/store-specials.service.ts index 5ab8a6d..9ff3c53 100644 --- a/src/store/store-specials.service.ts +++ b/src/store/store-specials.service.ts @@ -151,10 +151,11 @@ export class StoreSpecialsService { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'products.update'); - const storeItemIds = this.parseUniqueStoreItemIds(dto.storeItemIds ?? []); - if (storeItemIds.length > 0) { - await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds); - } + const storeItemIds = await this.resolveLinkedStoreItemIds( + businessId, + dto.storeItemIds, + dto.productIds, + ); const key = normalizeSpecialGroupKey(dto.key); await this.assertKeyAvailable(businessId, key); @@ -201,9 +202,12 @@ export class StoreSpecialsService { await this.findSpecialOrThrow(businessId, specialId); let storeItemIds: bigint[] | undefined; - if (dto.storeItemIds !== undefined) { - storeItemIds = this.parseUniqueStoreItemIds(dto.storeItemIds); - await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds); + if (dto.storeItemIds !== undefined || dto.productIds !== undefined) { + storeItemIds = await this.resolveLinkedStoreItemIds( + businessId, + dto.storeItemIds, + dto.productIds, + ); } const nextKey = @@ -277,6 +281,42 @@ export class StoreSpecialsService { return unique.map((id) => BigInt(id)); } + /** + * Prefer explicit storeItemIds. Otherwise resolve productIds to store items + * (creating a store_items row when the product is not yet in the store). + */ + private async resolveLinkedStoreItemIds( + businessId: bigint, + storeItemIdsRaw: string[] | undefined, + productIdsRaw: string[] | undefined, + ) { + if (storeItemIdsRaw !== undefined) { + const storeItemIds = this.parseUniqueStoreItemIds(storeItemIdsRaw); + if (storeItemIds.length > 0) { + await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds); + } + return storeItemIds; + } + + if (productIdsRaw === undefined) { + return []; + } + + const productIds = this.parseUniqueStoreItemIds(productIdsRaw); + if (productIds.length === 0) { + return []; + } + + await this.assertProductsBelongToBusiness(businessId, productIds); + + const storeItemIds: bigint[] = []; + for (const productId of productIds) { + const storeItem = await this.ensureStoreItem(businessId, productId); + storeItemIds.push(storeItem.id); + } + return storeItemIds; + } + private async assertStoreItemsBelongToBusiness( businessId: bigint, storeItemIds: bigint[], @@ -291,6 +331,36 @@ export class StoreSpecialsService { } } + private async assertProductsBelongToBusiness( + businessId: bigint, + productIds: bigint[], + ) { + const found = await this.prisma.product.findMany({ + where: { businessId, id: { in: productIds } }, + select: { id: true }, + }); + + if (found.length !== productIds.length) { + throw new BadRequestException( + 'One or more products were not found for this business', + ); + } + } + + private async ensureStoreItem(businessId: bigint, productId: bigint) { + const existing = await this.prisma.storeItem.findUnique({ + where: { businessId_productId: { businessId, productId } }, + }); + + if (existing) { + return existing; + } + + return this.prisma.storeItem.create({ + data: { businessId, productId }, + }); + } + private async replaceItems( tx: Prisma.TransactionClient, specialId: bigint, diff --git a/src/videos/videos.service.ts b/src/videos/videos.service.ts index 20c052d..97a1613 100644 --- a/src/videos/videos.service.ts +++ b/src/videos/videos.service.ts @@ -128,7 +128,7 @@ export class VideosService { } const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id; - await this.assertAuthorBelongsToBusiness(businessId, authorId); + await this.assertAuthorBelongsToBusiness(businessId, authorId, actor); if (dto.categoryId) { await this.assertCategoryBelongsToBusiness( @@ -235,7 +235,7 @@ export class VideosService { authorId = null; } else { authorId = BigInt(dto.authorId); - await this.assertAuthorBelongsToBusiness(businessId, authorId); + await this.assertAuthorBelongsToBusiness(businessId, authorId, actor); } } @@ -767,7 +767,12 @@ export class VideosService { 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 }, }); diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index caa31ef..23bd55e 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -2763,6 +2763,62 @@ } } }, + "/tenants/{domain}/workshops": { + "get": { + "tags": ["Workshops"], + "summary": "List published workshops", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "page", "in": "query", "schema": { "type": "integer" } }, + { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, + { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, + { "name": "title", "in": "query", "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "{ items, total, page, pageSize }" } + } + } + }, + "/tenants/{domain}/workshops/{slug}": { + "get": { + "tags": ["Workshops"], + "summary": "Workshop by slug", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "{ workshop } — includes eventDate, eventTime, duration" } + } + } + }, + "/tenants/{domain}/workshops/{workshopId}/register": { + "post": { + "tags": ["Workshops"], + "summary": "Register logged-in website customer for a workshop", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "workshopId", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "201": { "description": "{ registration: { id, status: pending|approved|rejected, ... }, message }" }, + "409": { "description": "Already registered" } + } + }, + "delete": { + "tags": ["Workshops"], + "summary": "Cancel workshop registration", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "workshopId", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "{ message }" } + } + } + }, "/businesses/{businessId}/payments/methods": { "get": { "tags": [ diff --git a/src/workshops/dto/workshop.dto.ts b/src/workshops/dto/workshop.dto.ts new file mode 100644 index 0000000..6f01849 --- /dev/null +++ b/src/workshops/dto/workshop.dto.ts @@ -0,0 +1,219 @@ +import { ContentStatus } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsInt, + IsOptional, + IsString, + Matches, + Min, + MinLength, + Max, + MaxLength, +} from 'class-validator'; + +export enum WorkshopRegistrationStatusDto { + pending = 'pending', + approved = 'approved', + rejected = 'rejected', +} + +export class ListWorkshopsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + title?: string; +} + +export class ListPublicWorkshopsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + title?: string; +} + +export class CreateWorkshopDto { + @IsString() + @MinLength(2) + titleFa!: string; + + @IsOptional() + @IsString() + titleEn?: string; + + /** @deprecated use titleFa */ + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + abstract?: string; + + @IsOptional() + @IsString() + descriptionHtml?: string; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + featuredMediaId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + galleryMediaIds?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + authorId?: string; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; + + /** YYYY-MM-DD */ + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + eventDate?: string | null; + + /** HH:mm (24h) */ + @IsOptional() + @IsString() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) + eventTime?: string | null; + + @IsOptional() + @IsString() + @MaxLength(100) + duration?: string | null; +} + +export class UpdateWorkshopDto { + @IsOptional() + @IsString() + @MinLength(2) + titleFa?: string; + + @IsOptional() + @IsString() + titleEn?: string | null; + + /** @deprecated use titleFa */ + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + abstract?: string | null; + + @IsOptional() + @IsString() + descriptionHtml?: string | null; + + @IsOptional() + @IsString() + categoryId?: string | null; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + featuredMediaId?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + galleryMediaIds?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + authorId?: string | null; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; + + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + eventDate?: string | null; + + @IsOptional() + @IsString() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) + eventTime?: string | null; + + @IsOptional() + @IsString() + @MaxLength(100) + duration?: string | null; +} + +export class AddWorkshopRegistrationDto { + @IsString() + userId!: string; +} + +export class UpdateWorkshopRegistrationDto { + @IsEnum(WorkshopRegistrationStatusDto) + status!: WorkshopRegistrationStatusDto; +} diff --git a/src/workshops/workshops.controller.ts b/src/workshops/workshops.controller.ts new file mode 100644 index 0000000..d34ab23 --- /dev/null +++ b/src/workshops/workshops.controller.ts @@ -0,0 +1,172 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { WorkshopsService } from './workshops.service'; +import { + CreateWorkshopDto, + AddWorkshopRegistrationDto, + UpdateWorkshopRegistrationDto, + ListPublicWorkshopsDto, + ListWorkshopsDto, + UpdateWorkshopDto, +} from './dto/workshop.dto'; + +@Controller('businesses/:businessId/workshops') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class WorkshopsController { + constructor(private readonly service: WorkshopsService) {} + + @Get() + @RequireBusinessPermission('workshops.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListWorkshopsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':workshopId/registrations') + @RequireBusinessPermission('workshops.read') + listRegistrations( + @Param('businessId') businessId: string, + @Param('workshopId') workshopId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.listRegistrations(businessId, workshopId, user); + } + + @Post(':workshopId/registrations') + @RequireBusinessPermission('workshops.update') + addRegistration( + @Param('businessId') businessId: string, + @Param('workshopId') workshopId: string, + @Body() dto: AddWorkshopRegistrationDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.addRegistration(businessId, workshopId, dto, user); + } + + @Patch(':workshopId/registrations/:registrationId') + @RequireBusinessPermission('workshops.update') + updateRegistration( + @Param('businessId') businessId: string, + @Param('workshopId') workshopId: string, + @Param('registrationId') registrationId: string, + @Body() dto: UpdateWorkshopRegistrationDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateRegistration( + businessId, + workshopId, + registrationId, + dto, + user, + ); + } + + @Delete(':workshopId/registrations/:registrationId') + @RequireBusinessPermission('workshops.update') + removeRegistration( + @Param('businessId') businessId: string, + @Param('workshopId') workshopId: string, + @Param('registrationId') registrationId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.removeRegistration( + businessId, + workshopId, + registrationId, + user, + ); + } + + @Get(':workshopId') + @RequireBusinessPermission('workshops.read') + getOne( + @Param('businessId') businessId: string, + @Param('workshopId') workshopId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, workshopId, user); + } + + @Post() + @RequireBusinessPermission('workshops.create') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateWorkshopDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':workshopId') + @RequireBusinessPermission('workshops.update') + update( + @Param('businessId') businessId: string, + @Param('workshopId') workshopId: string, + @Body() dto: UpdateWorkshopDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, workshopId, dto, user); + } + + @Delete(':workshopId') + @RequireBusinessPermission('workshops.delete') + remove( + @Param('businessId') businessId: string, + @Param('workshopId') workshopId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, workshopId, user); + } +} + +@Controller('tenants/:host/workshops') +export class PublicWorkshopsController { + constructor(private readonly service: WorkshopsService) {} + + @Get() + list(@Param('host') host: string, @Query() query: ListPublicWorkshopsDto) { + return this.service.listPublic(host, query); + } + + @Post(':workshopId/register') + @UseGuards(JwtAuthGuard) + register( + @Param('host') host: string, + @Param('workshopId') workshopId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.registerPublic(host, workshopId, user); + } + + @Delete(':workshopId/register') + @UseGuards(JwtAuthGuard) + unregister( + @Param('host') host: string, + @Param('workshopId') workshopId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.unregisterPublic(host, workshopId, user); + } + + @Get(':slug') + getBySlug(@Param('host') host: string, @Param('slug') slug: string) { + return this.service.getPublicBySlug(host, slug); + } +} diff --git a/src/workshops/workshops.module.ts b/src/workshops/workshops.module.ts new file mode 100644 index 0000000..ef32f1c --- /dev/null +++ b/src/workshops/workshops.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { + PublicWorkshopsController, + WorkshopsController, +} from './workshops.controller'; +import { WorkshopsService } from './workshops.service'; + +@Module({ + imports: [AuthModule, TenantModule], + controllers: [WorkshopsController, PublicWorkshopsController], + providers: [WorkshopsService], +}) +export class WorkshopsModule {} diff --git a/src/workshops/workshops.service.ts b/src/workshops/workshops.service.ts new file mode 100644 index 0000000..08d087b --- /dev/null +++ b/src/workshops/workshops.service.ts @@ -0,0 +1,1065 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + ContentStatus, + MediaEntityType, + Prisma, + WorkshopRegistrationStatus, +} from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreateWorkshopDto, + AddWorkshopRegistrationDto, + UpdateWorkshopRegistrationDto, + ListPublicWorkshopsDto, + ListWorkshopsDto, + UpdateWorkshopDto, +} from './dto/workshop.dto'; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'workshop' + ); +} + +type WorkshopWithRelations = Prisma.workshopsGetPayload<{ + include: { + media: true; + author: { + select: { id: true; firstName: true; lastName: true; email: true }; + }; + }; +}>; + +const workshopInclude = { + media: true, + author: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, +} satisfies Prisma.workshopsInclude; + +@Injectable() +export class WorkshopsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + ) {} + + async list(businessIdRaw: string, query: ListWorkshopsDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'workshops.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.workshops.findMany({ + where, + orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }], + skip, + take: pageSize, + include: workshopInclude, + }), + this.prisma.workshops.count({ where }), + ]); + + const serialized = await Promise.all( + items.map((item) => this.serializeWorkshop(item)), + ); + + return { items: serialized, total, page, pageSize }; + } + + async getOne(businessIdRaw: string, workshopIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const workshopId = BigInt(workshopIdRaw); + await this.assertPermission(businessId, actor.id, 'workshops.read'); + + const workshop = await this.findWorkshopOrThrow(businessId, workshopId); + + return { + workshop: await this.serializeWorkshop(workshop), + }; + } + + async create(businessIdRaw: string, dto: CreateWorkshopDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'workshops.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.ensureUniqueSlug( + businessId, + dto.slug ?? slugify(titleEn || titleFa), + ); + + const status = dto.status ?? ContentStatus.draft; + const featuredMediaId = dto.featuredMediaId + ? BigInt(dto.featuredMediaId) + : null; + + if (featuredMediaId) { + await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); + } + + const galleryMediaIds = await this.resolveGalleryMediaIds( + businessId, + dto.galleryMediaIds ?? [], + ); + + 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 created = await this.prisma.$transaction(async (tx) => { + const item = await tx.workshops.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, + status, + featured_media_id: featuredMediaId, + published_at: status === ContentStatus.published ? new Date() : null, + metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue, + event_date: this.parseEventDate(dto.eventDate) ?? null, + event_time: dto.eventTime?.trim() || null, + duration: dto.duration?.trim() || null, + }, + include: workshopInclude, + }); + + if (dto.categoryId) { + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId: BigInt(dto.categoryId), + entityType: MediaEntityType.workshop, + entityId: item.id, + }, + }); + } + + await this.syncGalleryAttachments( + tx, + businessId, + item.id, + galleryMediaIds, + ); + + return item; + }); + + return { + message: 'Workshop created successfully', + workshop: await this.serializeWorkshop(created), + }; + } + + async update( + businessIdRaw: string, + workshopIdRaw: string, + dto: UpdateWorkshopDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const workshopId = BigInt(workshopIdRaw); + await this.assertPermission(businessId, actor.id, 'workshops.update'); + + const existing = await this.prisma.workshops.findFirst({ + where: { id: workshopId, business_id: businessId }, + }); + + if (!existing) { + throw new NotFoundException('Workshop 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.ensureUniqueSlug(businessId, dto.slug, workshopId); + } 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.ensureUniqueSlug(businessId, slugify(slugBase), workshopId); + } + + 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 = { ...existingMetadata }; + if (dto.tags !== undefined) { + nextMetadata.tags = dto.tags; + } + + 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 workshop = await tx.workshops.update({ + where: { id: workshopId }, + 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 } + : {}), + ...(dto.status !== undefined ? { status: dto.status } : {}), + ...(featuredMediaId !== undefined + ? { featured_media_id: featuredMediaId } + : {}), + ...(authorId !== undefined ? { author_id: authorId } : {}), + ...(publishedAt !== undefined ? { published_at: publishedAt } : {}), + ...(dto.eventDate !== undefined + ? { event_date: this.parseEventDate(dto.eventDate) } + : {}), + ...(dto.eventTime !== undefined + ? { event_time: dto.eventTime?.trim() || null } + : {}), + ...(dto.duration !== undefined + ? { duration: dto.duration?.trim() || null } + : {}), + slug, + content: nextContent as Prisma.InputJsonValue, + metadata: nextMetadata as Prisma.InputJsonValue, + }, + include: workshopInclude, + }); + + if (dto.categoryId !== undefined) { + await tx.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.workshop, + entityId: workshopId, + }, + }); + + if (dto.categoryId) { + const categoryId = BigInt(dto.categoryId); + await this.assertCategoryBelongsToBusiness(businessId, categoryId); + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId, + entityType: MediaEntityType.workshop, + entityId: workshopId, + }, + }); + } + } + + if (dto.galleryMediaIds !== undefined) { + const galleryMediaIds = await this.resolveGalleryMediaIds( + businessId, + dto.galleryMediaIds, + ); + await this.syncGalleryAttachments( + tx, + businessId, + workshopId, + galleryMediaIds, + ); + } + + return workshop; + }); + + return { + message: 'Workshop updated successfully', + workshop: await this.serializeWorkshop(updated), + }; + } + + async remove(businessIdRaw: string, workshopIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const workshopId = BigInt(workshopIdRaw); + await this.assertPermission(businessId, actor.id, 'workshops.delete'); + + const existing = await this.prisma.workshops.findFirst({ + where: { id: workshopId, business_id: businessId }, + }); + + if (!existing) { + throw new NotFoundException('Workshop not found'); + } + + await this.prisma.$transaction([ + this.prisma.workshop_registrations.deleteMany({ + where: { workshop_id: workshopId, business_id: businessId }, + }), + this.prisma.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.workshop, + entityId: workshopId, + }, + }), + this.prisma.mediaAttachment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.workshop, + entityId: workshopId, + }, + }), + this.prisma.workshops.delete({ where: { id: workshopId } }), + ]); + + return { message: 'Workshop deleted successfully' }; + } + + async listRegistrations( + businessIdRaw: string, + workshopIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const workshopId = BigInt(workshopIdRaw); + await this.assertPermission(businessId, actor.id, 'workshops.read'); + + await this.findWorkshopOrThrow(businessId, workshopId); + + const items = await this.prisma.workshop_registrations.findMany({ + where: { business_id: businessId, workshop_id: workshopId }, + orderBy: { created_at: 'desc' }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + firstNameEn: true, + lastNameEn: true, + email: true, + cellNumber: true, + }, + }, + }, + }); + + return { + items: items.map((item) => this.serializeRegistration(item)), + }; + } + + async addRegistration( + businessIdRaw: string, + workshopIdRaw: string, + dto: AddWorkshopRegistrationDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const workshopId = BigInt(workshopIdRaw); + const userId = BigInt(dto.userId); + + await this.assertPermission(businessId, actor.id, 'workshops.update'); + await this.findWorkshopOrThrow(businessId, workshopId); + await this.assertBusinessCustomer(businessId, userId); + + const registration = await this.createRegistration( + businessId, + workshopId, + userId, + ); + + return { + message: 'User registered for workshop successfully', + registration, + }; + } + + async updateRegistration( + businessIdRaw: string, + workshopIdRaw: string, + registrationIdRaw: string, + dto: UpdateWorkshopRegistrationDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const workshopId = BigInt(workshopIdRaw); + const registrationId = BigInt(registrationIdRaw); + + await this.assertPermission(businessId, actor.id, 'workshops.update'); + await this.findWorkshopOrThrow(businessId, workshopId); + + const existing = await this.prisma.workshop_registrations.findFirst({ + where: { + id: registrationId, + business_id: businessId, + workshop_id: workshopId, + }, + }); + + if (!existing) { + throw new NotFoundException('Registration not found'); + } + + const updated = await this.prisma.workshop_registrations.update({ + where: { id: registrationId }, + data: { status: dto.status as WorkshopRegistrationStatus }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + firstNameEn: true, + lastNameEn: true, + email: true, + cellNumber: true, + }, + }, + }, + }); + + return { + message: 'Registration updated successfully', + registration: this.serializeRegistration(updated), + }; + } + + async removeRegistration( + businessIdRaw: string, + workshopIdRaw: string, + registrationIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const workshopId = BigInt(workshopIdRaw); + const registrationId = BigInt(registrationIdRaw); + + await this.assertPermission(businessId, actor.id, 'workshops.update'); + await this.findWorkshopOrThrow(businessId, workshopId); + + const existing = await this.prisma.workshop_registrations.findFirst({ + where: { + id: registrationId, + business_id: businessId, + workshop_id: workshopId, + }, + }); + + if (!existing) { + throw new NotFoundException('Registration not found'); + } + + await this.prisma.workshop_registrations.delete({ + where: { id: registrationId }, + }); + + return { message: 'Registration removed successfully' }; + } + + async listPublic(host: string, query: ListPublicWorkshopsDto) { + 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.workshops.findMany({ + where, + orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }], + skip, + take: pageSize, + include: workshopInclude, + }), + this.prisma.workshops.count({ where }), + ]); + + const serialized = await Promise.all( + items.map((item) => this.serializeWorkshop(item)), + ); + + return { items: serialized, total, page, pageSize }; + } + + async getPublicBySlug(host: string, slug: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const workshop = await this.prisma.workshops.findFirst({ + where: { + business_id: businessId, + slug, + status: ContentStatus.published, + }, + include: workshopInclude, + }); + + if (!workshop) { + throw new NotFoundException('Workshop not found'); + } + + return { + workshop: await this.serializeWorkshop(workshop), + }; + } + + async registerPublic(host: string, workshopIdRaw: string, actor: AuthUser) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const workshopId = BigInt(workshopIdRaw); + + await this.assertPublishedWorkshopExists(businessId, workshopId); + await this.assertCustomerAccess(businessId, actor); + + const registration = await this.createRegistration( + businessId, + workshopId, + actor.id, + ); + + return { + message: 'Registered for workshop successfully', + registration, + }; + } + + async unregisterPublic(host: string, workshopIdRaw: string, actor: AuthUser) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const workshopId = BigInt(workshopIdRaw); + + await this.assertCustomerAccess(businessId, actor); + + const existing = await this.prisma.workshop_registrations.findUnique({ + where: { + workshop_id_user_id: { workshop_id: workshopId, user_id: actor.id }, + }, + }); + + if (!existing) { + throw new NotFoundException('Registration not found'); + } + + await this.prisma.workshop_registrations.delete({ + where: { id: existing.id }, + }); + + return { message: 'Registration removed successfully' }; + } + + private resolveTitleFa(titleFa?: string, title?: string): string { + return (titleFa ?? title ?? '').trim(); + } + + private async buildWhere( + businessId: bigint, + query: (ListWorkshopsDto | ListPublicWorkshopsDto) & { + status?: ContentStatus; + }, + ): Promise { + let entityIds: bigint[] | undefined; + + if (query.categoryId) { + const assignments = await this.prisma.categoryAssignment.findMany({ + where: { + businessId, + categoryId: BigInt(query.categoryId), + entityType: MediaEntityType.workshop, + }, + 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 } : {}), + ...(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 findWorkshopOrThrow(businessId: bigint, workshopId: bigint) { + const workshop = await this.prisma.workshops.findFirst({ + where: { id: workshopId, business_id: businessId }, + include: workshopInclude, + }); + + if (!workshop) { + throw new NotFoundException('Workshop not found'); + } + + return workshop; + } + + private async assertPublishedWorkshopExists( + businessId: bigint, + workshopId: bigint, + ) { + const workshop = await this.prisma.workshops.findFirst({ + where: { + id: workshopId, + business_id: businessId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!workshop) { + throw new NotFoundException('Workshop not found'); + } + } + + private async serializeWorkshop(workshop: WorkshopWithRelations) { + const content = this.asRecord(workshop.content); + const metadata = this.asRecord(workshop.metadata); + + const [categoryAssignment, galleryAttachments, registrationCount] = + await Promise.all([ + this.prisma.categoryAssignment.findFirst({ + where: { + businessId: workshop.business_id, + entityType: MediaEntityType.workshop, + entityId: workshop.id, + }, + include: { category: true }, + }), + this.prisma.mediaAttachment.findMany({ + where: { + businessId: workshop.business_id, + entityType: MediaEntityType.workshop, + entityId: workshop.id, + isFeatured: false, + }, + orderBy: { sortOrder: 'asc' }, + include: { media: true }, + }), + this.prisma.workshop_registrations.count({ + where: { + business_id: workshop.business_id, + workshop_id: workshop.id, + }, + }), + ]); + + return { + id: workshop.id.toString(), + businessId: workshop.business_id.toString(), + title: workshop.title, + titleFa: workshop.title_fa ?? workshop.title, + titleEn: workshop.title_en, + slug: workshop.slug, + abstract: workshop.excerpt ?? '', + descriptionHtml: (content.html as string | undefined) ?? '', + status: workshop.status, + categoryId: categoryAssignment?.categoryId.toString() ?? null, + categoryName: categoryAssignment?.category.name ?? '', + tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [], + authorId: workshop.author_id?.toString() ?? null, + author: workshop.author + ? { + id: workshop.author.id.toString(), + firstName: workshop.author.firstName, + lastName: workshop.author.lastName, + email: workshop.author.email, + } + : null, + coverImageUrl: workshop.media?.publicUrl ?? null, + featuredMediaId: workshop.featured_media_id?.toString() ?? null, + gallery: galleryAttachments.map((item) => ({ + mediaId: item.mediaId.toString(), + url: item.media.publicUrl, + })), + galleryMediaIds: galleryAttachments.map((item) => + item.mediaId.toString(), + ), + registrationCount, + eventDate: this.formatEventDate(workshop.event_date), + eventTime: workshop.event_time ?? null, + duration: workshop.duration ?? null, + publishedAt: workshop.published_at, + createdAt: workshop.created_at, + updatedAt: workshop.updated_at, + }; + } + + private buildContent(descriptionHtml?: string) { + return { + html: descriptionHtml ?? '', + }; + } + + private buildMetadata(tags?: string[]) { + return { + tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [], + }; + } + + private parseEventDate(value?: string | null): Date | null { + if (!value) return null; + const parsed = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequestException('Invalid eventDate'); + } + return parsed; + } + + private formatEventDate(value: Date | null | undefined): string | null { + if (!value) return null; + return value.toISOString().slice(0, 10); + } + + private serializeRegistration( + item: Prisma.workshop_registrationsGetPayload<{ + include: { + user: { + select: { + id: true; + firstName: true; + lastName: true; + firstNameEn: true; + lastNameEn: true; + email: true; + cellNumber: true; + }; + }; + }; + }>, + ) { + return { + id: item.id.toString(), + workshopId: item.workshop_id.toString(), + userId: item.user_id.toString(), + status: item.status, + createdAt: item.created_at, + user: { + id: item.user.id.toString(), + firstName: item.user.firstName, + lastName: item.user.lastName, + firstNameEn: item.user.firstNameEn, + lastNameEn: item.user.lastNameEn, + email: item.user.email, + cellNumber: item.user.cellNumber, + }, + }; + } + + private asRecord(value: Prisma.JsonValue): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } + + private async syncGalleryAttachments( + tx: Prisma.TransactionClient, + businessId: bigint, + workshopId: bigint, + mediaIds: bigint[], + ) { + await tx.mediaAttachment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.workshop, + entityId: workshopId, + isFeatured: false, + }, + }); + + for (const [index, mediaId] of mediaIds.entries()) { + await tx.mediaAttachment.create({ + data: { + businessId, + mediaId, + entityType: MediaEntityType.workshop, + entityId: workshopId, + sortOrder: index, + isFeatured: false, + }, + }); + } + } + + private async resolveGalleryMediaIds(businessId: bigint, rawIds: string[]) { + const ids = rawIds.map((id) => BigInt(id)); + for (const mediaId of ids) { + await this.assertMediaBelongsToBusiness(businessId, mediaId); + } + return ids; + } + + 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.workshop, + isActive: true, + }, + }); + if (!category) { + throw new BadRequestException('Workshop category not found for this business'); + } + } + + private async createRegistration( + businessId: bigint, + workshopId: bigint, + userId: bigint, + ) { + const existing = await this.prisma.workshop_registrations.findUnique({ + where: { + workshop_id_user_id: { workshop_id: workshopId, user_id: userId }, + }, + }); + + if (existing) { + throw new ConflictException('Already registered for this workshop'); + } + + const created = await this.prisma.workshop_registrations.create({ + data: { + business_id: businessId, + workshop_id: workshopId, + user_id: userId, + status: WorkshopRegistrationStatus.pending, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + firstNameEn: true, + lastNameEn: true, + email: true, + cellNumber: true, + }, + }, + }, + }); + + return this.serializeRegistration(created); + } + + private async assertBusinessCustomer(businessId: bigint, userId: bigint) { + const membership = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId }, + }, + }); + + if (!membership?.isEnabled) { + throw new BadRequestException( + 'User must be an active customer of 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 assertCustomerAccess(businessId: bigint, actor: AuthUser) { + if (await this.permissions.isSuperAdmin(actor.id)) { + return; + } + + const membership = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId: actor.id }, + }, + }); + + if (!membership?.isEnabled) { + throw new ForbiddenException('You are not a customer of this business'); + } + } + + private async ensureUniqueSlug( + businessId: bigint, + baseSlug: string, + excludeId?: bigint, + ) { + let slug = baseSlug; + let suffix = 1; + + while (true) { + const existing = await this.prisma.workshops.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`, + ); + } + } +}