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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-22 12:22:24 +03:30
co-authored by Cursor
parent 9d56ec7396
commit c7d5204b49
19 changed files with 1919 additions and 19 deletions
+88
View File
@@ -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;
@@ -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);
@@ -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;
+56
View File
@@ -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": [
+63
View File
@@ -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")
}
+2
View File
@@ -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,
+11 -3
View File
@@ -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 },
});
@@ -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],
},
}),
},
});
@@ -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',
+24 -1
View File
@@ -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,
+7 -2
View File
@@ -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 },
});
+12
View File
@@ -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;
+77 -7
View File
@@ -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,
+7 -2
View File
@@ -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 },
});
+56
View File
@@ -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": [
+219
View File
@@ -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;
}
+172
View File
@@ -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);
}
}
+15
View File
@@ -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 {}
File diff suppressed because it is too large Load Diff