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`, + ); + } + } +}