From b6e2ef6c237acd96db05040ea62481b72e7449f1 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Sun, 26 Apr 2026 23:59:17 +0400 Subject: [PATCH 01/25] feat(moderation): add admin moderation APIs with checklist workflow --- .../migration.sql | 28 ++ prisma/schema.prisma | 31 ++ src/controllers/moderation.controller.ts | 273 ++++++++++++ src/controllers/service.controller.ts | 96 +--- src/routes/v1/index.ts | 2 + src/routes/v1/moderation.route.ts | 273 ++++++++++++ src/routes/v1/service.route.ts | 8 - src/schemas/moderation.schema.ts | 34 ++ src/services/moderation.service.ts | 409 ++++++++++++++++++ 9 files changed, 1051 insertions(+), 103 deletions(-) create mode 100644 prisma/migrations/20260426195531_add_moderation_review/migration.sql create mode 100644 src/controllers/moderation.controller.ts create mode 100644 src/routes/v1/moderation.route.ts create mode 100644 src/schemas/moderation.schema.ts create mode 100644 src/services/moderation.service.ts diff --git a/prisma/migrations/20260426195531_add_moderation_review/migration.sql b/prisma/migrations/20260426195531_add_moderation_review/migration.sql new file mode 100644 index 0000000..730dbb9 --- /dev/null +++ b/prisma/migrations/20260426195531_add_moderation_review/migration.sql @@ -0,0 +1,28 @@ +-- CreateEnum +CREATE TYPE "ModerationEntityType" AS ENUM ('brand', 'service'); + +-- CreateEnum +CREATE TYPE "ModerationOutcome" AS ENUM ('APPROVED', 'REJECTED'); + +-- CreateTable +CREATE TABLE "ModerationReview" ( + "id" TEXT NOT NULL, + "entity_type" "ModerationEntityType" NOT NULL, + "entity_id" TEXT NOT NULL, + "reviewer_id" TEXT NOT NULL, + "outcome" "ModerationOutcome" NOT NULL, + "rejection_reason" TEXT, + "checklist" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ModerationReview_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ModerationReview_entity_type_entity_id_idx" ON "ModerationReview"("entity_type", "entity_id"); + +-- CreateIndex +CREATE INDEX "ModerationReview_reviewer_id_idx" ON "ModerationReview"("reviewer_id"); + +-- AddForeignKey +ALTER TABLE "ModerationReview" ADD CONSTRAINT "ModerationReview_reviewer_id_fkey" FOREIGN KEY ("reviewer_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 320c311..881faf1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,18 @@ enum PriceType { FREE } +// ─── Moderation enums ───────────────────────────────────────────────────────── + +enum ModerationEntityType { + brand + service +} + +enum ModerationOutcome { + APPROVED + REJECTED +} + model User { id String @id @default(cuid()) first_name String @@ -83,6 +95,7 @@ model User { invitations_sent TeamMember[] @relation("TeamMemberInviter") feed_dismissals NotificationFeedDismissal[] feed_state NotificationFeedState? + moderation_reviews ModerationReview[] @relation("ModerationReviewer") avatar_media Media? @relation("UserAvatar", fields: [avatar_media_id], references: [id], onDelete: SetNull) @@ -385,3 +398,21 @@ model ServiceMedia { @@index([service_id]) } + +// ─── Moderation Review ─────────────────────────────────────────────────────── + +model ModerationReview { + id String @id @default(cuid()) + entity_type ModerationEntityType + entity_id String + reviewer_id String + outcome ModerationOutcome + rejection_reason String? + checklist Json? // Array of {key: string, label: string, passed: boolean} + created_at DateTime @default(now()) + + reviewer User @relation("ModerationReviewer", fields: [reviewer_id], references: [id], onDelete: Cascade) + + @@index([entity_type, entity_id]) + @@index([reviewer_id]) +} diff --git a/src/controllers/moderation.controller.ts b/src/controllers/moderation.controller.ts new file mode 100644 index 0000000..137936c --- /dev/null +++ b/src/controllers/moderation.controller.ts @@ -0,0 +1,273 @@ +import { Request, Response, NextFunction } from 'express'; +import { sendSuccess } from '../utils/response'; +import { AppError } from '../middlewares/error.middleware'; +import type { ApproveInput, RejectInput } from '../schemas/moderation.schema'; +import { listQueueSchema } from '../schemas/moderation.schema'; +import * as moderationService from '../services/moderation.service'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function requireAdmin(req: Request, next: NextFunction): boolean { + if (req.user.type !== 'admin') { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'errors.forbidden'; + next(err); + return false; + } + return true; +} + +// ─── Queue ──────────────────────────────────────────────────────────────────── + +/** + * GET /admin/queue + * Returns pending brands and/or services awaiting moderation. + * Optional ?type=brand|service to filter by entity type. + */ +export const listQueue = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireAdmin(req, next)) return; + + const parsed = listQueueSchema.safeParse(req.query); + if (!parsed.success) { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'errors.validation_error'; + return next(err); + } + const type = parsed.data.type; + + const queue = await moderationService.getModerationQueue(type); + + sendSuccess({ res, status: 200, message: 'moderation.queue', data: queue }); + } catch (err) { + next(err); + } +}; + +// ─── Brand detail ───────────────────────────────────────────────────────────── + +/** + * GET /admin/brands/:id + * Full brand detail for admin review. + */ +export const getBrandDetail = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireAdmin(req, next)) return; + + const id = req.params['id'] as string; + const brand = await moderationService.getBrandModerationDetail(id); + + if (!brand) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'brand.not_found'; + return next(err); + } + + sendSuccess({ res, status: 200, message: 'brand.found', data: { brand } }); + } catch (err) { + next(err); + } +}; + +// ─── Service detail ─────────────────────────────────────────────────────────── + +/** + * GET /admin/services/:id + * Full service detail for admin review. + */ +export const getServiceDetail = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireAdmin(req, next)) return; + + const id = req.params['id'] as string; + const service = await moderationService.getServiceModerationDetail(id); + + if (!service) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); + } + + sendSuccess({ res, status: 200, message: 'service.found', data: { service } }); + } catch (err) { + next(err); + } +}; + +// ─── Brand approve / reject ─────────────────────────────────────────────────── + +/** + * POST /admin/brands/:id/approve + * Approve a pending brand. Body: { checklist? } + */ +export const approveBrand = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireAdmin(req, next)) return; + + const id = req.params['id'] as string; + const body = req.body as ApproveInput; + + const result = await moderationService.approveBrand(id, req.user.sub, body.checklist); + + if ('notFound' in result) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'brand.not_found'; + return next(err); + } + + if ('wrongStatus' in result) { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'brand.cannot_approve_in_current_status'; + return next(err); + } + + sendSuccess({ res, status: 200, message: 'brand.approved' }); + } catch (err) { + next(err); + } +}; + +/** + * POST /admin/brands/:id/reject + * Reject a pending brand. Body: { rejection_reason, checklist? } + */ +export const rejectBrand = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireAdmin(req, next)) return; + + const id = req.params['id'] as string; + const body = req.body as RejectInput; + + const result = await moderationService.rejectBrand( + id, + req.user.sub, + body.rejection_reason, + body.checklist, + ); + + if ('notFound' in result) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'brand.not_found'; + return next(err); + } + + if ('wrongStatus' in result) { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'brand.cannot_reject_in_current_status'; + return next(err); + } + + sendSuccess({ res, status: 200, message: 'brand.rejected' }); + } catch (err) { + next(err); + } +}; + +// ─── Service approve / reject ───────────────────────────────────────────────── + +/** + * POST /admin/services/:id/approve + * Approve a pending service. Body: { checklist? } + */ +export const approveService = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireAdmin(req, next)) return; + + const id = req.params['id'] as string; + const body = req.body as ApproveInput; + + const result = await moderationService.approveService(id, req.user.sub, body.checklist); + + if ('notFound' in result) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); + } + + if ('wrongStatus' in result) { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'service.cannot_approve_in_current_status'; + return next(err); + } + + sendSuccess({ res, status: 200, message: 'service.approved' }); + } catch (err) { + next(err); + } +}; + +/** + * POST /admin/services/:id/reject + * Reject a pending service. Body: { rejection_reason, checklist? } + */ +export const rejectService = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireAdmin(req, next)) return; + + const id = req.params['id'] as string; + const body = req.body as RejectInput; + + const result = await moderationService.rejectService( + id, + req.user.sub, + body.rejection_reason, + body.checklist, + ); + + if ('notFound' in result) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); + } + + if ('wrongStatus' in result) { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'service.cannot_reject_in_current_status'; + return next(err); + } + + sendSuccess({ res, status: 200, message: 'service.rejected' }); + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index e19d166..aa6a18e 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -5,7 +5,7 @@ import { AppError } from '../middlewares/error.middleware'; import { buildFileUrl } from '../services/storage.service'; import { validateAndProcessImage, writeFileToDisk } from '../services/media.service'; import { buildStoragePath, ensureUserStorageDir } from '../services/storage.service'; -import type { CreateServiceInput, UpdateServiceInput, RejectServiceInput } from '../schemas/service.schema'; +import type { CreateServiceInput, UpdateServiceInput } from '../schemas/service.schema'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -615,97 +615,3 @@ export const archiveService = async ( } }; -// ─── Moderation (admin) ─────────────────────────────────────────────────────── - -export const approveService = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - if (req.user.type !== 'admin') { - const err: AppError = new Error(); - err.statusCode = 403; - err.messageKey = 'errors.forbidden'; - return next(err); - } - - const id = req.params['id'] as string; - - const existing = await prisma.service.findUnique({ - where: { id }, - select: { status: true }, - }); - - if (!existing) { - const err: AppError = new Error(); - err.statusCode = 404; - err.messageKey = 'service.not_found'; - return next(err); - } - - if (existing.status !== 'PENDING') { - const err: AppError = new Error(); - err.statusCode = 400; - err.messageKey = 'service.cannot_approve_in_current_status'; - return next(err); - } - - const service = await prisma.service.update({ - where: { id }, - data: { status: 'ACTIVE', rejection_reason: null }, - select: serviceSelect, - }); - - sendSuccess({ res, status: 200, message: 'service.approved', data: { service: mapService(service) } }); - } catch (err) { - next(err); - } -}; - -export const rejectService = async ( - req: Request, - res: Response, - next: NextFunction, -): Promise => { - try { - if (req.user.type !== 'admin') { - const err: AppError = new Error(); - err.statusCode = 403; - err.messageKey = 'errors.forbidden'; - return next(err); - } - - const id = req.params['id'] as string; - const body = req.body as RejectServiceInput; - - const existing = await prisma.service.findUnique({ - where: { id }, - select: { status: true }, - }); - - if (!existing) { - const err: AppError = new Error(); - err.statusCode = 404; - err.messageKey = 'service.not_found'; - return next(err); - } - - if (existing.status !== 'PENDING') { - const err: AppError = new Error(); - err.statusCode = 400; - err.messageKey = 'service.cannot_reject_in_current_status'; - return next(err); - } - - const service = await prisma.service.update({ - where: { id }, - data: { status: 'REJECTED', rejection_reason: body.rejection_reason }, - select: serviceSelect, - }); - - sendSuccess({ res, status: 200, message: 'service.rejected', data: { service: mapService(service) } }); - } catch (err) { - next(err); - } -}; diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index 4402823..9d618fa 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -7,6 +7,7 @@ import brandRoute from './brand.route'; import notificationRoute from './notification.route'; import teamRoute from './team.route'; import serviceRoute from './service.route'; +import moderationRoute from './moderation.route'; const router: Router = Router(); @@ -18,5 +19,6 @@ router.use('/', brandRoute); router.use('/notifications', notificationRoute); router.use('/', teamRoute); router.use('/', serviceRoute); +router.use('/', moderationRoute); export default router; diff --git a/src/routes/v1/moderation.route.ts b/src/routes/v1/moderation.route.ts new file mode 100644 index 0000000..57831e4 --- /dev/null +++ b/src/routes/v1/moderation.route.ts @@ -0,0 +1,273 @@ +import { Router } from 'express'; +import { + listQueue, + getBrandDetail, + approveBrand, + rejectBrand, + getServiceDetail, + approveService, + rejectService, +} from '../../controllers/moderation.controller'; +import { authenticate } from '../../middlewares/auth.middleware'; +import { validate } from '../../middlewares/validate.middleware'; +import { + approveSchema, + rejectSchema, +} from '../../schemas/moderation.schema'; + +const router: Router = Router(); + +/** + * @openapi + * /api/v1/admin/queue: + * get: + * tags: + * - Admin Moderation + * summary: Get the moderation queue + * description: Returns pending brands and/or services awaiting admin review. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: type + * schema: + * type: string + * enum: [brand, service] + * description: Filter by entity type. Omit for both. + * responses: + * 200: + * description: Moderation queue returned successfully. + * 403: + * description: Forbidden — admin access required. + */ +router.get('/admin/queue', authenticate, listQueue); + +/** + * @openapi + * /api/v1/admin/brands/{id}: + * get: + * tags: + * - Admin Moderation + * summary: Get brand detail for moderation + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Brand detail returned. + * 403: + * description: Forbidden. + * 404: + * description: Brand not found. + */ +router.get('/admin/brands/:id', authenticate, getBrandDetail); + +/** + * @openapi + * /api/v1/admin/brands/{id}/approve: + * post: + * tags: + * - Admin Moderation + * summary: Approve a pending brand + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * checklist: + * type: array + * items: + * type: object + * properties: + * key: { type: string } + * label: { type: string } + * passed: { type: boolean } + * responses: + * 200: + * description: Brand approved. + * 400: + * description: Brand is not in PENDING status. + * 403: + * description: Forbidden. + * 404: + * description: Brand not found. + */ +router.post('/admin/brands/:id/approve', authenticate, validate(approveSchema), approveBrand); + +/** + * @openapi + * /api/v1/admin/brands/{id}/reject: + * post: + * tags: + * - Admin Moderation + * summary: Reject a pending brand + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - rejection_reason + * properties: + * rejection_reason: + * type: string + * minLength: 10 + * maxLength: 1000 + * checklist: + * type: array + * items: + * type: object + * properties: + * key: { type: string } + * label: { type: string } + * passed: { type: boolean } + * responses: + * 200: + * description: Brand rejected. + * 400: + * description: Brand is not in PENDING status or validation failed. + * 403: + * description: Forbidden. + * 404: + * description: Brand not found. + */ +router.post('/admin/brands/:id/reject', authenticate, validate(rejectSchema), rejectBrand); + +/** + * @openapi + * /api/v1/admin/services/{id}: + * get: + * tags: + * - Admin Moderation + * summary: Get service detail for moderation + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Service detail returned. + * 403: + * description: Forbidden. + * 404: + * description: Service not found. + */ +router.get('/admin/services/:id', authenticate, getServiceDetail); + +/** + * @openapi + * /api/v1/admin/services/{id}/approve: + * post: + * tags: + * - Admin Moderation + * summary: Approve a pending service + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * checklist: + * type: array + * items: + * type: object + * properties: + * key: { type: string } + * label: { type: string } + * passed: { type: boolean } + * responses: + * 200: + * description: Service approved. + * 400: + * description: Service is not in PENDING status. + * 403: + * description: Forbidden. + * 404: + * description: Service not found. + */ +router.post('/admin/services/:id/approve', authenticate, validate(approveSchema), approveService); + +/** + * @openapi + * /api/v1/admin/services/{id}/reject: + * post: + * tags: + * - Admin Moderation + * summary: Reject a pending service + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - rejection_reason + * properties: + * rejection_reason: + * type: string + * minLength: 10 + * maxLength: 1000 + * checklist: + * type: array + * items: + * type: object + * properties: + * key: { type: string } + * label: { type: string } + * passed: { type: boolean } + * responses: + * 200: + * description: Service rejected. + * 400: + * description: Service is not in PENDING status or validation failed. + * 403: + * description: Forbidden. + * 404: + * description: Service not found. + */ +router.post('/admin/services/:id/reject', authenticate, validate(rejectSchema), rejectService); + +export default router; diff --git a/src/routes/v1/service.route.ts b/src/routes/v1/service.route.ts index a9b7e08..821acec 100644 --- a/src/routes/v1/service.route.ts +++ b/src/routes/v1/service.route.ts @@ -12,8 +12,6 @@ import { pauseService, resumeService, archiveService, - approveService, - rejectService, } from '../../controllers/service.controller'; import { authenticate } from '../../middlewares/auth.middleware'; import { validate } from '../../middlewares/validate.middleware'; @@ -21,7 +19,6 @@ import { AppError } from '../../middlewares/error.middleware'; import { createServiceSchema, updateServiceSchema, - rejectServiceSchema, } from '../../schemas/service.schema'; const upload = multer({ @@ -65,9 +62,4 @@ router.post('/services/:id/pause', authenticate, pauseService); router.post('/services/:id/resume', authenticate, resumeService); router.post('/services/:id/archive', authenticate, archiveService); -// ─── Moderation (admin) ──────────────────────────────────────────────────────── - -router.post('/services/:id/approve', authenticate, approveService); -router.post('/services/:id/reject', authenticate, validate(rejectServiceSchema), rejectService); - export default router; diff --git a/src/schemas/moderation.schema.ts b/src/schemas/moderation.schema.ts new file mode 100644 index 0000000..a44dfb9 --- /dev/null +++ b/src/schemas/moderation.schema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +// ─── Shared ─────────────────────────────────────────────────────────────────── + +const checklistItemSchema = z.object({ + key: z.string(), + label: z.string(), + passed: z.boolean(), +}); + +// ─── Approve ────────────────────────────────────────────────────────────────── + +export const approveSchema = z.object({ + checklist: z.array(checklistItemSchema).optional(), +}); + +export type ApproveInput = z.infer; + +// ─── Reject ─────────────────────────────────────────────────────────────────── + +export const rejectSchema = z.object({ + rejection_reason: z.string().min(10).max(1000), + checklist: z.array(checklistItemSchema).optional(), +}); + +export type RejectInput = z.infer; + +// ─── Queue query ────────────────────────────────────────────────────────────── + +export const listQueueSchema = z.object({ + type: z.enum(['brand', 'service']).optional(), +}); + +export type ListQueueInput = z.infer; diff --git a/src/services/moderation.service.ts b/src/services/moderation.service.ts new file mode 100644 index 0000000..2dae174 --- /dev/null +++ b/src/services/moderation.service.ts @@ -0,0 +1,409 @@ +import prisma from '../lib/prisma'; +import { Prisma } from '../generated/prisma/client'; +import { buildFileUrl } from './storage.service'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ChecklistItem { + key: string; + label: string; + passed: boolean; +} + +// ─── Queue ──────────────────────────────────────────────────────────────────── + +export async function getModerationQueue(type?: 'brand' | 'service') { + const [brands, services] = await Promise.all([ + type === 'service' + ? [] + : prisma.brand.findMany({ + where: { status: 'PENDING' }, + select: { + id: true, + name: true, + description: true, + status: true, + created_at: true, + updated_at: true, + owner: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + logo_media: { select: { id: true, storage_path: true } }, + }, + orderBy: { created_at: 'asc' }, + }), + type === 'brand' + ? [] + : prisma.service.findMany({ + where: { status: 'PENDING' }, + select: { + id: true, + title: true, + description: true, + status: true, + category: true, + price: true, + price_type: true, + created_at: true, + updated_at: true, + owner: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + }, + }, + }, + orderBy: { created_at: 'asc' }, + }), + ]); + + return { + brands: brands.map((b) => ({ + id: b.id, + name: b.name, + description: b.description ?? undefined, + status: b.status, + logo_url: b.logo_media ? buildFileUrl(b.logo_media.storage_path) : null, + owner: b.owner, + created_at: b.created_at.toISOString(), + updated_at: b.updated_at.toISOString(), + })), + services: services.map((s) => ({ + id: s.id, + title: s.title, + description: s.description ?? undefined, + status: s.status, + category: s.category ?? undefined, + price: s.price ? Number(s.price) : null, + price_type: s.price_type, + owner: s.owner, + created_at: s.created_at.toISOString(), + updated_at: s.updated_at.toISOString(), + })), + }; +} + +// ─── Brand detail ───────────────────────────────────────────────────────────── + +export async function getBrandModerationDetail(brandId: string) { + const brand = await prisma.brand.findUnique({ + where: { id: brandId }, + include: { + owner: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + type: true, + created_at: true, + }, + }, + logo_media: { select: { id: true, storage_path: true } }, + gallery: { + select: { + id: true, + media_id: true, + order: true, + media: { select: { id: true, storage_path: true } }, + }, + orderBy: { order: 'asc' }, + }, + branches: { + include: { + cover_media: { select: { id: true, storage_path: true } }, + breaks: { select: { id: true, start: true, end: true } }, + }, + orderBy: { created_at: 'asc' }, + }, + categories: { select: { id: true, name: true } }, + }, + }); + + if (!brand) return null; + + return { + id: brand.id, + name: brand.name, + description: brand.description ?? undefined, + status: brand.status, + rejection_reason: (brand as { rejection_reason?: string | null }).rejection_reason ?? undefined, + owner: brand.owner, + logo_url: brand.logo_media ? buildFileUrl(brand.logo_media.storage_path) : null, + gallery: brand.gallery.map((g) => ({ + id: g.id, + media_id: g.media_id, + order: g.order, + url: buildFileUrl(g.media.storage_path), + })), + branches: brand.branches.map((br) => ({ + id: br.id, + name: br.name, + description: br.description ?? undefined, + address1: br.address1, + address2: br.address2 ?? undefined, + phone: br.phone ?? undefined, + email: br.email ?? undefined, + is_24_7: br.is_24_7, + opening: br.opening ?? undefined, + closing: br.closing ?? undefined, + cover_url: br.cover_media ? buildFileUrl(br.cover_media.storage_path) : null, + breaks: br.breaks, + created_at: br.created_at.toISOString(), + updated_at: br.updated_at.toISOString(), + })), + categories: brand.categories, + created_at: brand.created_at.toISOString(), + updated_at: brand.updated_at.toISOString(), + }; +} + +// ─── Service detail ─────────────────────────────────────────────────────────── + +export async function getServiceModerationDetail(serviceId: string) { + const service = await prisma.service.findUnique({ + where: { id: serviceId }, + include: { + owner: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + type: true, + created_at: true, + }, + }, + images: { + select: { + id: true, + media_id: true, + order: true, + media: { select: { id: true, storage_path: true } }, + }, + orderBy: { order: 'asc' }, + }, + branch: { + select: { + id: true, + name: true, + address1: true, + brand: { select: { id: true, name: true } }, + }, + }, + }, + }); + + if (!service) return null; + + return { + id: service.id, + title: service.title, + description: service.description ?? undefined, + status: service.status, + rejection_reason: service.rejection_reason ?? undefined, + category: service.category ?? undefined, + price: service.price ? Number(service.price) : null, + price_type: service.price_type, + duration: service.duration ?? undefined, + address: service.address ?? undefined, + owner: service.owner, + images: service.images.map((img) => ({ + id: img.id, + media_id: img.media_id, + order: img.order, + url: buildFileUrl(img.media.storage_path), + })), + branch: service.branch + ? { + id: service.branch.id, + name: service.branch.name, + address1: service.branch.address1, + brand: service.branch.brand, + } + : null, + created_at: service.created_at.toISOString(), + updated_at: service.updated_at.toISOString(), + }; +} + +// ─── Brand moderation actions ───────────────────────────────────────────────── + +export async function approveBrand( + brandId: string, + reviewerId: string, + checklist?: ChecklistItem[], +) { + const brand = await prisma.brand.findUnique({ + where: { id: brandId }, + select: { id: true, status: true, owner_id: true, name: true }, + }); + + if (!brand) return { notFound: true } as const; + if (brand.status !== 'PENDING') return { wrongStatus: true } as const; + + await prisma.$transaction([ + prisma.brand.update({ + where: { id: brandId }, + data: { status: 'ACTIVE', rejection_reason: null }, + }), + prisma.moderationReview.create({ + data: { + entity_type: 'brand', + entity_id: brandId, + reviewer_id: reviewerId, + outcome: 'APPROVED', + rejection_reason: null, + checklist: checklist ? (checklist as unknown as Prisma.InputJsonValue) : undefined, + }, + }), + prisma.notification.create({ + data: { + user_id: brand.owner_id, + type: 'brand_approved', + title: 'Brand approved', + body: `Your brand "${brand.name}" has been approved and is now active.`, + data: { brand_id: brandId }, + }, + }), + ]); + + return { ok: true } as const; +} + +export async function rejectBrand( + brandId: string, + reviewerId: string, + rejectionReason: string, + checklist?: ChecklistItem[], +) { + const brand = await prisma.brand.findUnique({ + where: { id: brandId }, + select: { id: true, status: true, owner_id: true, name: true }, + }); + + if (!brand) return { notFound: true } as const; + if (brand.status !== 'PENDING') return { wrongStatus: true } as const; + + await prisma.$transaction([ + prisma.brand.update({ + where: { id: brandId }, + data: { status: 'REJECTED', rejection_reason: rejectionReason }, + }), + prisma.moderationReview.create({ + data: { + entity_type: 'brand', + entity_id: brandId, + reviewer_id: reviewerId, + outcome: 'REJECTED', + rejection_reason: rejectionReason, + checklist: checklist ? (checklist as unknown as Prisma.InputJsonValue) : undefined, + }, + }), + prisma.notification.create({ + data: { + user_id: brand.owner_id, + type: 'brand_rejected', + title: 'Brand rejected', + body: `Your brand "${brand.name}" has been rejected. Reason: ${rejectionReason}`, + data: { brand_id: brandId }, + }, + }), + ]); + + return { ok: true } as const; +} + +// ─── Service moderation actions ─────────────────────────────────────────────── + +export async function approveService( + serviceId: string, + reviewerId: string, + checklist?: ChecklistItem[], +) { + const service = await prisma.service.findUnique({ + where: { id: serviceId }, + select: { id: true, status: true, owner_id: true, title: true }, + }); + + if (!service) return { notFound: true } as const; + if (service.status !== 'PENDING') return { wrongStatus: true } as const; + + await prisma.$transaction([ + prisma.service.update({ + where: { id: serviceId }, + data: { status: 'ACTIVE', rejection_reason: null }, + }), + prisma.moderationReview.create({ + data: { + entity_type: 'service', + entity_id: serviceId, + reviewer_id: reviewerId, + outcome: 'APPROVED', + rejection_reason: null, + checklist: checklist ? (checklist as unknown as Prisma.InputJsonValue) : undefined, + }, + }), + prisma.notification.create({ + data: { + user_id: service.owner_id, + type: 'service_approved', + title: 'Service approved', + body: `Your service "${service.title}" has been approved and is now active.`, + data: { service_id: serviceId }, + }, + }), + ]); + + return { ok: true } as const; +} + +export async function rejectService( + serviceId: string, + reviewerId: string, + rejectionReason: string, + checklist?: ChecklistItem[], +) { + const service = await prisma.service.findUnique({ + where: { id: serviceId }, + select: { id: true, status: true, owner_id: true, title: true }, + }); + + if (!service) return { notFound: true } as const; + if (service.status !== 'PENDING') return { wrongStatus: true } as const; + + await prisma.$transaction([ + prisma.service.update({ + where: { id: serviceId }, + data: { status: 'REJECTED', rejection_reason: rejectionReason }, + }), + prisma.moderationReview.create({ + data: { + entity_type: 'service', + entity_id: serviceId, + reviewer_id: reviewerId, + outcome: 'REJECTED', + rejection_reason: rejectionReason, + checklist: checklist ? (checklist as unknown as Prisma.InputJsonValue) : undefined, + }, + }), + prisma.notification.create({ + data: { + user_id: service.owner_id, + type: 'service_rejected', + title: 'Service rejected', + body: `Your service "${service.title}" has been rejected. Reason: ${rejectionReason}`, + data: { service_id: serviceId }, + }, + }), + ]); + + return { ok: true } as const; +} From 9bbafbcbfa265327f4e4b9818383ccd8318bfc5d Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Mon, 27 Apr 2026 00:27:09 +0400 Subject: [PATCH 02/25] feat(categories): add ServiceCategory table, refactor BrandCategory to use key field --- prisma/schema.prisma | 46 +++++++++++++--------- src/controllers/brand.controller.ts | 6 +-- src/controllers/service.controller.ts | 31 +++++++++++---- src/lib/seed-categories.ts | 56 ++++++++++++++++++--------- src/routes/v1/service.route.ts | 2 + src/schemas/service.schema.ts | 4 +- src/services/moderation.service.ts | 4 +- 7 files changed, 99 insertions(+), 50 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 881faf1..6135c77 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -148,10 +148,16 @@ enum BrandTransferStatus { model BrandCategory { id String @id @default(cuid()) - name String @unique + key String @unique brands Brand[] } +model ServiceCategory { + id String @id @default(cuid()) + key String @unique + services Service[] +} + model Brand { id String @id @default(cuid()) name String @@ -363,28 +369,30 @@ model TeamMember { // ─── Service models ─────────────────────────────────────────────────────────── model Service { - id String @id @default(cuid()) - title String - description String? - owner_id String - branch_id String? - category String? - price Decimal? @db.Decimal(10, 2) - price_type PriceType @default(FIXED) - duration Int? - address String? - status ServiceStatus @default(DRAFT) - rejection_reason String? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - owner User @relation(fields: [owner_id], references: [id], onDelete: Cascade) - branch Branch? @relation(fields: [branch_id], references: [id], onDelete: SetNull) - images ServiceMedia[] + id String @id @default(cuid()) + title String + description String? + owner_id String + branch_id String? + service_category_id String? + price Decimal? @db.Decimal(10, 2) + price_type PriceType @default(FIXED) + duration Int? + address String? + status ServiceStatus @default(DRAFT) + rejection_reason String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + owner User @relation(fields: [owner_id], references: [id], onDelete: Cascade) + branch Branch? @relation(fields: [branch_id], references: [id], onDelete: SetNull) + service_category ServiceCategory? @relation(fields: [service_category_id], references: [id], onDelete: SetNull) + images ServiceMedia[] @@index([owner_id]) @@index([branch_id]) @@index([status]) + @@index([service_category_id]) } model ServiceMedia { diff --git a/src/controllers/brand.controller.ts b/src/controllers/brand.controller.ts index 766c45d..45dd779 100644 --- a/src/controllers/brand.controller.ts +++ b/src/controllers/brand.controller.ts @@ -135,7 +135,7 @@ const brandSelect = { logo_media_id: true, created_at: true, updated_at: true, - categories: { select: { id: true, name: true } }, + categories: { select: { id: true, key: true } }, logo_media: { select: { id: true, storage_path: true } }, gallery: { select: { @@ -155,7 +155,7 @@ const brandSelect = { } as const; type BrandRaw = Awaited> & { - categories: { id: string; name: string }[]; + categories: { id: string; key: string }[]; logo_media: { id: string; storage_path: string } | null; gallery: { id: string; media_id: string; order: number; media: { id: string; storage_path: string } }[]; ratings: { value: number; user_id: string }[]; @@ -1283,7 +1283,7 @@ export const listCategories = async ( ): Promise => { try { const categories = await prisma.brandCategory.findMany({ - orderBy: { name: 'asc' }, + orderBy: { key: 'asc' }, }); sendSuccess({ res, status: 200, message: 'brand.categories_list', data: { categories } }); diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index aa6a18e..3de104b 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -60,7 +60,8 @@ const serviceSelect = { description: true, owner_id: true, branch_id: true, - category: true, + service_category_id: true, + service_category: { select: { id: true, key: true } }, price: true, price_type: true, duration: true, @@ -87,7 +88,8 @@ function mapService(raw: any) { description: raw.description ?? undefined, owner_id: raw.owner_id, branch_id: raw.branch_id ?? null, - category: raw.category ?? undefined, + service_category_id: raw.service_category_id ?? null, + service_category: raw.service_category ?? null, price: raw.price ? Number(raw.price) : null, price_type: raw.price_type, duration: raw.duration ?? null, @@ -105,7 +107,7 @@ function mapService(raw: any) { }; } -const SIGNIFICANT_FIELDS = ['title', 'description', 'price', 'price_type', 'duration', 'address', 'branch_id'] as const; +const SIGNIFICANT_FIELDS = ['title', 'description', 'price', 'price_type', 'duration', 'address', 'branch_id', 'service_category_id'] as const; // ─── Media upload ───────────────────────────────────────────────────────────── @@ -195,7 +197,7 @@ export const createService = async ( description: body.description, owner_id: userId, branch_id: body.branch_id ?? null, - category: body.category, + service_category_id: body.service_category_id ?? null, price: body.price !== undefined ? body.price : null, price_type: body.price_type ?? 'FIXED', duration: body.duration ?? null, @@ -252,14 +254,14 @@ export const listPublicServices = async ( next: NextFunction, ): Promise => { try { - const category = typeof req.query['category'] === 'string' ? req.query['category'] : undefined; + const service_category_id = typeof req.query['service_category_id'] === 'string' ? req.query['service_category_id'] : undefined; const branch_id = typeof req.query['branch_id'] === 'string' ? req.query['branch_id'] : undefined; const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined; const services = await prisma.service.findMany({ where: { status: 'ACTIVE', - ...(category && { category }), + ...(service_category_id && { service_category_id }), ...(branch_id && { branch_id }), ...(q && { OR: [ @@ -373,7 +375,7 @@ export const updateService = async ( ...(body.title !== undefined && { title: body.title }), ...(body.description !== undefined && { description: body.description }), ...(body.branch_id !== undefined && { branch_id: body.branch_id }), - ...(body.category !== undefined && { category: body.category }), + ...(body.service_category_id !== undefined && { service_category_id: body.service_category_id }), ...(body.price !== undefined && { price: body.price }), ...(body.price_type !== undefined && { price_type: body.price_type }), ...(body.duration !== undefined && { duration: body.duration }), @@ -615,3 +617,18 @@ export const archiveService = async ( } }; +// ─── Service categories (public) ────────────────────────────────────────────── + +export const listServiceCategories = async ( + _req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const categories = await prisma.serviceCategory.findMany({ orderBy: { key: 'asc' } }); + sendSuccess({ res, status: 200, message: 'service.categories_list', data: { categories } }); + } catch (err) { + next(err); + } +}; + diff --git a/src/lib/seed-categories.ts b/src/lib/seed-categories.ts index 6a0cf7c..57b5232 100644 --- a/src/lib/seed-categories.ts +++ b/src/lib/seed-categories.ts @@ -1,35 +1,55 @@ import prisma from './prisma'; -const BRAND_CATEGORIES = [ - 'Food & Beverage', - 'Beauty & Wellness', - 'Fitness & Sports', - 'Fashion & Apparel', - 'Technology & Electronics', - 'Home & Furniture', - 'Health & Pharmacy', - 'Education & Training', - 'Entertainment & Media', - 'Travel & Hospitality', +const BRAND_CATEGORY_KEYS = [ + 'food_beverage', + 'beauty_wellness', + 'fitness_sports', + 'fashion_apparel', + 'technology_electronics', + 'home_furniture', + 'health_pharmacy', + 'education_training', + 'entertainment_media', + 'travel_hospitality', +]; + +const SERVICE_CATEGORY_KEYS = [ + 'haircut_styling', + 'massage_therapy', + 'personal_training', + 'nail_care', + 'facial_treatment', + 'dental_care', + 'consulting', + 'photo_session', ]; export async function seedBrandCategories(): Promise { console.log('Seeding brand categories...'); - - for (const name of BRAND_CATEGORIES) { + for (const key of BRAND_CATEGORY_KEYS) { await prisma.brandCategory.upsert({ - where: { name }, + where: { key }, update: {}, - create: { name }, + create: { key }, }); } + console.log(`Seeded ${BRAND_CATEGORY_KEYS.length} brand categories.`); +} - console.log(`Seeded ${BRAND_CATEGORIES.length} brand categories.`); +export async function seedServiceCategories(): Promise { + console.log('Seeding service categories...'); + for (const key of SERVICE_CATEGORY_KEYS) { + await prisma.serviceCategory.upsert({ + where: { key }, + update: {}, + create: { key }, + }); + } + console.log(`Seeded ${SERVICE_CATEGORY_KEYS.length} service categories.`); } -// Allow running directly: npx ts-node src/lib/seed-categories.ts if (require.main === module) { - seedBrandCategories() + Promise.all([seedBrandCategories(), seedServiceCategories()]) .catch((err) => { console.error(err); process.exit(1); diff --git a/src/routes/v1/service.route.ts b/src/routes/v1/service.route.ts index 821acec..adf94ba 100644 --- a/src/routes/v1/service.route.ts +++ b/src/routes/v1/service.route.ts @@ -5,6 +5,7 @@ import { createService, getMyServices, listPublicServices, + listServiceCategories, getServiceById, updateService, deleteService, @@ -45,6 +46,7 @@ router.post('/services/media', authenticate, upload.single('file'), uploadServic // ─── Public listing ──────────────────────────────────────────────────────────── +router.get('/service-categories', listServiceCategories); router.get('/services', listPublicServices); // ─── Authenticated routes ────────────────────────────────────────────────────── diff --git a/src/schemas/service.schema.ts b/src/schemas/service.schema.ts index 4416fd6..5ceed22 100644 --- a/src/schemas/service.schema.ts +++ b/src/schemas/service.schema.ts @@ -4,7 +4,7 @@ export const createServiceSchema = z.object({ title: z.string().min(2, 'Title must be at least 2 characters').max(150).trim(), description: z.string().max(2000).trim().optional(), branch_id: z.string().cuid('Invalid branch id').nullable().optional(), - category: z.string().max(100).trim().optional(), + service_category_id: z.string().cuid('Invalid category id').nullable().optional(), price: z.number().positive().optional(), price_type: z.enum(['FIXED', 'STARTING_FROM', 'FREE']).default('FIXED'), duration: z.number().int().positive().max(1440).optional(), @@ -21,7 +21,7 @@ export const updateServiceSchema = z.object({ title: z.string().min(2).max(150).trim().optional(), description: z.string().max(2000).trim().nullable().optional(), branch_id: z.string().cuid('Invalid branch id').nullable().optional(), - category: z.string().max(100).trim().nullable().optional(), + service_category_id: z.string().cuid('Invalid category id').nullable().optional(), price: z.number().positive().nullable().optional(), price_type: z.enum(['FIXED', 'STARTING_FROM', 'FREE']).optional(), duration: z.number().int().positive().max(1440).nullable().optional(), diff --git a/src/services/moderation.service.ts b/src/services/moderation.service.ts index 2dae174..8a92af4 100644 --- a/src/services/moderation.service.ts +++ b/src/services/moderation.service.ts @@ -198,6 +198,7 @@ export async function getServiceModerationDetail(serviceId: string) { brand: { select: { id: true, name: true } }, }, }, + service_category: { select: { id: true, key: true } }, }, }); @@ -209,7 +210,8 @@ export async function getServiceModerationDetail(serviceId: string) { description: service.description ?? undefined, status: service.status, rejection_reason: service.rejection_reason ?? undefined, - category: service.category ?? undefined, + service_category_id: service.service_category_id ?? null, + service_category: service.service_category ?? null, price: service.price ? Number(service.price) : null, price_type: service.price_type, duration: service.duration ?? undefined, From 32c187605ec259b88ebd2ca5123280760849fe43 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Mon, 27 Apr 2026 00:38:01 +0400 Subject: [PATCH 03/25] feat: add ServiceCategory table and refactor Service and BrandCategory relationships --- .../migration.sql | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql diff --git a/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql b/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql new file mode 100644 index 0000000..8ab349a --- /dev/null +++ b/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "ServiceCategory" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + + CONSTRAINT "ServiceCategory_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ServiceCategory_key_key" ON "ServiceCategory"("key"); + +-- AlterTable BrandCategory: rename name to key +ALTER TABLE "BrandCategory" RENAME COLUMN "name" TO "key"; + +-- DropIndex old unique on name (already renamed, constraint name stays) +ALTER TABLE "BrandCategory" RENAME CONSTRAINT "BrandCategory_name_key" TO "BrandCategory_key_key"; + +-- AlterTable Service +ALTER TABLE "Service" DROP COLUMN IF EXISTS "category"; +ALTER TABLE "Service" ADD COLUMN "service_category_id" TEXT; + +-- CreateIndex +CREATE INDEX "Service_service_category_id_idx" ON "Service"("service_category_id"); + +-- AddForeignKey +ALTER TABLE "Service" ADD CONSTRAINT "Service_service_category_id_fkey" FOREIGN KEY ("service_category_id") REFERENCES "ServiceCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; From 94a20ddc15358bcd0195ec6b75a72376ec27414e Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Mon, 27 Apr 2026 00:38:58 +0400 Subject: [PATCH 04/25] fix(categories): fix migration SQL to match actual DB state after db push --- .../migration.sql | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql b/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql index 8ab349a..55a8cd7 100644 --- a/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql +++ b/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql @@ -1,26 +1,18 @@ --- CreateTable -CREATE TABLE "ServiceCategory" ( +-- BrandCategory: rename unique index to match new key field name +ALTER INDEX "BrandCategory_name_key" RENAME TO "BrandCategory_key_key"; + +-- ServiceCategory already exists from db push — ensure idempotent +CREATE TABLE IF NOT EXISTS "ServiceCategory" ( "id" TEXT NOT NULL, "key" TEXT NOT NULL, - CONSTRAINT "ServiceCategory_pkey" PRIMARY KEY ("id") ); +CREATE UNIQUE INDEX IF NOT EXISTS "ServiceCategory_key_key" ON "ServiceCategory"("key"); --- CreateIndex -CREATE UNIQUE INDEX "ServiceCategory_key_key" ON "ServiceCategory"("key"); - --- AlterTable BrandCategory: rename name to key -ALTER TABLE "BrandCategory" RENAME COLUMN "name" TO "key"; - --- DropIndex old unique on name (already renamed, constraint name stays) -ALTER TABLE "BrandCategory" RENAME CONSTRAINT "BrandCategory_name_key" TO "BrandCategory_key_key"; - --- AlterTable Service +-- Service: drop old category text column, add service_category_id relation ALTER TABLE "Service" DROP COLUMN IF EXISTS "category"; -ALTER TABLE "Service" ADD COLUMN "service_category_id" TEXT; - --- CreateIndex -CREATE INDEX "Service_service_category_id_idx" ON "Service"("service_category_id"); - --- AddForeignKey -ALTER TABLE "Service" ADD CONSTRAINT "Service_service_category_id_fkey" FOREIGN KEY ("service_category_id") REFERENCES "ServiceCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Service" ADD COLUMN IF NOT EXISTS "service_category_id" TEXT; +CREATE INDEX IF NOT EXISTS "Service_service_category_id_idx" ON "Service"("service_category_id"); +ALTER TABLE "Service" ADD CONSTRAINT "Service_service_category_id_fkey" + FOREIGN KEY ("service_category_id") REFERENCES "ServiceCategory"("id") + ON DELETE SET NULL ON UPDATE CASCADE; From a5597ae5ec85ecb685d71989f254e54fde911b37 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Mon, 27 Apr 2026 00:39:45 +0400 Subject: [PATCH 05/25] fix(categories): correct migration SQL for clean apply on top of old migrations --- .../migration.sql | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql b/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql index 55a8cd7..e1b67c5 100644 --- a/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql +++ b/prisma/migrations/20260427000000_add_service_categories_refactor_brand_categories/migration.sql @@ -1,18 +1,19 @@ --- BrandCategory: rename unique index to match new key field name +-- BrandCategory: rename name -> key +ALTER TABLE "BrandCategory" RENAME COLUMN "name" TO "key"; ALTER INDEX "BrandCategory_name_key" RENAME TO "BrandCategory_key_key"; --- ServiceCategory already exists from db push — ensure idempotent -CREATE TABLE IF NOT EXISTS "ServiceCategory" ( +-- ServiceCategory +CREATE TABLE "ServiceCategory" ( "id" TEXT NOT NULL, "key" TEXT NOT NULL, CONSTRAINT "ServiceCategory_pkey" PRIMARY KEY ("id") ); -CREATE UNIQUE INDEX IF NOT EXISTS "ServiceCategory_key_key" ON "ServiceCategory"("key"); +CREATE UNIQUE INDEX "ServiceCategory_key_key" ON "ServiceCategory"("key"); --- Service: drop old category text column, add service_category_id relation -ALTER TABLE "Service" DROP COLUMN IF EXISTS "category"; -ALTER TABLE "Service" ADD COLUMN IF NOT EXISTS "service_category_id" TEXT; -CREATE INDEX IF NOT EXISTS "Service_service_category_id_idx" ON "Service"("service_category_id"); +-- Service: drop category text, add service_category_id relation +ALTER TABLE "Service" DROP COLUMN "category"; +ALTER TABLE "Service" ADD COLUMN "service_category_id" TEXT; +CREATE INDEX "Service_service_category_id_idx" ON "Service"("service_category_id"); ALTER TABLE "Service" ADD CONSTRAINT "Service_service_category_id_fkey" FOREIGN KEY ("service_category_id") REFERENCES "ServiceCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; From 49b770c584a3da6108bc02c18d725b0eadd6731b Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Tue, 28 Apr 2026 10:49:53 +0400 Subject: [PATCH 06/25] feat: update minimum age requirement to 18 in auth and user schemas --- src/schemas/auth.schema.ts | 4 ++-- src/schemas/user.schema.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 73016ee..81a9dc6 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -16,8 +16,8 @@ export const registerSchema = z.object({ .date('Birthday must be a valid date (YYYY-MM-DD)') .refine((val) => { const age = (Date.now() - new Date(val).getTime()) / (1000 * 60 * 60 * 24 * 365.25); - return age >= 13; - }, 'Must be at least 13 years old'), + return age >= 18; + }, 'Must be at least 18 years old'), country: z.string().min(2).max(100).trim(), email: z.string().email('Invalid email address').toLowerCase().trim(), password: z diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index 1e1a1ca..f124d78 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -8,8 +8,8 @@ export const updateMeSchema = z.object({ .date('Birthday must be a valid date (YYYY-MM-DD)') .refine((val) => { const age = (Date.now() - new Date(val).getTime()) / (1000 * 60 * 60 * 24 * 365.25); - return age >= 13; - }, 'Must be at least 13 years old'), + return age >= 18; + }, 'Must be at least 18 years old'), country: z.string().min(2).max(100).trim(), email: z.string().email('Invalid email address').toLowerCase().trim(), // Full E.164 format: '+' followed by 7–15 digits (e.g. "+9941234567"). From cdaae6e4c1cd4a06c2705728731cfef7044994fb Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Wed, 29 Apr 2026 15:43:59 +0400 Subject: [PATCH 07/25] fix: allow editing of paused services and permit submission from paused status --- src/controllers/service.controller.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index 3de104b..32c062a 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -346,8 +346,8 @@ export const updateService = async ( const { status } = existing; - // PAUSED, ARCHIVED, PENDING cannot be edited - if (status === 'PAUSED' || status === 'ARCHIVED' || status === 'PENDING') { + // ARCHIVED and PENDING cannot be edited + if (status === 'ARCHIVED' || status === 'PENDING') { const err: AppError = new Error(); err.statusCode = 400; err.messageKey = 'service.cannot_update_in_current_status'; @@ -466,7 +466,11 @@ export const submitService = async ( if (!requireOwner(existing.owner_id, userId, next)) return; - if (existing.status !== 'DRAFT' && existing.status !== 'REJECTED') { + if ( + existing.status !== 'DRAFT' && + existing.status !== 'REJECTED' && + existing.status !== 'PAUSED' + ) { const err: AppError = new Error(); err.statusCode = 400; err.messageKey = 'service.cannot_submit_in_current_status'; From 7012f5598b2a05e612b18809a58335eddb171412 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Thu, 30 Apr 2026 12:49:24 +0400 Subject: [PATCH 08/25] feat: add unarchive service endpoint to restore archived services to draft status --- src/controllers/service.controller.ts | 44 +++++++++++++++++++++++++++ src/routes/v1/service.route.ts | 2 ++ 2 files changed, 46 insertions(+) diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index 32c062a..3be03be 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -621,6 +621,50 @@ export const archiveService = async ( } }; +export const unarchiveService = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUso(req, next)) return; + + const id = req.params['id'] as string; + const userId = req.user.sub; + + const existing = await prisma.service.findUnique({ + where: { id }, + select: { owner_id: true, status: true }, + }); + + if (!existing) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); + } + + if (!requireOwner(existing.owner_id, userId, next)) return; + + if (existing.status !== 'ARCHIVED') { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'service.cannot_unarchive_in_current_status'; + return next(err); + } + + const service = await prisma.service.update({ + where: { id }, + data: { status: 'DRAFT' }, + select: serviceSelect, + }); + + sendSuccess({ res, status: 200, message: 'service.unarchived', data: { service: mapService(service) } }); + } catch (err) { + next(err); + } +}; + // ─── Service categories (public) ────────────────────────────────────────────── export const listServiceCategories = async ( diff --git a/src/routes/v1/service.route.ts b/src/routes/v1/service.route.ts index adf94ba..466ccfe 100644 --- a/src/routes/v1/service.route.ts +++ b/src/routes/v1/service.route.ts @@ -13,6 +13,7 @@ import { pauseService, resumeService, archiveService, + unarchiveService, } from '../../controllers/service.controller'; import { authenticate } from '../../middlewares/auth.middleware'; import { validate } from '../../middlewares/validate.middleware'; @@ -63,5 +64,6 @@ router.post('/services/:id/submit', authenticate, submitService); router.post('/services/:id/pause', authenticate, pauseService); router.post('/services/:id/resume', authenticate, resumeService); router.post('/services/:id/archive', authenticate, archiveService); +router.post('/services/:id/unarchive', authenticate, unarchiveService); export default router; From 3d8c4b3abdc41d0d8a9c821d99a36c994f3a4a9c Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Thu, 30 Apr 2026 13:39:23 +0400 Subject: [PATCH 09/25] refactor: update moderation service response to include sorted items list with nested service category data --- src/services/moderation.service.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/services/moderation.service.ts b/src/services/moderation.service.ts index 8a92af4..f433ee8 100644 --- a/src/services/moderation.service.ts +++ b/src/services/moderation.service.ts @@ -46,7 +46,7 @@ export async function getModerationQueue(type?: 'brand' | 'service') { title: true, description: true, status: true, - category: true, + service_category: { select: { id: true, key: true } }, price: true, price_type: true, created_at: true, @@ -64,9 +64,10 @@ export async function getModerationQueue(type?: 'brand' | 'service') { }), ]); - return { - brands: brands.map((b) => ({ + const brandItems = brands.map((b) => ({ id: b.id, + type: 'brand' as const, + title: b.name, name: b.name, description: b.description ?? undefined, status: b.status, @@ -74,19 +75,28 @@ export async function getModerationQueue(type?: 'brand' | 'service') { owner: b.owner, created_at: b.created_at.toISOString(), updated_at: b.updated_at.toISOString(), - })), - services: services.map((s) => ({ + })); + + const serviceItems = services.map((s) => ({ id: s.id, + type: 'service' as const, title: s.title, description: s.description ?? undefined, status: s.status, - category: s.category ?? undefined, + service_category: s.service_category ?? null, price: s.price ? Number(s.price) : null, price_type: s.price_type, owner: s.owner, created_at: s.created_at.toISOString(), updated_at: s.updated_at.toISOString(), - })), + })); + + return { + items: [...brandItems, ...serviceItems].sort((a, b) => + a.created_at.localeCompare(b.created_at), + ), + brands: brandItems, + services: serviceItems, }; } From b3f25af04e17243e9ce519147694dc73f3ecb47e Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Thu, 30 Apr 2026 13:53:14 +0400 Subject: [PATCH 10/25] feat: reset brand status to PENDING upon update if currently ACTIVE or REJECTED --- src/controllers/brand.controller.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/controllers/brand.controller.ts b/src/controllers/brand.controller.ts index 45dd779..9a4ba9f 100644 --- a/src/controllers/brand.controller.ts +++ b/src/controllers/brand.controller.ts @@ -501,7 +501,7 @@ export const updateBrand = async ( const id = req.params['id'] as string; const userId = req.user.sub; - const existing = await prisma.brand.findUnique({ where: { id }, select: { owner_id: true } }); + const existing = await prisma.brand.findUnique({ where: { id }, select: { owner_id: true, status: true } }); if (!existing) { const err: AppError = new Error(); err.statusCode = 404; @@ -510,6 +510,10 @@ export const updateBrand = async ( } if (!requireOwner(existing.owner_id, userId, next)) return; + // Editing an ACTIVE or REJECTED brand resets it to PENDING for re-review. + // PENDING stays PENDING; CLOSED is immutable. + const shouldResetToPending = existing.status === 'ACTIVE' || existing.status === 'REJECTED'; + const body = req.body as UpdateBrandInput; // Validate ownership of any new media being attached @@ -539,6 +543,7 @@ export const updateBrand = async ( const brand = await prisma.brand.update({ where: { id }, data: { + ...(shouldResetToPending && { status: 'PENDING' }), ...(body.name !== undefined && { name: body.name }), ...(body.description !== undefined && { description: body.description }), // logo_media_id can be set to null (removal) or a new id; skip if not in payload From e79eb657875168fba67762fe6b25e8c1d11c5de9 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Thu, 30 Apr 2026 14:18:36 +0400 Subject: [PATCH 11/25] feat: add social media and website URL fields to User and Brand models and controllers --- .../migration.sql | 8 +++++ .../migration.sql | 8 +++++ prisma/schema.prisma | 14 +++++++++ src/controllers/brand.controller.ts | 28 +++++++++++++++++ src/controllers/user.controller.ts | 30 ++++++++++++++++++- src/schemas/brand.schema.ts | 14 +++++++++ src/schemas/user.schema.ts | 9 ++++++ 7 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260430095619_add_brand_social_links/migration.sql create mode 100644 prisma/migrations/20260430100653_add_user_social_links/migration.sql diff --git a/prisma/migrations/20260430095619_add_brand_social_links/migration.sql b/prisma/migrations/20260430095619_add_brand_social_links/migration.sql new file mode 100644 index 0000000..f8381b3 --- /dev/null +++ b/prisma/migrations/20260430095619_add_brand_social_links/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "Brand" ADD COLUMN "facebook_url" TEXT, +ADD COLUMN "instagram_url" TEXT, +ADD COLUMN "linkedin_url" TEXT, +ADD COLUMN "website_url" TEXT, +ADD COLUMN "whatsapp_url" TEXT, +ADD COLUMN "x_url" TEXT, +ADD COLUMN "youtube_url" TEXT; diff --git a/prisma/migrations/20260430100653_add_user_social_links/migration.sql b/prisma/migrations/20260430100653_add_user_social_links/migration.sql new file mode 100644 index 0000000..ef931a7 --- /dev/null +++ b/prisma/migrations/20260430100653_add_user_social_links/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "facebook_url" TEXT, +ADD COLUMN "instagram_url" TEXT, +ADD COLUMN "linkedin_url" TEXT, +ADD COLUMN "website_url" TEXT, +ADD COLUMN "whatsapp_url" TEXT, +ADD COLUMN "x_url" TEXT, +ADD COLUMN "youtube_url" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6135c77..249cbda 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,6 +78,13 @@ model User { phone_verified Boolean @default(false) email_verified Boolean @default(false) avatar_media_id String? + instagram_url String? + facebook_url String? + youtube_url String? + whatsapp_url String? + linkedin_url String? + x_url String? + website_url String? created_at DateTime @default(now()) updated_at DateTime @updatedAt @@ -165,6 +172,13 @@ model Brand { status BrandStatus @default(PENDING) owner_id String logo_media_id String? + instagram_url String? + facebook_url String? + youtube_url String? + whatsapp_url String? + linkedin_url String? + x_url String? + website_url String? created_at DateTime @default(now()) updated_at DateTime @updatedAt diff --git a/src/controllers/brand.controller.ts b/src/controllers/brand.controller.ts index 9a4ba9f..0bc6e7f 100644 --- a/src/controllers/brand.controller.ts +++ b/src/controllers/brand.controller.ts @@ -133,6 +133,13 @@ const brandSelect = { status: true, owner_id: true, logo_media_id: true, + instagram_url: true, + facebook_url: true, + youtube_url: true, + whatsapp_url: true, + linkedin_url: true, + x_url: true, + website_url: true, created_at: true, updated_at: true, categories: { select: { id: true, key: true } }, @@ -186,6 +193,13 @@ function mapBrand(raw: BrandRaw, requesterId?: string) { order: g.order, url: buildFileUrl(g.media.storage_path), })), + instagram_url: raw.instagram_url ?? undefined, + facebook_url: raw.facebook_url ?? undefined, + youtube_url: raw.youtube_url ?? undefined, + whatsapp_url: raw.whatsapp_url ?? undefined, + linkedin_url: raw.linkedin_url ?? undefined, + x_url: raw.x_url ?? undefined, + website_url: raw.website_url ?? undefined, rating: ratingAverage, rating_count: ratingCount, my_rating: myRating, @@ -349,6 +363,13 @@ export const createBrand = async ( description: body.description, owner_id: userId, logo_media_id: body.logo_media_id ?? null, + instagram_url: body.instagram_url ?? null, + facebook_url: body.facebook_url ?? null, + youtube_url: body.youtube_url ?? null, + whatsapp_url: body.whatsapp_url ?? null, + linkedin_url: body.linkedin_url ?? null, + x_url: body.x_url ?? null, + website_url: body.website_url ?? null, categories: body.categoryIds && body.categoryIds.length > 0 ? { connect: body.categoryIds.map((id) => ({ id })) } @@ -548,6 +569,13 @@ export const updateBrand = async ( ...(body.description !== undefined && { description: body.description }), // logo_media_id can be set to null (removal) or a new id; skip if not in payload ...(body.logo_media_id !== undefined && { logo_media_id: body.logo_media_id }), + ...(body.instagram_url !== undefined && { instagram_url: body.instagram_url }), + ...(body.facebook_url !== undefined && { facebook_url: body.facebook_url }), + ...(body.youtube_url !== undefined && { youtube_url: body.youtube_url }), + ...(body.whatsapp_url !== undefined && { whatsapp_url: body.whatsapp_url }), + ...(body.linkedin_url !== undefined && { linkedin_url: body.linkedin_url }), + ...(body.x_url !== undefined && { x_url: body.x_url }), + ...(body.website_url !== undefined && { website_url: body.website_url }), ...(body.categoryIds !== undefined && { categories: { set: body.categoryIds.map((cid) => ({ id: cid })), diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index d4aa066..69b4e7f 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -35,6 +35,13 @@ const privateUserSelect = { type: true, phone_verified: true, email_verified: true, + instagram_url: true, + facebook_url: true, + youtube_url: true, + whatsapp_url: true, + linkedin_url: true, + x_url: true, + website_url: true, avatar_media: { select: { storage_path: true } }, created_at: true, updated_at: true, @@ -57,6 +64,13 @@ export const getUserById = async ( last_name: true, email: true, type: true, + instagram_url: true, + facebook_url: true, + youtube_url: true, + whatsapp_url: true, + linkedin_url: true, + x_url: true, + website_url: true, avatar_media: { select: { storage_path: true } }, created_at: true, updated_at: true, @@ -70,7 +84,7 @@ export const getUserById = async ( return next(err); } - const { avatar_media, ...rest } = user; + const { avatar_media, instagram_url, facebook_url, youtube_url, whatsapp_url, linkedin_url, x_url, website_url, ...rest } = user; sendSuccess({ res, @@ -80,6 +94,13 @@ export const getUserById = async ( user: { ...rest, avatar_url: resolveAvatarUrl(avatar_media?.storage_path), + ...(instagram_url && { instagram_url }), + ...(facebook_url && { facebook_url }), + ...(youtube_url && { youtube_url }), + ...(whatsapp_url && { whatsapp_url }), + ...(linkedin_url && { linkedin_url }), + ...(x_url && { x_url }), + ...(website_url && { website_url }), }, }, }); @@ -169,6 +190,13 @@ export const updateMe = async ( phone: newPhone, ...(emailChanged && { email_verified: false }), ...(phoneChanged && { phone_verified: false }), + ...(body.instagram_url !== undefined && { instagram_url: body.instagram_url }), + ...(body.facebook_url !== undefined && { facebook_url: body.facebook_url }), + ...(body.youtube_url !== undefined && { youtube_url: body.youtube_url }), + ...(body.whatsapp_url !== undefined && { whatsapp_url: body.whatsapp_url }), + ...(body.linkedin_url !== undefined && { linkedin_url: body.linkedin_url }), + ...(body.x_url !== undefined && { x_url: body.x_url }), + ...(body.website_url !== undefined && { website_url: body.website_url }), }, select: privateUserSelect, }); diff --git a/src/schemas/brand.schema.ts b/src/schemas/brand.schema.ts index ddb2365..c76fa96 100644 --- a/src/schemas/brand.schema.ts +++ b/src/schemas/brand.schema.ts @@ -59,6 +59,18 @@ export type UpdateBranchInput = z.infer; // ─── Brand ──────────────────────────────────────────────────────────────────── +const socialUrlSchema = z.string().url('Invalid URL').max(500).nullable().optional(); + +const socialLinksShape = { + instagram_url: socialUrlSchema, + facebook_url: socialUrlSchema, + youtube_url: socialUrlSchema, + whatsapp_url: socialUrlSchema, + linkedin_url: socialUrlSchema, + x_url: socialUrlSchema, + website_url: socialUrlSchema, +}; + export const createBrandSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters').max(100).trim(), description: z.string().max(1000).trim().optional(), @@ -66,6 +78,7 @@ export const createBrandSchema = z.object({ logo_media_id: z.string().cuid('Invalid media id').optional(), gallery_media_ids: z.array(z.string().cuid('Invalid media id')).optional().default([]), branches: z.array(createBranchSchema).optional().default([]), + ...socialLinksShape, }); export type CreateBrandInput = z.infer; @@ -76,6 +89,7 @@ export const updateBrandSchema = z.object({ categoryIds: z.array(z.string().cuid('Invalid category id')).optional(), logo_media_id: z.string().cuid('Invalid media id').nullable().optional(), gallery_media_ids: z.array(z.string().cuid('Invalid media id')).optional(), + ...socialLinksShape, }); export type UpdateBrandInput = z.infer; diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index f124d78..ab43275 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +const socialUrlField = z.string().url('Invalid URL').max(500).nullable().optional(); + export const updateMeSchema = z.object({ first_name: z.string().min(2, 'First name must be at least 2 characters').max(50).trim(), last_name: z.string().min(2, 'Last name must be at least 2 characters').max(50).trim(), @@ -21,6 +23,13 @@ export const updateMeSchema = z.object({ .regex(/^\+\d{7,15}$/, 'Phone must be in E.164 format, e.g. +9941234567') .nullable() .optional(), + instagram_url: socialUrlField, + facebook_url: socialUrlField, + youtube_url: socialUrlField, + whatsapp_url: socialUrlField, + linkedin_url: socialUrlField, + x_url: socialUrlField, + website_url: socialUrlField, }); export type UpdateMeInput = z.infer; From c5d26e4600d57cc8eb283a098b2776f88415105d Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Thu, 30 Apr 2026 15:04:48 +0400 Subject: [PATCH 12/25] refactor: enrich brand and service DTOs with avatar, branch, and rating details and update visibility logic for non-active brands --- src/controllers/brand.controller.ts | 5 +- src/services/moderation.service.ts | 119 ++++++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/src/controllers/brand.controller.ts b/src/controllers/brand.controller.ts index 0bc6e7f..147f22c 100644 --- a/src/controllers/brand.controller.ts +++ b/src/controllers/brand.controller.ts @@ -484,10 +484,11 @@ export const getBrandById = async ( return next(err); } - // Non-ACTIVE brands are only visible to their owner + // Non-ACTIVE brands are visible to their owner and admins. if (brand.status !== 'ACTIVE') { const userId = req.user?.sub; - if (!userId || brand.owner_id !== userId) { + const isAdmin = req.user?.type === 'admin'; + if (!isAdmin && (!userId || brand.owner_id !== userId)) { const err: AppError = new Error(); err.statusCode = 404; err.messageKey = 'brand.not_found'; diff --git a/src/services/moderation.service.ts b/src/services/moderation.service.ts index f433ee8..497a614 100644 --- a/src/services/moderation.service.ts +++ b/src/services/moderation.service.ts @@ -31,9 +31,20 @@ export async function getModerationQueue(type?: 'brand' | 'service') { first_name: true, last_name: true, email: true, + avatar_media: { select: { storage_path: true } }, }, }, logo_media: { select: { id: true, storage_path: true } }, + categories: { select: { id: true, key: true } }, + branches: { + select: { + id: true, + name: true, + address1: true, + address2: true, + }, + orderBy: { created_at: 'asc' }, + }, }, orderBy: { created_at: 'asc' }, }), @@ -49,6 +60,7 @@ export async function getModerationQueue(type?: 'brand' | 'service') { service_category: { select: { id: true, key: true } }, price: true, price_type: true, + address: true, created_at: true, updated_at: true, owner: { @@ -57,6 +69,23 @@ export async function getModerationQueue(type?: 'brand' | 'service') { first_name: true, last_name: true, email: true, + avatar_media: { select: { storage_path: true } }, + }, + }, + branch: { + select: { + id: true, + name: true, + address1: true, + address2: true, + brand: { + select: { + id: true, + name: true, + logo_media: { select: { storage_path: true } }, + ratings: { select: { value: true } }, + }, + }, }, }, }, @@ -64,6 +93,20 @@ export async function getModerationQueue(type?: 'brand' | 'service') { }), ]); + const mapOwner = (owner: { + id: string; + first_name: string; + last_name: string; + email: string; + avatar_media?: { storage_path: string } | null; + }) => ({ + id: owner.id, + first_name: owner.first_name, + last_name: owner.last_name, + email: owner.email, + avatar_url: owner.avatar_media ? buildFileUrl(owner.avatar_media.storage_path) : null, + }); + const brandItems = brands.map((b) => ({ id: b.id, type: 'brand' as const, @@ -72,21 +115,52 @@ export async function getModerationQueue(type?: 'brand' | 'service') { description: b.description ?? undefined, status: b.status, logo_url: b.logo_media ? buildFileUrl(b.logo_media.storage_path) : null, - owner: b.owner, + owner: mapOwner(b.owner), + categories: b.categories, + address: b.branches.map((branch) => [branch.address1, branch.address2].filter(Boolean).join(', ')).filter(Boolean).join(' • ') || null, + branches: b.branches, created_at: b.created_at.toISOString(), updated_at: b.updated_at.toISOString(), })); const serviceItems = services.map((s) => ({ + ...(s.branch?.brand + ? { + brand: { + id: s.branch.brand.id, + name: s.branch.brand.name, + logo_url: s.branch.brand.logo_media + ? buildFileUrl(s.branch.brand.logo_media.storage_path) + : null, + rating: + s.branch.brand.ratings.length > 0 + ? s.branch.brand.ratings.reduce((sum, rating) => sum + rating.value, 0) / + s.branch.brand.ratings.length + : null, + rating_count: s.branch.brand.ratings.length, + }, + } + : {}), id: s.id, type: 'service' as const, title: s.title, description: s.description ?? undefined, status: s.status, service_category: s.service_category ?? null, + address: s.branch + ? [s.branch.address1, s.branch.address2].filter(Boolean).join(', ') + : (s.address ?? null), + branch: s.branch + ? { + id: s.branch.id, + name: s.branch.name, + address1: s.branch.address1, + address2: s.branch.address2, + } + : null, price: s.price ? Number(s.price) : null, price_type: s.price_type, - owner: s.owner, + owner: mapOwner(s.owner), created_at: s.created_at.toISOString(), updated_at: s.updated_at.toISOString(), })); @@ -114,6 +188,7 @@ export async function getBrandModerationDetail(brandId: string) { email: true, type: true, created_at: true, + avatar_media: { select: { storage_path: true } }, }, }, logo_media: { select: { id: true, storage_path: true } }, @@ -133,7 +208,7 @@ export async function getBrandModerationDetail(brandId: string) { }, orderBy: { created_at: 'asc' }, }, - categories: { select: { id: true, name: true } }, + categories: { select: { id: true, key: true } }, }, }); @@ -145,7 +220,11 @@ export async function getBrandModerationDetail(brandId: string) { description: brand.description ?? undefined, status: brand.status, rejection_reason: (brand as { rejection_reason?: string | null }).rejection_reason ?? undefined, - owner: brand.owner, + owner: { + ...brand.owner, + avatar_url: brand.owner.avatar_media ? buildFileUrl(brand.owner.avatar_media.storage_path) : null, + avatar_media: undefined, + }, logo_url: brand.logo_media ? buildFileUrl(brand.logo_media.storage_path) : null, gallery: brand.gallery.map((g) => ({ id: g.id, @@ -189,6 +268,7 @@ export async function getServiceModerationDetail(serviceId: string) { email: true, type: true, created_at: true, + avatar_media: { select: { storage_path: true } }, }, }, images: { @@ -205,7 +285,15 @@ export async function getServiceModerationDetail(serviceId: string) { id: true, name: true, address1: true, - brand: { select: { id: true, name: true } }, + address2: true, + brand: { + select: { + id: true, + name: true, + logo_media: { select: { storage_path: true } }, + ratings: { select: { value: true } }, + }, + }, }, }, service_category: { select: { id: true, key: true } }, @@ -226,7 +314,11 @@ export async function getServiceModerationDetail(serviceId: string) { price_type: service.price_type, duration: service.duration ?? undefined, address: service.address ?? undefined, - owner: service.owner, + owner: { + ...service.owner, + avatar_url: service.owner.avatar_media ? buildFileUrl(service.owner.avatar_media.storage_path) : null, + avatar_media: undefined, + }, images: service.images.map((img) => ({ id: img.id, media_id: img.media_id, @@ -238,7 +330,20 @@ export async function getServiceModerationDetail(serviceId: string) { id: service.branch.id, name: service.branch.name, address1: service.branch.address1, - brand: service.branch.brand, + address2: service.branch.address2 ?? undefined, + brand: { + id: service.branch.brand.id, + name: service.branch.brand.name, + logo_url: service.branch.brand.logo_media + ? buildFileUrl(service.branch.brand.logo_media.storage_path) + : null, + rating: + service.branch.brand.ratings.length > 0 + ? service.branch.brand.ratings.reduce((sum, rating) => sum + rating.value, 0) / + service.branch.brand.ratings.length + : null, + rating_count: service.branch.brand.ratings.length, + }, } : null, created_at: service.created_at.toISOString(), From 7b6458c151b33c157a503f31eb45526201a44ccd Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Thu, 30 Apr 2026 18:50:33 +0400 Subject: [PATCH 13/25] feat: add URL protocol validation to social schemas and include express types in tsconfig --- src/schemas/brand.schema.ts | 11 ++++++++++- src/schemas/user.schema.ts | 11 ++++++++++- tsconfig.json | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/schemas/brand.schema.ts b/src/schemas/brand.schema.ts index c76fa96..d98256a 100644 --- a/src/schemas/brand.schema.ts +++ b/src/schemas/brand.schema.ts @@ -59,7 +59,16 @@ export type UpdateBranchInput = z.infer; // ─── Brand ──────────────────────────────────────────────────────────────────── -const socialUrlSchema = z.string().url('Invalid URL').max(500).nullable().optional(); +const socialUrlSchema = z + .string() + .url('Invalid URL') + .max(500) + .refine( + (url) => url.startsWith('https://') || url.startsWith('http://'), + 'URL must use https:// or http://', + ) + .nullable() + .optional(); const socialLinksShape = { instagram_url: socialUrlSchema, diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index ab43275..9906db0 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -1,6 +1,15 @@ import { z } from 'zod'; -const socialUrlField = z.string().url('Invalid URL').max(500).nullable().optional(); +const socialUrlField = z + .string() + .url('Invalid URL') + .max(500) + .refine( + (url) => url.startsWith('https://') || url.startsWith('http://'), + 'URL must use https:// or http://', + ) + .nullable() + .optional(); export const updateMeSchema = z.object({ first_name: z.string().min(2, 'First name must be at least 2 characters').max(50).trim(), diff --git a/tsconfig.json b/tsconfig.json index 6dfab9e..9ac4825 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "commonjs", "lib": ["ES2020"], - "types": ["node"], + "types": ["node", "express"], "rootDir": "./src", "outDir": "./dist", "sourceMap": true, From 0e1306d577cbc86a0e0629599dea7bce7d8206b7 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Thu, 30 Apr 2026 18:58:34 +0400 Subject: [PATCH 14/25] chore: enable files resolution for ts-node in tsconfig --- tsconfig.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index 9ac4825..ccdef44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,9 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, + "ts-node": { + "files": true + }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } From 51405bb7d32a4eb6a6a427ccaba432b45fdb3a71 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Sun, 3 May 2026 15:13:39 +0400 Subject: [PATCH 15/25] feat: implement marketplace facets endpoint and add category filtering to brand listing --- src/controllers/brand.controller.ts | 9 +++- src/controllers/marketplace.controller.ts | 52 +++++++++++++++++++++++ src/routes/v1/index.ts | 2 + src/routes/v1/marketplace.route.ts | 8 ++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/controllers/marketplace.controller.ts create mode 100644 src/routes/v1/marketplace.route.ts diff --git a/src/controllers/brand.controller.ts b/src/controllers/brand.controller.ts index 147f22c..86b42b3 100644 --- a/src/controllers/brand.controller.ts +++ b/src/controllers/brand.controller.ts @@ -647,10 +647,12 @@ export const listPublicBrands = async ( ): Promise => { try { const accountId = typeof req.query['account'] === 'string' ? req.query['account'] : undefined; + const brandCategoryId = typeof req.query['brand_category_id'] === 'string' ? req.query['brand_category_id'] : undefined; if (accountId) { // Public account view: ACTIVE first (newest-first), then CLOSED (newest-first). // PENDING, REJECTED and any other non-public statuses are intentionally excluded. + // brand_category_id filter is not applied in account view. const [activeBrands, closedBrands] = await Promise.all([ prisma.brand.findMany({ where: { owner_id: accountId, status: 'ACTIVE' }, @@ -669,9 +671,12 @@ export const listPublicBrands = async ( return; } - // Default public gallery: active brands only. + // Default public gallery: active brands only, optionally filtered by category. const brands = await prisma.brand.findMany({ - where: { status: 'ACTIVE' }, + where: { + status: 'ACTIVE', + ...(brandCategoryId && { categories: { some: { id: brandCategoryId } } }), + }, select: brandSelect, orderBy: { created_at: 'desc' }, }); diff --git a/src/controllers/marketplace.controller.ts b/src/controllers/marketplace.controller.ts new file mode 100644 index 0000000..730d543 --- /dev/null +++ b/src/controllers/marketplace.controller.ts @@ -0,0 +1,52 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../lib/prisma'; +import { sendSuccess } from '../utils/response'; + +export const getMarketplaceFacets = async ( + _req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const [serviceCategories, brandCategories] = await Promise.all([ + prisma.serviceCategory.findMany({ + where: { services: { some: { status: 'ACTIVE' } } }, + select: { + id: true, + key: true, + _count: { select: { services: { where: { status: 'ACTIVE' } } } }, + }, + orderBy: { key: 'asc' }, + }), + prisma.brandCategory.findMany({ + where: { brands: { some: { status: 'ACTIVE' } } }, + select: { + id: true, + key: true, + _count: { select: { brands: { where: { status: 'ACTIVE' } } } }, + }, + orderBy: { key: 'asc' }, + }), + ]); + + sendSuccess({ + res, + status: 200, + message: 'marketplace.facets', + data: { + service_categories: serviceCategories.map((c) => ({ + id: c.id, + key: c.key, + count: c._count.services, + })), + brand_categories: brandCategories.map((c) => ({ + id: c.id, + key: c.key, + count: c._count.brands, + })), + }, + }); + } catch (err) { + next(err); + } +}; diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index 9d618fa..9379c7f 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -8,6 +8,7 @@ import notificationRoute from './notification.route'; import teamRoute from './team.route'; import serviceRoute from './service.route'; import moderationRoute from './moderation.route'; +import marketplaceRoute from './marketplace.route'; const router: Router = Router(); @@ -20,5 +21,6 @@ router.use('/notifications', notificationRoute); router.use('/', teamRoute); router.use('/', serviceRoute); router.use('/', moderationRoute); +router.use('/', marketplaceRoute); export default router; diff --git a/src/routes/v1/marketplace.route.ts b/src/routes/v1/marketplace.route.ts new file mode 100644 index 0000000..5736ddd --- /dev/null +++ b/src/routes/v1/marketplace.route.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { getMarketplaceFacets } from '../../controllers/marketplace.controller'; + +const router: Router = Router(); + +router.get('/marketplace/facets', getMarketplaceFacets); + +export default router; From b18f2c1822d6e06a85b2d9e6a2d793c6663c2f95 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Sun, 3 May 2026 18:25:27 +0400 Subject: [PATCH 16/25] feat: implement service rating system with database schema, controller, and seeding capabilities --- package.json | 4 +- .../migration.sql | 22 + prisma/schema.prisma | 18 + src/controllers/service.controller.ts | 111 ++- src/lib/seed-marketplace.ts | 749 ++++++++++++++++++ src/routes/v1/service.route.ts | 3 + src/schemas/service.schema.ts | 6 + 7 files changed, 905 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260503000000_add_service_ratings/migration.sql create mode 100644 src/lib/seed-marketplace.ts diff --git a/package.json b/package.json index d6938d9..e0308e8 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "prisma:migrate": "prisma migrate dev", "prisma:migrate:prod": "prisma migrate deploy", "prisma:studio": "prisma studio", - "prisma:reset": "prisma migrate reset" + "prisma:reset": "prisma migrate reset", + "seed:marketplace": "ts-node src/lib/seed-marketplace.ts", + "seed:marketplace:docker": "pnpm prisma migrate deploy && docker compose build app && docker compose up -d app && docker compose exec app node dist/lib/seed-marketplace.js" }, "devDependencies": { "@types/bcryptjs": "^3.0.0", diff --git a/prisma/migrations/20260503000000_add_service_ratings/migration.sql b/prisma/migrations/20260503000000_add_service_ratings/migration.sql new file mode 100644 index 0000000..d969637 --- /dev/null +++ b/prisma/migrations/20260503000000_add_service_ratings/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "ServiceRating" ( + "id" TEXT NOT NULL, + "service_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "value" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ServiceRating_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ServiceRating_service_id_user_id_key" ON "ServiceRating"("service_id", "user_id"); +CREATE INDEX "ServiceRating_service_id_idx" ON "ServiceRating"("service_id"); +CREATE INDEX "ServiceRating_user_id_idx" ON "ServiceRating"("user_id"); + +ALTER TABLE "ServiceRating" +ADD CONSTRAINT "ServiceRating_service_id_fkey" +FOREIGN KEY ("service_id") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ServiceRating" +ADD CONSTRAINT "ServiceRating_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 249cbda..170cf20 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -96,6 +96,7 @@ model User { brand_transfers BrandTransfer[] @relation("BrandTransferRequester") received_transfers BrandTransfer[] @relation("BrandTransferRecipient") brand_ratings BrandRating[] + service_ratings ServiceRating[] notifications Notification[] teams_created Team[] @relation("TeamCreator") team_memberships TeamMember[] @relation("TeamMemberUser") @@ -402,6 +403,7 @@ model Service { branch Branch? @relation(fields: [branch_id], references: [id], onDelete: SetNull) service_category ServiceCategory? @relation(fields: [service_category_id], references: [id], onDelete: SetNull) images ServiceMedia[] + ratings ServiceRating[] @@index([owner_id]) @@index([branch_id]) @@ -409,6 +411,22 @@ model Service { @@index([service_category_id]) } +model ServiceRating { + id String @id @default(cuid()) + service_id String + user_id String + value Int + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + service Service @relation(fields: [service_id], references: [id], onDelete: Cascade) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@unique([service_id, user_id]) + @@index([service_id]) + @@index([user_id]) +} + model ServiceMedia { id String @id @default(cuid()) service_id String diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index 3be03be..1a362d6 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -5,7 +5,7 @@ import { AppError } from '../middlewares/error.middleware'; import { buildFileUrl } from '../services/storage.service'; import { validateAndProcessImage, writeFileToDisk } from '../services/media.service'; import { buildStoragePath, ensureUserStorageDir } from '../services/storage.service'; -import type { CreateServiceInput, UpdateServiceInput } from '../schemas/service.schema'; +import type { CreateServiceInput, UpdateServiceInput, UpsertServiceRatingInput } from '../schemas/service.schema'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -20,6 +20,17 @@ function requireUso(req: Request, next: NextFunction): boolean { return true; } +function requireUcr(req: Request, next: NextFunction): boolean { + if (req.user.type !== 'ucr') { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'errors.forbidden'; + next(err); + return false; + } + return true; +} + function requireOwner(ownerId: string, userId: string, next: NextFunction): boolean { if (ownerId !== userId) { const err: AppError = new Error(); @@ -79,9 +90,29 @@ const serviceSelect = { }, orderBy: { order: 'asc' as const }, }, + ratings: { + select: { + value: true, + user_id: true, + }, + }, } as const; -function mapService(raw: any) { +function roundRating(value: number): number { + return Math.round(value * 10) / 10; +} + +function mapService(raw: any, requesterId?: string) { + const ratingCount = raw.ratings?.length ?? 0; + const ratingAverage = + ratingCount > 0 + ? roundRating(raw.ratings.reduce((sum: number, rating: { value: number }) => sum + rating.value, 0) / ratingCount) + : null; + const myRating = + requesterId + ? raw.ratings?.find((rating: { user_id: string }) => rating.user_id === requesterId)?.value ?? null + : null; + return { id: raw.id, title: raw.title, @@ -102,6 +133,9 @@ function mapService(raw: any) { order: img.order, url: buildFileUrl(img.media.storage_path), })), + rating: ratingAverage, + rating_count: ratingCount, + my_rating: myRating, created_at: raw.created_at.toISOString(), updated_at: raw.updated_at.toISOString(), }; @@ -216,7 +250,7 @@ export const createService = async ( select: serviceSelect, }); - sendSuccess({ res, status: 201, message: 'service.created', data: { service: mapService(service) } }); + sendSuccess({ res, status: 201, message: 'service.created', data: { service: mapService(service, userId) } }); } catch (err) { next(err); } @@ -240,7 +274,7 @@ export const getMyServices = async ( orderBy: { created_at: 'desc' }, }); - sendSuccess({ res, status: 200, message: 'service.list', data: { services: services.map(mapService) } }); + sendSuccess({ res, status: 200, message: 'service.list', data: { services: services.map((service) => mapService(service, userId)) } }); } catch (err) { next(err); } @@ -274,7 +308,7 @@ export const listPublicServices = async ( orderBy: { created_at: 'desc' }, }); - sendSuccess({ res, status: 200, message: 'service.list', data: { services: services.map(mapService) } }); + sendSuccess({ res, status: 200, message: 'service.list', data: { services: services.map((service) => mapService(service)) } }); } catch (err) { next(err); } @@ -310,7 +344,71 @@ export const getServiceById = async ( } } - sendSuccess({ res, status: 200, message: 'service.found', data: { service: mapService(service) } }); + sendSuccess({ res, status: 200, message: 'service.found', data: { service: mapService(service, req.user?.sub) } }); + } catch (err) { + next(err); + } +}; + +// ─── Rating ────────────────────────────────────────────────────────────────── + +export const upsertServiceRating = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUcr(req, next)) return; + + const id = req.params['id'] as string; + const userId = req.user.sub; + const body = req.body as UpsertServiceRatingInput; + + const service = await prisma.service.findUnique({ + where: { id }, + select: { id: true, status: true }, + }); + + if (!service || service.status !== 'ACTIVE') { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); + } + + await prisma.serviceRating.upsert({ + where: { + service_id_user_id: { + service_id: id, + user_id: userId, + }, + }, + update: { value: body.value }, + create: { + service_id: id, + user_id: userId, + value: body.value, + }, + }); + + const updatedService = await prisma.service.findUnique({ + where: { id }, + select: serviceSelect, + }); + + if (!updatedService) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); + } + + sendSuccess({ + res, + status: 200, + message: 'service.rating_saved', + data: { service: mapService(updatedService, userId) }, + }); } catch (err) { next(err); } @@ -679,4 +777,3 @@ export const listServiceCategories = async ( next(err); } }; - diff --git a/src/lib/seed-marketplace.ts b/src/lib/seed-marketplace.ts new file mode 100644 index 0000000..ba187af --- /dev/null +++ b/src/lib/seed-marketplace.ts @@ -0,0 +1,749 @@ +import fs from 'fs/promises'; +import path from 'path'; +import crypto from 'crypto'; +import sharp from 'sharp'; +import prisma from './prisma'; +import { hashPassword } from '../utils/hash'; +import { seedBrandCategories, seedServiceCategories } from './seed-categories'; + +// Resolve STORAGE_DIR relative to this file's location (nodejs-app/src/lib → nodejs-app/) +// so the seed always writes to the same place regardless of where `ts-node` is called from. +const rawStorageDir = process.env['STORAGE_DIR'] ?? 'storage'; +const SEED_STORAGE_DIR = path.isAbsolute(rawStorageDir) + ? rawStorageDir + : path.resolve(__dirname, '../..', rawStorageDir); + +async function ensureSeedUserDir(userId: string): Promise { + await fs.mkdir(path.join(SEED_STORAGE_DIR, 'users', userId), { recursive: true }); +} + +async function deleteSeedUserDir(userId: string): Promise { + await fs.rm(path.join(SEED_STORAGE_DIR, 'users', userId), { recursive: true, force: true }); +} + +function buildSeedStoragePath(userId: string): string { + return path.join(SEED_STORAGE_DIR, 'users', userId, `${crypto.randomUUID()}.webp`); +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BranchDef { + name: string; + address1: string; + phone: string; + email: string; +} + +interface ServiceDef { + title: string; + description: string; + category_key: string; + price?: number; + price_type: 'FIXED' | 'STARTING_FROM' | 'FREE'; + duration: number; +} + +interface UserSeedDef { + email: string; + first_name: string; + last_name: string; + phone: string; + brand_name: string; + brand_description: string; + brand_category_keys: string[]; + image_query: string; + branches: BranchDef[]; + services: ServiceDef[]; + direct_services: ServiceDef[]; +} + +type SeededMarketplaceOwner = { + userId: string; + brandId: string; + serviceIds: string[]; +}; + +function deterministicNumber(seed: string, min: number, max: number): number { + const digest = crypto.createHash('sha256').update(seed).digest('hex'); + const value = parseInt(digest.slice(0, 8), 16); + return min + (value % (max - min + 1)); +} + +function deterministicOrder(items: T[], seed: string, getKey: (item: T) => string): T[] { + return [...items].sort( + (a, b) => + deterministicNumber(`${seed}:${getKey(a)}`, 0, Number.MAX_SAFE_INTEGER - 1) - + deterministicNumber(`${seed}:${getKey(b)}`, 0, Number.MAX_SAFE_INTEGER - 1), + ); +} + +function buildRatingValues(brandId: string, count: number): number[] { + const targetTenths = deterministicNumber(`${brandId}:rating-target`, 39, 49); + const targetTotal = Math.round((targetTenths / 10) * count); + const baseline = 4 * count; + const values = Array.from({ length: count }, () => 4); + const upgradeOrder = deterministicOrder( + Array.from({ length: count }, (_, index) => index), + `${brandId}:rating-upgrade-order`, + (index) => String(index), + ); + const downgradeOrder = deterministicOrder( + Array.from({ length: count }, (_, index) => index), + `${brandId}:rating-downgrade-order`, + (index) => String(index), + ); + + if (targetTotal > baseline) { + const upgrades = Math.min(targetTotal - baseline, count); + for (const index of upgradeOrder.slice(0, upgrades)) { + values[index] = 5; + } + } + + if (targetTotal < baseline) { + const downgrades = Math.min(baseline - targetTotal, count); + for (const index of downgradeOrder.slice(0, downgrades)) { + values[index] = 3; + } + } + + return values; +} + +// ─── Seed data ──────────────────────────────────────────────────────────────── + +const BAKU_BRANCHES: BranchDef[] = [ + { + name: 'Nasimi — İçərişəhər', + address1: 'Hüseyn Cavid pr. 5, Nasimi r.', + phone: '+99412 555 0101', + email: 'nasimi@brand.az', + }, + { + name: 'Sabail — Şəhər Mərkəzi', + address1: 'İstiqlaliyyət küç. 12, Sabail r.', + phone: '+99412 555 0102', + email: 'sabail@brand.az', + }, + { + name: 'Yasamal — Atatürk Prospekti', + address1: 'Atatürk pr. 44, Yasamal r.', + phone: '+99412 555 0103', + email: 'yasamal@brand.az', + }, + { + name: 'Nizami — Köhnə Şəhər', + address1: 'Nizami küç. 88, Nizami r.', + phone: '+99412 555 0104', + email: 'nizami@brand.az', + }, + { + name: 'Binəqədi — Şimali Baku', + address1: 'Binaqadi şossesi 17, Binaqadi r.', + phone: '+99412 555 0105', + email: 'binaqadi@brand.az', + }, + { + name: 'Suraxanı — Sənaye Rayonu', + address1: 'Surakhani küç. 3, Surakhani r.', + phone: '+99412 555 0106', + email: 'surakhani@brand.az', + }, + { + name: 'Sabunçu — Maştağa Yolu', + address1: 'Maştağa küç. 22, Sabunchu r.', + phone: '+99412 555 0107', + email: 'sabunchu@brand.az', + }, + { + name: 'Xəzər — Buzovna Sahili', + address1: 'Buzovna şossesi 9, Xəzər r.', + phone: '+99412 555 0108', + email: 'xazar@brand.az', + }, +]; + +function bakuBranches(domain: string): BranchDef[] { + return BAKU_BRANCHES.map((b) => ({ + ...b, + email: b.email.replace('brand.az', domain), + })); +} + +const USER_PROFILES = [ + ['Aylin', 'Karimova'], + ['Murad', 'Aliyev'], + ['Leyla', 'Mammadova'], + ['Orkhan', 'Hasanov'], + ['Nigar', 'Huseynova'], + ['Rauf', 'Mammadli'], + ['Zahra', 'Ismayilova'], + ['Tural', 'Guliyev'], + ['Fidan', 'Rahimli'], + ['Kamran', 'Abdullayev'], + ['Sabina', 'Rustamova'], + ['Emin', 'Jafarov'], + ['Lala', 'Asadova'], + ['Farid', 'Valiyev'], + ['Aysel', 'Nabiyeva'], + ['Samir', 'Hajiyev'], + ['Gunel', 'Bayramova'], + ['Elvin', 'Mustafayev'], + ['Narmin', 'Taghiyeva'], + ['Rashad', 'Suleymanov'], +] as const; + +const BRAND_BLUEPRINTS = [ + { name: 'Luna Beauty Studio', categoryKeys: ['beauty_wellness', 'fashion_apparel'], query: 'beauty,salon,spa', domain: 'luna.az', focus: 'luxury beauty, hair design and skin rituals' }, + { name: 'Nova Wellness Club', categoryKeys: ['fitness_sports', 'health_pharmacy'], query: 'fitness,gym,workout', domain: 'nova.az', focus: 'personal training, recovery and healthy routines' }, + { name: 'DentaCare House', categoryKeys: ['health_pharmacy'], query: 'dental,clinic,health', domain: 'dentacare.az', focus: 'digital dentistry and calm patient care' }, + { name: 'MentorLab Consulting', categoryKeys: ['education_training'], query: 'business,office,consulting', domain: 'mentorlab.az', focus: 'career, leadership and business advisory' }, + { name: 'Aura Nail & Skin Bar', categoryKeys: ['beauty_wellness'], query: 'nails,skincare,spa', domain: 'aura.az', focus: 'nail artistry, skincare and spa maintenance' }, + { name: 'Pulse Performance Gym', categoryKeys: ['fitness_sports'], query: 'gym,trainer,athlete', domain: 'pulse.az', focus: 'strength, conditioning and athletic development' }, + { name: 'Bright Smile Clinic', categoryKeys: ['health_pharmacy', 'beauty_wellness'], query: 'dentist,smile,clinic', domain: 'brightsmile.az', focus: 'cosmetic dentistry and preventive oral care' }, + { name: 'Focus Learning Hub', categoryKeys: ['education_training'], query: 'education,classroom,workshop', domain: 'focus.az', focus: 'professional learning and exam readiness' }, + { name: 'FrameCraft Studio', categoryKeys: ['entertainment_media', 'technology_electronics'], query: 'photography,studio,camera', domain: 'framecraft.az', focus: 'photography, brand content and visual storytelling' }, + { name: 'ZenMotion Therapy', categoryKeys: ['health_pharmacy', 'fitness_sports'], query: 'physiotherapy,massage,wellness', domain: 'zenmotion.az', focus: 'massage, mobility and rehabilitation support' }, + { name: 'StyleForge Atelier', categoryKeys: ['fashion_apparel'], query: 'fashion,tailor,atelier', domain: 'styleforge.az', focus: 'tailoring, styling and wardrobe consulting' }, + { name: 'HomeNest Design', categoryKeys: ['home_furniture'], query: 'interior,home,design', domain: 'homenest.az', focus: 'interior planning and home styling' }, + { name: 'TechCare Lab', categoryKeys: ['technology_electronics'], query: 'technology,repair,electronics', domain: 'techcare.az', focus: 'device support, smart setup and tech coaching' }, + { name: 'Baku Kids Academy', categoryKeys: ['education_training', 'entertainment_media'], query: 'kids,learning,creative', domain: 'kidsacademy.az', focus: 'children workshops, tutoring and creative sessions' }, + { name: 'FitFuel Nutrition', categoryKeys: ['fitness_sports', 'health_pharmacy'], query: 'nutrition,healthy,meal', domain: 'fitfuel.az', focus: 'nutrition planning and body composition coaching' }, + { name: 'Eventory Creative', categoryKeys: ['entertainment_media'], query: 'event,creative,party', domain: 'eventory.az', focus: 'events, workshops and creative production' }, + { name: 'TravelMind Baku', categoryKeys: ['travel_hospitality'], query: 'travel,hotel,city', domain: 'travelmind.az', focus: 'local experiences and hospitality planning' }, + { name: 'GreenShelf Flowers', categoryKeys: ['home_furniture', 'beauty_wellness'], query: 'flowers,plants,florist', domain: 'greenshelf.az', focus: 'floral design, plant care and decor' }, + { name: 'LegalWay Advisory', categoryKeys: ['education_training'], query: 'legal,office,documents', domain: 'legalway.az', focus: 'legal document guidance and compliance consulting' }, + { name: 'SoundRoom Academy', categoryKeys: ['education_training', 'entertainment_media'], query: 'music,studio,lesson', domain: 'soundroom.az', focus: 'music lessons, recording and performance coaching' }, +] as const; + +const BRAND_SERVICE_TEMPLATES: Record[]> = { + beauty_wellness: [ + { title: 'Signature Haircut & Styling', category_key: 'haircut_styling', price: 45, price_type: 'FIXED', duration: 45 }, + { title: 'Gloss Colour Refresh', category_key: 'haircut_styling', price: 90, price_type: 'STARTING_FROM', duration: 100 }, + { title: 'Keratin Smooth Treatment', category_key: 'haircut_styling', price: 140, price_type: 'FIXED', duration: 120 }, + { title: 'Luxury Spa Manicure', category_key: 'nail_care', price: 30, price_type: 'FIXED', duration: 40 }, + { title: 'Hard Gel Nail Extensions', category_key: 'nail_care', price: 65, price_type: 'FIXED', duration: 75 }, + { title: 'Deep-Cleanse Facial', category_key: 'facial_treatment', price: 70, price_type: 'FIXED', duration: 60 }, + { title: 'Hyaluronic Hydration Facial', category_key: 'facial_treatment', price: 65, price_type: 'FIXED', duration: 45 }, + { title: 'Collagen Lift Facial', category_key: 'facial_treatment', price: 95, price_type: 'FIXED', duration: 65 }, + { title: 'Back & Shoulder Massage', category_key: 'massage_therapy', price: 55, price_type: 'FIXED', duration: 60 }, + { title: 'Aromatherapy Swedish Massage', category_key: 'massage_therapy', price: 85, price_type: 'FIXED', duration: 90 }, + ], + fitness_sports: [ + { title: '1-on-1 Personal Training', category_key: 'personal_training', price: 55, price_type: 'FIXED', duration: 60 }, + { title: 'Monthly Coaching Programme', category_key: 'personal_training', price: 380, price_type: 'FIXED', duration: 60 }, + { title: 'HIIT Metabolic Class', category_key: 'personal_training', price: 22, price_type: 'FIXED', duration: 45 }, + { title: 'Strength Conditioning Session', category_key: 'personal_training', price: 60, price_type: 'FIXED', duration: 60 }, + { title: 'Mobility & Stretch Assessment', category_key: 'personal_training', price: 40, price_type: 'FIXED', duration: 40 }, + { title: 'Sports Recovery Massage', category_key: 'massage_therapy', price: 55, price_type: 'FIXED', duration: 60 }, + { title: 'Trigger Point Release', category_key: 'massage_therapy', price: 58, price_type: 'FIXED', duration: 45 }, + { title: 'Progress Photo Session', category_key: 'photo_session', price: 110, price_type: 'FIXED', duration: 60 }, + { title: 'Body Composition Review', category_key: 'consulting', price: 35, price_type: 'FIXED', duration: 30 }, + { title: 'Nutrition Goal Review', category_key: 'consulting', price: 50, price_type: 'FIXED', duration: 45 }, + ], + health_pharmacy: [ + { title: 'Comprehensive Check-Up', category_key: 'dental_care', price: 35, price_type: 'FIXED', duration: 30 }, + { title: 'Professional Scale & Polish', category_key: 'dental_care', price: 55, price_type: 'FIXED', duration: 45 }, + { title: 'LED Teeth Whitening', category_key: 'dental_care', price: 160, price_type: 'FIXED', duration: 60 }, + { title: 'Composite Filling', category_key: 'dental_care', price: 65, price_type: 'STARTING_FROM', duration: 45 }, + { title: 'Root Canal Treatment', category_key: 'dental_care', price: 220, price_type: 'STARTING_FROM', duration: 90 }, + { title: 'Implant Planning Consultation', category_key: 'dental_care', price_type: 'FREE', duration: 30 }, + { title: 'Porcelain Crown Fitting', category_key: 'dental_care', price: 270, price_type: 'STARTING_FROM', duration: 90 }, + { title: 'Orthodontic Assessment', category_key: 'dental_care', price_type: 'FREE', duration: 40 }, + { title: 'Oral Hygiene Coaching', category_key: 'consulting', price: 40, price_type: 'FIXED', duration: 30 }, + { title: 'Post-Treatment Review', category_key: 'consulting', price_type: 'FREE', duration: 20 }, + ], + education_training: [ + { title: 'Career Strategy Session', category_key: 'consulting', price: 80, price_type: 'FIXED', duration: 60 }, + { title: 'Business Plan Review', category_key: 'consulting', price: 110, price_type: 'FIXED', duration: 90 }, + { title: 'CV & LinkedIn Optimisation', category_key: 'consulting', price: 55, price_type: 'FIXED', duration: 45 }, + { title: 'Interview Preparation', category_key: 'consulting', price: 65, price_type: 'FIXED', duration: 60 }, + { title: 'Startup Pitch Coaching', category_key: 'consulting', price: 130, price_type: 'FIXED', duration: 90 }, + { title: 'Leadership Coaching', category_key: 'consulting', price: 160, price_type: 'FIXED', duration: 90 }, + { title: 'Team Dynamics Consulting', category_key: 'consulting', price: 220, price_type: 'STARTING_FROM', duration: 120 }, + { title: 'Go-to-Market Workshop', category_key: 'consulting', price: 105, price_type: 'FIXED', duration: 60 }, + { title: 'Financial Planning Session', category_key: 'consulting', price: 85, price_type: 'FIXED', duration: 60 }, + { title: 'Digital Readiness Audit', category_key: 'consulting', price: 160, price_type: 'STARTING_FROM', duration: 90 }, + ], + default: [ + { title: 'Introductory Consultation', category_key: 'consulting', price: 45, price_type: 'FIXED', duration: 45 }, + { title: 'Personalised Planning Session', category_key: 'consulting', price: 75, price_type: 'FIXED', duration: 60 }, + { title: 'Premium Advisory Package', category_key: 'consulting', price: 150, price_type: 'STARTING_FROM', duration: 90 }, + { title: 'Creative Photo Session', category_key: 'photo_session', price: 120, price_type: 'FIXED', duration: 60 }, + { title: 'Skill-Building Workshop', category_key: 'consulting', price: 90, price_type: 'FIXED', duration: 75 }, + { title: 'Follow-Up Review', category_key: 'consulting', price: 35, price_type: 'FIXED', duration: 30 }, + { title: 'Express Problem Solving', category_key: 'consulting', price: 55, price_type: 'FIXED', duration: 40 }, + { title: 'Implementation Roadmap', category_key: 'consulting', price: 180, price_type: 'STARTING_FROM', duration: 120 }, + { title: 'Portfolio Polish Session', category_key: 'photo_session', price: 100, price_type: 'FIXED', duration: 60 }, + { title: 'Monthly Maintenance Check', category_key: 'consulting', price: 70, price_type: 'FIXED', duration: 50 }, + ], +}; + +const DIRECT_SERVICE_POOL: Omit[] = [ + { title: 'Private Career Mentorship', category_key: 'consulting', price: 70, price_type: 'FIXED', duration: 60 }, + { title: 'At-Home Styling Visit', category_key: 'haircut_styling', price: 65, price_type: 'STARTING_FROM', duration: 75 }, + { title: 'Freelance Portrait Session', category_key: 'photo_session', price: 95, price_type: 'FIXED', duration: 60 }, + { title: 'Mobile Recovery Massage', category_key: 'massage_therapy', price: 75, price_type: 'FIXED', duration: 60 }, + { title: 'Nutrition Habit Audit', category_key: 'consulting', price: 45, price_type: 'FIXED', duration: 45 }, + { title: 'Personal Training Trial', category_key: 'personal_training', price: 40, price_type: 'FIXED', duration: 45 }, + { title: 'Remote Business Advisory Call', category_key: 'consulting', price: 85, price_type: 'FIXED', duration: 60 }, + { title: 'Express Nail Care Visit', category_key: 'nail_care', price: 35, price_type: 'FIXED', duration: 40 }, + { title: 'Skin Routine Review', category_key: 'facial_treatment', price: 50, price_type: 'FIXED', duration: 45 }, + { title: 'Dental Second Opinion', category_key: 'dental_care', price_type: 'FREE', duration: 30 }, +]; + +function richDescription(title: string, ownerFocus: string, mode: 'brand' | 'direct'): string { + const intro = mode === 'brand' + ? `${title} is delivered inside a fully equipped branch with the same standards, tools, and service flow used across the brand network.` + : `${title} is offered directly by the service owner, giving customers a personal appointment flow without needing to choose a brand branch.`; + + return [ + `

${intro}

`, + `

The session is designed around ${ownerFocus}. Before starting, the owner confirms goals, timing, and any special preferences so the experience feels prepared rather than generic.

`, + '
    ', + '
  • Clear consultation and expectation setting before the work begins.
  • ', + '
  • Professional tools, clean workflow, and customer-friendly pacing.
  • ', + '
  • Practical aftercare or next-step guidance at the end of the appointment.
  • ', + '
', + '

This mock description intentionally uses paragraphs and lists so rich-text rendering can be tested realistically in service detail screens.

', + ].join(''); +} + +function pickBrandServiceTemplates(categoryKeys: readonly string[], offset: number): Omit[] { + const primary = categoryKeys[0] ?? 'default'; + const templates = BRAND_SERVICE_TEMPLATES[primary] ?? BRAND_SERVICE_TEMPLATES.default; + return templates.map((template, index) => ({ + ...template, + title: `${template.title}${offset > 0 ? ` ${offset + 1}` : ''}`, + price: template.price !== undefined ? template.price + ((offset + index) % 4) * 5 : undefined, + duration: template.duration + ((offset + index) % 3) * 5, + })); +} + +function pickDirectServices(ownerIndex: number, ownerFocus: string): ServiceDef[] { + const count = 2 + (ownerIndex % 4); + return Array.from({ length: count }, (_, index) => { + const template = DIRECT_SERVICE_POOL[(ownerIndex * 3 + index) % DIRECT_SERVICE_POOL.length]; + return { + ...template, + title: `${template.title} ${ownerIndex + 1}.${index + 1}`, + price: template.price !== undefined ? template.price + ((ownerIndex + index) % 3) * 10 : undefined, + description: richDescription(template.title, ownerFocus, 'direct'), + }; + }); +} + +function buildSeedUsers(): UserSeedDef[] { + return USER_PROFILES.map(([firstName, lastName], index) => { + const blueprint = BRAND_BLUEPRINTS[index % BRAND_BLUEPRINTS.length]; + const brandName = index < BRAND_BLUEPRINTS.length + ? blueprint.name + : `${blueprint.name} ${index + 1}`; + const branchDomain = blueprint.domain.replace('.az', `${index + 1}.az`); + const services = pickBrandServiceTemplates(blueprint.categoryKeys, Math.floor(index / BRAND_BLUEPRINTS.length)).map((service) => ({ + ...service, + description: richDescription(service.title, blueprint.focus, 'brand'), + })); + + return { + email: `marketplace-uso-${index + 1}@reziphay.test`, + first_name: firstName, + last_name: lastName, + phone: `+99450111${String(index + 1).padStart(4, '0')}`, + brand_name: brandName, + brand_description: [ + `

${brandName} is a marketplace-ready brand focused on ${blueprint.focus} across Baku.

`, + '

The seeded profile includes multiple branches, realistic media, service variety, and review data so UCR discovery screens can be tested with richer content.

', + ].join(''), + brand_category_keys: [...blueprint.categoryKeys], + image_query: blueprint.query, + branches: bakuBranches(branchDomain), + services, + direct_services: pickDirectServices(index, blueprint.focus), + }; + }); +} + +const SEED_USERS: UserSeedDef[] = buildSeedUsers(); + +// ─── Image generation ───────────────────────────────────────────────────────── + + +const SERVICE_CATEGORY_QUERIES: Record = { + haircut_styling: 'hair,salon,haircut', + nail_care: 'nails,manicure,beauty', + facial_treatment: 'spa,facial,skincare', + massage_therapy: 'massage,spa,relaxation', + personal_training: 'fitness,workout,gym', + photo_session: 'photography,portrait,camera', + dental_care: 'dental,teeth,smile', + consulting: 'business,meeting,office', +}; + +function queryToLock(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; + } + return (Math.abs(hash) % 1000) + 1; +} + +async function fetchThematicImage(width: number, height: number, query: string, lockKey: string): Promise { + const lock = queryToLock(lockKey); + const url = `https://loremflickr.com/${width}/${height}/${encodeURIComponent(query)}?lock=${lock}`; + const response = await fetch(url, { redirect: 'follow' }); + if (!response.ok) throw new Error(`Image fetch failed (${response.status}) for query "${query}"`); + const arrayBuffer = await response.arrayBuffer(); + return sharp(Buffer.from(arrayBuffer)).webp({ quality: 80 }).toBuffer(); +} + +// ─── Media helpers ──────────────────────────────────────────────────────────── + +type SeedMediaKind = 'other' | 'branch_cover' | 'service_image'; + +async function createMediaRecord( + userId: string, + buffer: Buffer, + name: string, + kind: SeedMediaKind, + width: number, + height: number, +): Promise { + await ensureSeedUserDir(userId); + const storagePath = buildSeedStoragePath(userId); + await fs.writeFile(storagePath, buffer); + const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); + const media = await prisma.media.create({ + data: { + name, + format: 'webp', + mime_type: 'image/webp', + size: buffer.length, + kind, + storage_path: storagePath, + checksum, + is_public: true, + owner_id: userId, + width, + height, + }, + select: { id: true }, + }); + return media.id; +} + +// ─── Per-user seeding ───────────────────────────────────────────────────────── + +async function seedUser( + userDef: UserSeedDef, + categoryMaps: { brand: Map; service: Map }, +): Promise { + const hashedPw = await hashPassword('Password123'); + + // Clean up any existing seed data for this email + const existing = await prisma.user.findUnique({ + where: { email: userDef.email }, + select: { id: true }, + }); + if (existing) { + await deleteSeedUserDir(existing.id); + await prisma.user.delete({ where: { id: existing.id } }); + } + + const user = await prisma.user.create({ + data: { + first_name: userDef.first_name, + last_name: userDef.last_name, + birthday: new Date('1990-01-01'), + country: 'AZ', + email: userDef.email, + phone: userDef.phone, + hashed_password: hashedPw, + type: 'uso', + email_verified: true, + phone_verified: true, + }, + select: { id: true }, + }); + const userId = user.id; + const serviceIds: string[] = []; + + // Brand logo (1:1) + const logoBuffer = await fetchThematicImage(512, 512, userDef.image_query, `${userDef.brand_name}-logo`); + const logoMediaId = await createMediaRecord( + userId, + logoBuffer, + `${userDef.brand_name} Logo`, + 'other', + 512, + 512, + ); + + // Brand gallery (16:9) + const galleryBuffer = await fetchThematicImage(1280, 720, userDef.image_query, `${userDef.brand_name}-gallery`); + const galleryMediaId = await createMediaRecord( + userId, + galleryBuffer, + `${userDef.brand_name} Gallery`, + 'other', + 1280, + 720, + ); + + const brandCatIds = userDef.brand_category_keys + .map((key) => categoryMaps.brand.get(key)) + .filter((id): id is string => id !== undefined); + + const brand = await prisma.brand.create({ + data: { + name: userDef.brand_name, + description: userDef.brand_description, + owner_id: userId, + status: 'ACTIVE', + logo_media_id: logoMediaId, + categories: { connect: brandCatIds.map((id) => ({ id })) }, + gallery: { create: [{ media_id: galleryMediaId, order: 0 }] }, + }, + select: { id: true }, + }); + const brandId = brand.id; + + // Branches + const branchIds: string[] = []; + for (const branchDef of userDef.branches) { + const coverBuffer = await fetchThematicImage(1280, 720, userDef.image_query, `${userDef.brand_name}-${branchDef.name}`); + const coverMediaId = await createMediaRecord( + userId, + coverBuffer, + `${branchDef.name} Cover`, + 'branch_cover', + 1280, + 720, + ); + + const branch = await prisma.branch.create({ + data: { + brand_id: brandId, + name: branchDef.name, + address1: branchDef.address1, + phone: branchDef.phone, + email: branchDef.email, + opening: '09:00', + closing: '21:00', + cover_media_id: coverMediaId, + }, + select: { id: true }, + }); + + await prisma.team.create({ + data: { + branch_id: branch.id, + created_by_user_id: userId, + members: { + create: { + user_id: userId, + invited_by_user_id: userId, + role: 'OWNER', + status: 'ACCEPTED', + }, + }, + }, + }); + + branchIds.push(branch.id); + } + + async function createSeedService(svcDef: ServiceDef, index: number, branchId: string | null) { + const catId = categoryMaps.service.get(svcDef.category_key); + if (!catId) { + console.warn( + ` [WARN] Service category '${svcDef.category_key}' not found — skipping '${svcDef.title}'`, + ); + return; + } + + const svcQuery = SERVICE_CATEGORY_QUERIES[svcDef.category_key] ?? userDef.image_query; + const imageLock = branchId + ? `${userDef.brand_name}-${svcDef.title}` + : `${userDef.email}-direct-${svcDef.title}`; + const imgBuffer = await fetchThematicImage(1280, 720, svcQuery, imageLock); + const imgMediaId = await createMediaRecord( + userId, + imgBuffer, + `${svcDef.title} Image`, + 'service_image', + 1280, + 720, + ); + + const service = await prisma.service.create({ + data: { + title: svcDef.title, + description: svcDef.description, + owner_id: userId, + branch_id: branchId, + service_category_id: catId, + price: svcDef.price ?? null, + price_type: svcDef.price_type, + duration: svcDef.duration, + address: branchId ? null : userDef.branches[index % userDef.branches.length]?.address1, + status: 'ACTIVE', + images: { create: [{ media_id: imgMediaId, order: 0 }] }, + }, + select: { id: true }, + }); + serviceIds.push(service.id); + } + + // Brand services distributed across branches + for (let i = 0; i < userDef.services.length; i++) { + const svcDef = userDef.services[i]; + const branchId = branchIds[i % branchIds.length]; + await createSeedService(svcDef, i, branchId); + } + + // Direct user services without a brand/branch context + for (let i = 0; i < userDef.direct_services.length; i++) { + await createSeedService(userDef.direct_services[i], i, null); + } + + console.log( + ` ✓ ${userDef.first_name} ${userDef.last_name} → ${userDef.brand_name}` + + ` | ${userDef.branches.length} branches | ${userDef.services.length} brand services` + + ` | ${userDef.direct_services.length} direct services`, + ); + + return { userId, brandId, serviceIds }; +} + +async function seedBrandRatings(records: SeededMarketplaceOwner[]): Promise { + const ratings = records.flatMap((record) => { + const raters = deterministicOrder( + records.filter((r) => r.userId !== record.userId), + `${record.brandId}:raters`, + (rater) => rater.userId, + ); + const count = Math.min( + raters.length, + deterministicNumber(`${record.brandId}:rating-count`, 5, 18), + ); + if (count === 0) return []; + + const values = buildRatingValues(record.brandId, count); + + return raters.slice(0, count).map((rater, ratingIndex) => ({ + brand_id: record.brandId, + user_id: rater.userId, + value: values[ratingIndex] ?? 4, + })); + }); + + if (ratings.length === 0) return 0; + + process.stdout.write(`Seeding brand ratings (${ratings.length}) ... `); + for (let i = 0; i < ratings.length; i += 100) { + await prisma.brandRating.createMany({ + data: ratings.slice(i, i + 100), + skipDuplicates: true, + }); + } + console.log('✓'); + + return ratings.length; +} + +async function seedServiceRatings(records: SeededMarketplaceOwner[]): Promise { + const serviceRecords = records.flatMap((record) => + record.serviceIds.map((serviceId) => ({ + serviceId, + ownerId: record.userId, + })), + ); + + const ratings = serviceRecords.flatMap((service) => { + const raters = deterministicOrder( + records.filter((r) => r.userId !== service.ownerId), + `${service.serviceId}:raters`, + (rater) => rater.userId, + ); + const count = Math.min( + raters.length, + deterministicNumber(`${service.serviceId}:rating-count`, 3, 16), + ); + if (count === 0) return []; + + const values = buildRatingValues(service.serviceId, count); + + return raters.slice(0, count).map((rater, ratingIndex) => ({ + service_id: service.serviceId, + user_id: rater.userId, + value: values[ratingIndex] ?? 4, + })); + }); + + if (ratings.length === 0) return 0; + + process.stdout.write(`Seeding service ratings (${ratings.length}) ... `); + for (let i = 0; i < ratings.length; i += 250) { + await prisma.serviceRating.createMany({ + data: ratings.slice(i, i + 250), + skipDuplicates: true, + }); + } + console.log('✓'); + + return ratings.length; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log('Seeding marketplace mock data...\n'); + + await seedBrandCategories(); + await seedServiceCategories(); + + const [brandCategories, serviceCategories] = await Promise.all([ + prisma.brandCategory.findMany({ select: { id: true, key: true } }), + prisma.serviceCategory.findMany({ select: { id: true, key: true } }), + ]); + + const categoryMaps = { + brand: new Map(brandCategories.map((c) => [c.key, c.id])), + service: new Map(serviceCategories.map((c) => [c.key, c.id])), + }; + + const seededRecords: SeededMarketplaceOwner[] = []; + + for (const userDef of SEED_USERS) { + process.stdout.write(`Seeding: ${userDef.email} ... `); + seededRecords.push(await seedUser(userDef, categoryMaps)); + } + + const brandRatingCount = await seedBrandRatings(seededRecords); + const serviceRatingCount = await seedServiceRatings(seededRecords); + + const totals = { + users: SEED_USERS.length, + brands: SEED_USERS.length, + branches: SEED_USERS.length * 8, + brandServices: SEED_USERS.reduce((sum, u) => sum + u.services.length, 0), + directServices: SEED_USERS.reduce((sum, u) => sum + u.direct_services.length, 0), + brandRatings: brandRatingCount, + serviceRatings: serviceRatingCount, + }; + + console.log( + `\nDone. Created ${totals.users} users, ${totals.brands} brands,` + + ` ${totals.branches} branches, ${totals.brandServices} brand services,` + + ` ${totals.directServices} direct user services, ${totals.brandRatings} brand ratings,` + + ` ${totals.serviceRatings} service ratings.`, + ); +} + +main() + .catch((err: unknown) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/routes/v1/service.route.ts b/src/routes/v1/service.route.ts index 466ccfe..c70ccbe 100644 --- a/src/routes/v1/service.route.ts +++ b/src/routes/v1/service.route.ts @@ -14,12 +14,14 @@ import { resumeService, archiveService, unarchiveService, + upsertServiceRating, } from '../../controllers/service.controller'; import { authenticate } from '../../middlewares/auth.middleware'; import { validate } from '../../middlewares/validate.middleware'; import { AppError } from '../../middlewares/error.middleware'; import { createServiceSchema, + upsertServiceRatingSchema, updateServiceSchema, } from '../../schemas/service.schema'; @@ -65,5 +67,6 @@ router.post('/services/:id/pause', authenticate, pauseService); router.post('/services/:id/resume', authenticate, resumeService); router.post('/services/:id/archive', authenticate, archiveService); router.post('/services/:id/unarchive', authenticate, unarchiveService); +router.put('/services/:id/rating', authenticate, validate(upsertServiceRatingSchema), upsertServiceRating); export default router; diff --git a/src/schemas/service.schema.ts b/src/schemas/service.schema.ts index 5ceed22..0fccc14 100644 --- a/src/schemas/service.schema.ts +++ b/src/schemas/service.schema.ts @@ -36,3 +36,9 @@ export const rejectServiceSchema = z.object({ }); export type RejectServiceInput = z.infer; + +export const upsertServiceRatingSchema = z.object({ + value: z.number().int().min(1).max(5), +}); + +export type UpsertServiceRatingInput = z.infer; From d3d85662bcf5f05a625cdde96502d5eb180f47e9 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Sun, 3 May 2026 21:48:34 +0400 Subject: [PATCH 17/25] feat: add owner_id and direct_only filtering to service retrieval query --- src/controllers/service.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index 1a362d6..3aee855 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -290,6 +290,8 @@ export const listPublicServices = async ( try { const service_category_id = typeof req.query['service_category_id'] === 'string' ? req.query['service_category_id'] : undefined; const branch_id = typeof req.query['branch_id'] === 'string' ? req.query['branch_id'] : undefined; + const owner_id = typeof req.query['owner_id'] === 'string' ? req.query['owner_id'] : undefined; + const direct_only = req.query['direct_only'] === 'true'; const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined; const services = await prisma.service.findMany({ @@ -297,6 +299,8 @@ export const listPublicServices = async ( status: 'ACTIVE', ...(service_category_id && { service_category_id }), ...(branch_id && { branch_id }), + ...(owner_id && { owner_id }), + ...(direct_only && { branch_id: null }), ...(q && { OR: [ { title: { contains: q, mode: 'insensitive' } }, From d408acb5abc18105ae2bad40a214f5306a91d03b Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Mon, 4 May 2026 01:28:23 +0400 Subject: [PATCH 18/25] feat: implement user favorite functionality for brands and services with database schema and API routes --- .../migration.sql | 28 ++ prisma/schema.prisma | 32 ++ src/controllers/favorite.controller.ts | 360 ++++++++++++++++++ src/lib/seed-marketplace.ts | 244 ++++++------ src/routes/v1/favorite.route.ts | 19 + src/routes/v1/index.ts | 2 + 6 files changed, 578 insertions(+), 107 deletions(-) create mode 100644 prisma/migrations/20260504000000_add_favorites/migration.sql create mode 100644 src/controllers/favorite.controller.ts create mode 100644 src/routes/v1/favorite.route.ts diff --git a/prisma/migrations/20260504000000_add_favorites/migration.sql b/prisma/migrations/20260504000000_add_favorites/migration.sql new file mode 100644 index 0000000..a77fa13 --- /dev/null +++ b/prisma/migrations/20260504000000_add_favorites/migration.sql @@ -0,0 +1,28 @@ +CREATE TABLE "FavoriteBrand" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "brand_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "FavoriteBrand_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "FavoriteService" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "service_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "FavoriteService_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "FavoriteBrand_user_id_brand_id_key" ON "FavoriteBrand"("user_id", "brand_id"); +CREATE INDEX "FavoriteBrand_user_id_created_at_idx" ON "FavoriteBrand"("user_id", "created_at"); +CREATE INDEX "FavoriteBrand_brand_id_idx" ON "FavoriteBrand"("brand_id"); + +CREATE UNIQUE INDEX "FavoriteService_user_id_service_id_key" ON "FavoriteService"("user_id", "service_id"); +CREATE INDEX "FavoriteService_user_id_created_at_idx" ON "FavoriteService"("user_id", "created_at"); +CREATE INDEX "FavoriteService_service_id_idx" ON "FavoriteService"("service_id"); + +ALTER TABLE "FavoriteBrand" ADD CONSTRAINT "FavoriteBrand_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FavoriteBrand" ADD CONSTRAINT "FavoriteBrand_brand_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "Brand"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FavoriteService" ADD CONSTRAINT "FavoriteService_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FavoriteService" ADD CONSTRAINT "FavoriteService_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 170cf20..6e75080 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -97,6 +97,8 @@ model User { received_transfers BrandTransfer[] @relation("BrandTransferRecipient") brand_ratings BrandRating[] service_ratings ServiceRating[] + favorite_brands FavoriteBrand[] + favorite_services FavoriteService[] notifications Notification[] teams_created Team[] @relation("TeamCreator") team_memberships TeamMember[] @relation("TeamMemberUser") @@ -189,6 +191,7 @@ model Brand { branches Branch[] categories BrandCategory[] ratings BrandRating[] + favorites FavoriteBrand[] transfers BrandTransfer[] @@index([owner_id]) @@ -404,6 +407,7 @@ model Service { service_category ServiceCategory? @relation(fields: [service_category_id], references: [id], onDelete: SetNull) images ServiceMedia[] ratings ServiceRating[] + favorites FavoriteService[] @@index([owner_id]) @@index([branch_id]) @@ -427,6 +431,34 @@ model ServiceRating { @@index([user_id]) } +model FavoriteBrand { + id String @id @default(cuid()) + user_id String + brand_id String + created_at DateTime @default(now()) + + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + brand Brand @relation(fields: [brand_id], references: [id], onDelete: Cascade) + + @@unique([user_id, brand_id]) + @@index([user_id, created_at]) + @@index([brand_id]) +} + +model FavoriteService { + id String @id @default(cuid()) + user_id String + service_id String + created_at DateTime @default(now()) + + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + service Service @relation(fields: [service_id], references: [id], onDelete: Cascade) + + @@unique([user_id, service_id]) + @@index([user_id, created_at]) + @@index([service_id]) +} + model ServiceMedia { id String @id @default(cuid()) service_id String diff --git a/src/controllers/favorite.controller.ts b/src/controllers/favorite.controller.ts new file mode 100644 index 0000000..c323a9e --- /dev/null +++ b/src/controllers/favorite.controller.ts @@ -0,0 +1,360 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../lib/prisma'; +import { AppError } from '../middlewares/error.middleware'; +import { buildFileUrl } from '../services/storage.service'; +import { sendSuccess } from '../utils/response'; + +function requireUcr(req: Request, next: NextFunction): boolean { + if (req.user.type !== 'ucr') { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'errors.forbidden'; + next(err); + return false; + } + return true; +} + +function roundRating(value: number): number { + return Math.round(value * 10) / 10; +} + +const branchSelect = { + id: true, + brand_id: true, + name: true, + description: true, + address1: true, + address2: true, + phone: true, + email: true, + is_24_7: true, + opening: true, + closing: true, + cover_media_id: true, + created_at: true, + updated_at: true, + breaks: { select: { id: true, start: true, end: true } }, + cover_media: { select: { id: true, storage_path: true } }, +} as const; + +const brandSelect = { + id: true, + name: true, + description: true, + status: true, + owner_id: true, + logo_media_id: true, + instagram_url: true, + facebook_url: true, + youtube_url: true, + whatsapp_url: true, + linkedin_url: true, + x_url: true, + website_url: true, + created_at: true, + updated_at: true, + categories: { select: { id: true, key: true } }, + logo_media: { select: { id: true, storage_path: true } }, + gallery: { + select: { + id: true, + media_id: true, + order: true, + media: { select: { id: true, storage_path: true } }, + }, + orderBy: { order: 'asc' as const }, + }, + branches: { select: branchSelect }, + ratings: { + select: { + value: true, + user_id: true, + }, + }, +} as const; + +const serviceSelect = { + id: true, + title: true, + description: true, + owner_id: true, + branch_id: true, + service_category_id: true, + service_category: { select: { id: true, key: true } }, + price: true, + price_type: true, + duration: true, + address: true, + status: true, + rejection_reason: true, + created_at: true, + updated_at: true, + images: { + select: { + id: true, + media_id: true, + order: true, + media: { select: { id: true, storage_path: true } }, + }, + orderBy: { order: 'asc' as const }, + }, + ratings: { + select: { + value: true, + user_id: true, + }, + }, + branch: { + select: { + id: true, + brand: { select: brandSelect }, + }, + }, +} as const; + +function mapBranch(raw: any) { + return { + id: raw.id, + brand_id: raw.brand_id, + name: raw.name, + description: raw.description ?? undefined, + address1: raw.address1, + address2: raw.address2 ?? undefined, + phone: raw.phone ?? undefined, + email: raw.email ?? undefined, + is_24_7: raw.is_24_7, + opening: raw.opening ?? undefined, + closing: raw.closing ?? undefined, + cover_media_id: raw.cover_media_id ?? null, + cover_url: raw.cover_media ? buildFileUrl(raw.cover_media.storage_path) : null, + breaks: raw.breaks ?? [], + created_at: raw.created_at.toISOString(), + updated_at: raw.updated_at.toISOString(), + }; +} + +function mapBrand(raw: any, requesterId?: string) { + const ratingCount = raw.ratings?.length ?? 0; + const ratingAverage = + ratingCount > 0 + ? roundRating(raw.ratings.reduce((sum: number, rating: { value: number }) => sum + rating.value, 0) / ratingCount) + : null; + const myRating = + requesterId + ? raw.ratings?.find((rating: { user_id: string }) => rating.user_id === requesterId)?.value ?? null + : null; + + return { + id: raw.id, + name: raw.name, + description: raw.description ?? undefined, + status: raw.status, + owner_id: raw.owner_id, + logo_url: raw.logo_media ? buildFileUrl(raw.logo_media.storage_path) : undefined, + categories: raw.categories ?? [], + gallery: (raw.gallery ?? []).map((g: any) => ({ + id: g.id, + media_id: g.media_id, + order: g.order, + url: buildFileUrl(g.media.storage_path), + })), + branches: (raw.branches ?? []).map(mapBranch), + instagram_url: raw.instagram_url ?? undefined, + facebook_url: raw.facebook_url ?? undefined, + youtube_url: raw.youtube_url ?? undefined, + whatsapp_url: raw.whatsapp_url ?? undefined, + linkedin_url: raw.linkedin_url ?? undefined, + x_url: raw.x_url ?? undefined, + website_url: raw.website_url ?? undefined, + rating: ratingAverage, + rating_count: ratingCount, + my_rating: myRating, + created_at: raw.created_at.toISOString(), + updated_at: raw.updated_at.toISOString(), + }; +} + +function mapService(raw: any, requesterId?: string) { + const ratingCount = raw.ratings?.length ?? 0; + const ratingAverage = + ratingCount > 0 + ? roundRating(raw.ratings.reduce((sum: number, rating: { value: number }) => sum + rating.value, 0) / ratingCount) + : null; + const myRating = + requesterId + ? raw.ratings?.find((rating: { user_id: string }) => rating.user_id === requesterId)?.value ?? null + : null; + + return { + id: raw.id, + title: raw.title, + description: raw.description ?? undefined, + owner_id: raw.owner_id, + branch_id: raw.branch_id ?? null, + service_category_id: raw.service_category_id ?? null, + service_category: raw.service_category ?? null, + price: raw.price ? Number(raw.price) : null, + price_type: raw.price_type, + duration: raw.duration ?? null, + address: raw.address ?? undefined, + status: raw.status, + rejection_reason: raw.rejection_reason ?? undefined, + images: (raw.images ?? []).map((img: any) => ({ + id: img.id, + media_id: img.media_id, + order: img.order, + url: buildFileUrl(img.media.storage_path), + })), + rating: ratingAverage, + rating_count: ratingCount, + my_rating: myRating, + created_at: raw.created_at.toISOString(), + updated_at: raw.updated_at.toISOString(), + }; +} + +export const listFavorites = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUcr(req, next)) return; + + const userId = req.user.sub; + const [favoriteBrands, favoriteServices] = await Promise.all([ + prisma.favoriteBrand.findMany({ + where: { user_id: userId, brand: { status: 'ACTIVE' } }, + include: { brand: { select: brandSelect } }, + orderBy: { created_at: 'desc' }, + }), + prisma.favoriteService.findMany({ + where: { user_id: userId, service: { status: 'ACTIVE' } }, + include: { service: { select: serviceSelect } }, + orderBy: { created_at: 'desc' }, + }), + ]); + + const serviceBrandMap = new Map(); + for (const favorite of favoriteServices) { + const brand = (favorite.service as any).branch?.brand; + if (brand) serviceBrandMap.set(brand.id, brand); + } + + sendSuccess({ + res, + status: 200, + message: 'favorite.list', + data: { + brands: favoriteBrands.map((favorite) => mapBrand((favorite as any).brand, userId)), + services: favoriteServices.map((favorite) => mapService((favorite as any).service, userId)), + service_brands: [...serviceBrandMap.values()].map((brand) => mapBrand(brand, userId)), + brand_ids: favoriteBrands.map((favorite) => favorite.brand_id), + service_ids: favoriteServices.map((favorite) => favorite.service_id), + }, + }); + } catch (err) { + next(err); + } +}; + +export const addFavoriteBrand = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUcr(req, next)) return; + + const userId = req.user.sub; + const brandId = req.params['id'] as string; + const brand = await prisma.brand.findFirst({ where: { id: brandId, status: 'ACTIVE' }, select: { id: true } }); + + if (!brand) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'brand.not_found'; + return next(err); + } + + await prisma.favoriteBrand.upsert({ + where: { user_id_brand_id: { user_id: userId, brand_id: brandId } }, + update: {}, + create: { user_id: userId, brand_id: brandId }, + }); + + sendSuccess({ res, status: 200, message: 'favorite.brand_added', data: { favorite: true, id: brandId } }); + } catch (err) { + next(err); + } +}; + +export const removeFavoriteBrand = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUcr(req, next)) return; + + const userId = req.user.sub; + const brandId = req.params['id'] as string; + + await prisma.favoriteBrand.deleteMany({ where: { user_id: userId, brand_id: brandId } }); + + sendSuccess({ res, status: 200, message: 'favorite.brand_removed', data: { favorite: false, id: brandId } }); + } catch (err) { + next(err); + } +}; + +export const addFavoriteService = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUcr(req, next)) return; + + const userId = req.user.sub; + const serviceId = req.params['id'] as string; + const service = await prisma.service.findFirst({ where: { id: serviceId, status: 'ACTIVE' }, select: { id: true } }); + + if (!service) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); + } + + await prisma.favoriteService.upsert({ + where: { user_id_service_id: { user_id: userId, service_id: serviceId } }, + update: {}, + create: { user_id: userId, service_id: serviceId }, + }); + + sendSuccess({ res, status: 200, message: 'favorite.service_added', data: { favorite: true, id: serviceId } }); + } catch (err) { + next(err); + } +}; + +export const removeFavoriteService = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUcr(req, next)) return; + + const userId = req.user.sub; + const serviceId = req.params['id'] as string; + + await prisma.favoriteService.deleteMany({ where: { user_id: userId, service_id: serviceId } }); + + sendSuccess({ res, status: 200, message: 'favorite.service_removed', data: { favorite: false, id: serviceId } }); + } catch (err) { + next(err); + } +}; diff --git a/src/lib/seed-marketplace.ts b/src/lib/seed-marketplace.ts index ba187af..ad9ead1 100644 --- a/src/lib/seed-marketplace.ts +++ b/src/lib/seed-marketplace.ts @@ -48,6 +48,7 @@ interface UserSeedDef { first_name: string; last_name: string; phone: string; + has_brand: boolean; brand_name: string; brand_description: string; brand_category_keys: string[]; @@ -59,7 +60,7 @@ interface UserSeedDef { type SeededMarketplaceOwner = { userId: string; - brandId: string; + brandId?: string; serviceIds: string[]; }; @@ -170,29 +171,40 @@ function bakuBranches(domain: string): BranchDef[] { })); } -const USER_PROFILES = [ - ['Aylin', 'Karimova'], - ['Murad', 'Aliyev'], - ['Leyla', 'Mammadova'], - ['Orkhan', 'Hasanov'], - ['Nigar', 'Huseynova'], - ['Rauf', 'Mammadli'], - ['Zahra', 'Ismayilova'], - ['Tural', 'Guliyev'], - ['Fidan', 'Rahimli'], - ['Kamran', 'Abdullayev'], - ['Sabina', 'Rustamova'], - ['Emin', 'Jafarov'], - ['Lala', 'Asadova'], - ['Farid', 'Valiyev'], - ['Aysel', 'Nabiyeva'], - ['Samir', 'Hajiyev'], - ['Gunel', 'Bayramova'], - ['Elvin', 'Mustafayev'], - ['Narmin', 'Taghiyeva'], - ['Rashad', 'Suleymanov'], +function pickBranches(domain: string, ownerIndex: number): BranchDef[] { + const count = deterministicNumber(`owner-${ownerIndex}:branch-count`, 3, 10); + const rotated = deterministicOrder( + bakuBranches(domain), + `owner-${ownerIndex}:branches`, + (branch) => branch.name, + ); + + return rotated.slice(0, count); +} + +const FIRST_NAMES = [ + 'Aylin', 'Murad', 'Leyla', 'Orkhan', 'Nigar', 'Rauf', 'Zahra', 'Tural', + 'Fidan', 'Kamran', 'Sabina', 'Emin', 'Lala', 'Farid', 'Aysel', 'Samir', + 'Gunel', 'Elvin', 'Narmin', 'Rashad', 'Diana', 'Nicat', 'Sevda', 'Ilkin', + 'Amina', 'Javid', 'Malak', 'Fuad', 'Rena', 'Teymur', 'Sona', 'Adil', + 'Jala', 'Ruslan', 'Naila', 'Kanan', 'Milana', 'Anar', 'Sahar', 'Vusal', + 'Sara', 'Nurlan', 'Inci', 'Arif', 'Mina', 'Omar', 'Roya', 'Eldar', + 'Laman', 'Azad', +] as const; + +const LAST_NAMES = [ + 'Karimova', 'Aliyev', 'Mammadova', 'Hasanov', 'Huseynova', 'Mammadli', + 'Ismayilova', 'Guliyev', 'Rahimli', 'Abdullayev', 'Rustamova', 'Jafarov', + 'Asadova', 'Valiyev', 'Nabiyeva', 'Hajiyev', 'Bayramova', 'Mustafayev', + 'Taghiyeva', 'Suleymanov', 'Qasimova', 'Novruzov', 'Safarova', 'Babayev', + 'Aliyeva', ] as const; +const USER_PROFILES = Array.from({ length: 50 }, (_, index) => [ + FIRST_NAMES[index % FIRST_NAMES.length], + LAST_NAMES[(index * 7) % LAST_NAMES.length], +] as const); + const BRAND_BLUEPRINTS = [ { name: 'Luna Beauty Studio', categoryKeys: ['beauty_wellness', 'fashion_apparel'], query: 'beauty,salon,spa', domain: 'luna.az', focus: 'luxury beauty, hair design and skin rituals' }, { name: 'Nova Wellness Club', categoryKeys: ['fitness_sports', 'health_pharmacy'], query: 'fitness,gym,workout', domain: 'nova.az', focus: 'personal training, recovery and healthy routines' }, @@ -312,16 +324,24 @@ function richDescription(title: string, ownerFocus: string, mode: 'brand' | 'dir function pickBrandServiceTemplates(categoryKeys: readonly string[], offset: number): Omit[] { const primary = categoryKeys[0] ?? 'default'; const templates = BRAND_SERVICE_TEMPLATES[primary] ?? BRAND_SERVICE_TEMPLATES.default; - return templates.map((template, index) => ({ - ...template, - title: `${template.title}${offset > 0 ? ` ${offset + 1}` : ''}`, - price: template.price !== undefined ? template.price + ((offset + index) % 4) * 5 : undefined, - duration: template.duration + ((offset + index) % 3) * 5, - })); + const count = deterministicNumber(`owner-${offset}:brand-service-count`, 1, 20); + const pool = [...templates, ...BRAND_SERVICE_TEMPLATES.default]; + + return Array.from({ length: count }, (_, index) => { + const template = pool[(offset * 5 + index) % pool.length]; + return { + ...template, + title: `${template.title}${index >= templates.length ? ` ${index + 1}` : offset > 0 ? ` ${offset + 1}` : ''}`, + price: template.price !== undefined ? template.price + ((offset + index) % 4) * 5 : undefined, + duration: template.duration + ((offset + index) % 3) * 5, + }; + }); } -function pickDirectServices(ownerIndex: number, ownerFocus: string): ServiceDef[] { - const count = 2 + (ownerIndex % 4); +function pickDirectServices(ownerIndex: number, ownerFocus: string, hasBrand: boolean): ServiceDef[] { + const count = hasBrand + ? deterministicNumber(`owner-${ownerIndex}:direct-count`, 0, 3) + : deterministicNumber(`owner-${ownerIndex}:direct-only-count`, 3, 12); return Array.from({ length: count }, (_, index) => { const template = DIRECT_SERVICE_POOL[(ownerIndex * 3 + index) % DIRECT_SERVICE_POOL.length]; return { @@ -336,20 +356,22 @@ function pickDirectServices(ownerIndex: number, ownerFocus: string): ServiceDef[ function buildSeedUsers(): UserSeedDef[] { return USER_PROFILES.map(([firstName, lastName], index) => { const blueprint = BRAND_BLUEPRINTS[index % BRAND_BLUEPRINTS.length]; + const hasBrand = index < 40; const brandName = index < BRAND_BLUEPRINTS.length ? blueprint.name : `${blueprint.name} ${index + 1}`; const branchDomain = blueprint.domain.replace('.az', `${index + 1}.az`); - const services = pickBrandServiceTemplates(blueprint.categoryKeys, Math.floor(index / BRAND_BLUEPRINTS.length)).map((service) => ({ + const services = hasBrand ? pickBrandServiceTemplates(blueprint.categoryKeys, index).map((service) => ({ ...service, description: richDescription(service.title, blueprint.focus, 'brand'), - })); + })) : []; return { email: `marketplace-uso-${index + 1}@reziphay.test`, first_name: firstName, last_name: lastName, phone: `+99450111${String(index + 1).padStart(4, '0')}`, + has_brand: hasBrand, brand_name: brandName, brand_description: [ `

${brandName} is a marketplace-ready brand focused on ${blueprint.focus} across Baku.

`, @@ -357,9 +379,9 @@ function buildSeedUsers(): UserSeedDef[] { ].join(''), brand_category_keys: [...blueprint.categoryKeys], image_query: blueprint.query, - branches: bakuBranches(branchDomain), + branches: hasBrand ? pickBranches(branchDomain, index) : [], services, - direct_services: pickDirectServices(index, blueprint.focus), + direct_services: pickDirectServices(index, blueprint.focus, hasBrand), }; }); } @@ -467,90 +489,93 @@ async function seedUser( }); const userId = user.id; const serviceIds: string[] = []; - - // Brand logo (1:1) - const logoBuffer = await fetchThematicImage(512, 512, userDef.image_query, `${userDef.brand_name}-logo`); - const logoMediaId = await createMediaRecord( - userId, - logoBuffer, - `${userDef.brand_name} Logo`, - 'other', - 512, - 512, - ); - - // Brand gallery (16:9) - const galleryBuffer = await fetchThematicImage(1280, 720, userDef.image_query, `${userDef.brand_name}-gallery`); - const galleryMediaId = await createMediaRecord( - userId, - galleryBuffer, - `${userDef.brand_name} Gallery`, - 'other', - 1280, - 720, - ); - - const brandCatIds = userDef.brand_category_keys - .map((key) => categoryMaps.brand.get(key)) - .filter((id): id is string => id !== undefined); - - const brand = await prisma.brand.create({ - data: { - name: userDef.brand_name, - description: userDef.brand_description, - owner_id: userId, - status: 'ACTIVE', - logo_media_id: logoMediaId, - categories: { connect: brandCatIds.map((id) => ({ id })) }, - gallery: { create: [{ media_id: galleryMediaId, order: 0 }] }, - }, - select: { id: true }, - }); - const brandId = brand.id; + let brandId: string | undefined; // Branches const branchIds: string[] = []; - for (const branchDef of userDef.branches) { - const coverBuffer = await fetchThematicImage(1280, 720, userDef.image_query, `${userDef.brand_name}-${branchDef.name}`); - const coverMediaId = await createMediaRecord( + if (userDef.has_brand) { + // Brand logo (1:1) + const logoBuffer = await fetchThematicImage(512, 512, userDef.image_query, `${userDef.brand_name}-logo`); + const logoMediaId = await createMediaRecord( userId, - coverBuffer, - `${branchDef.name} Cover`, - 'branch_cover', + logoBuffer, + `${userDef.brand_name} Logo`, + 'other', + 512, + 512, + ); + + // Brand gallery (16:9) + const galleryBuffer = await fetchThematicImage(1280, 720, userDef.image_query, `${userDef.brand_name}-gallery`); + const galleryMediaId = await createMediaRecord( + userId, + galleryBuffer, + `${userDef.brand_name} Gallery`, + 'other', 1280, 720, ); - const branch = await prisma.branch.create({ + const brandCatIds = userDef.brand_category_keys + .map((key) => categoryMaps.brand.get(key)) + .filter((id): id is string => id !== undefined); + + const brand = await prisma.brand.create({ data: { - brand_id: brandId, - name: branchDef.name, - address1: branchDef.address1, - phone: branchDef.phone, - email: branchDef.email, - opening: '09:00', - closing: '21:00', - cover_media_id: coverMediaId, + name: userDef.brand_name, + description: userDef.brand_description, + owner_id: userId, + status: 'ACTIVE', + logo_media_id: logoMediaId, + categories: { connect: brandCatIds.map((id) => ({ id })) }, + gallery: { create: [{ media_id: galleryMediaId, order: 0 }] }, }, select: { id: true }, }); + brandId = brand.id; + + for (const branchDef of userDef.branches) { + const coverBuffer = await fetchThematicImage(1280, 720, userDef.image_query, `${userDef.brand_name}-${branchDef.name}`); + const coverMediaId = await createMediaRecord( + userId, + coverBuffer, + `${branchDef.name} Cover`, + 'branch_cover', + 1280, + 720, + ); - await prisma.team.create({ - data: { - branch_id: branch.id, - created_by_user_id: userId, - members: { - create: { - user_id: userId, - invited_by_user_id: userId, - role: 'OWNER', - status: 'ACCEPTED', + const branch = await prisma.branch.create({ + data: { + brand_id: brandId, + name: branchDef.name, + address1: branchDef.address1, + phone: branchDef.phone, + email: branchDef.email, + opening: '09:00', + closing: '21:00', + cover_media_id: coverMediaId, + }, + select: { id: true }, + }); + + await prisma.team.create({ + data: { + branch_id: branch.id, + created_by_user_id: userId, + members: { + create: { + user_id: userId, + invited_by_user_id: userId, + role: 'OWNER', + status: 'ACCEPTED', + }, }, }, - }, - }); + }); - branchIds.push(branch.id); + branchIds.push(branch.id); + } } async function createSeedService(svcDef: ServiceDef, index: number, branchId: string | null) { @@ -586,7 +611,7 @@ async function seedUser( price: svcDef.price ?? null, price_type: svcDef.price_type, duration: svcDef.duration, - address: branchId ? null : userDef.branches[index % userDef.branches.length]?.address1, + address: branchId ? null : (userDef.branches[index % userDef.branches.length]?.address1 ?? BAKU_BRANCHES[index % BAKU_BRANCHES.length].address1), status: 'ACTIVE', images: { create: [{ media_id: imgMediaId, order: 0 }] }, }, @@ -608,7 +633,8 @@ async function seedUser( } console.log( - ` ✓ ${userDef.first_name} ${userDef.last_name} → ${userDef.brand_name}` + + ` ✓ ${userDef.first_name} ${userDef.last_name}` + + (userDef.has_brand ? ` → ${userDef.brand_name}` : ' → direct-only owner') + ` | ${userDef.branches.length} branches | ${userDef.services.length} brand services` + ` | ${userDef.direct_services.length} direct services`, ); @@ -617,7 +643,11 @@ async function seedUser( } async function seedBrandRatings(records: SeededMarketplaceOwner[]): Promise { - const ratings = records.flatMap((record) => { + const brandRecords = records.filter( + (record): record is SeededMarketplaceOwner & { brandId: string } => Boolean(record.brandId), + ); + + const ratings = brandRecords.flatMap((record) => { const raters = deterministicOrder( records.filter((r) => r.userId !== record.userId), `${record.brandId}:raters`, @@ -725,8 +755,8 @@ async function main(): Promise { const totals = { users: SEED_USERS.length, - brands: SEED_USERS.length, - branches: SEED_USERS.length * 8, + brands: SEED_USERS.filter((u) => u.has_brand).length, + branches: SEED_USERS.reduce((sum, u) => sum + u.branches.length, 0), brandServices: SEED_USERS.reduce((sum, u) => sum + u.services.length, 0), directServices: SEED_USERS.reduce((sum, u) => sum + u.direct_services.length, 0), brandRatings: brandRatingCount, diff --git a/src/routes/v1/favorite.route.ts b/src/routes/v1/favorite.route.ts new file mode 100644 index 0000000..be17dfd --- /dev/null +++ b/src/routes/v1/favorite.route.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { + addFavoriteBrand, + addFavoriteService, + listFavorites, + removeFavoriteBrand, + removeFavoriteService, +} from '../../controllers/favorite.controller'; +import { authenticate } from '../../middlewares/auth.middleware'; + +const router: Router = Router(); + +router.get('/favorites', authenticate, listFavorites); +router.post('/favorites/brands/:id', authenticate, addFavoriteBrand); +router.delete('/favorites/brands/:id', authenticate, removeFavoriteBrand); +router.post('/favorites/services/:id', authenticate, addFavoriteService); +router.delete('/favorites/services/:id', authenticate, removeFavoriteService); + +export default router; diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index 9379c7f..6891572 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -9,6 +9,7 @@ import teamRoute from './team.route'; import serviceRoute from './service.route'; import moderationRoute from './moderation.route'; import marketplaceRoute from './marketplace.route'; +import favoriteRoute from './favorite.route'; const router: Router = Router(); @@ -22,5 +23,6 @@ router.use('/', teamRoute); router.use('/', serviceRoute); router.use('/', moderationRoute); router.use('/', marketplaceRoute); +router.use('/', favoriteRoute); export default router; From 53d9db96a905440e566c41f6c18e3a268bf82537 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Mon, 4 May 2026 02:10:34 +0400 Subject: [PATCH 19/25] feat: implement pagination and meta response for service and brand list endpoints --- src/controllers/brand.controller.ts | 38 ++++++++++++++---- src/controllers/service.controller.ts | 56 +++++++++++++++++++-------- 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/controllers/brand.controller.ts b/src/controllers/brand.controller.ts index 86b42b3..0abeb0a 100644 --- a/src/controllers/brand.controller.ts +++ b/src/controllers/brand.controller.ts @@ -648,6 +648,9 @@ export const listPublicBrands = async ( try { const accountId = typeof req.query['account'] === 'string' ? req.query['account'] : undefined; const brandCategoryId = typeof req.query['brand_category_id'] === 'string' ? req.query['brand_category_id'] : undefined; + const page = Math.max(1, Number.parseInt(String(req.query['page'] ?? '1'), 10) || 1); + const limit = Math.min(60, Math.max(1, Number.parseInt(String(req.query['limit'] ?? '60'), 10) || 60)); + const skip = (page - 1) * limit; if (accountId) { // Public account view: ACTIVE first (newest-first), then CLOSED (newest-first). @@ -672,16 +675,35 @@ export const listPublicBrands = async ( } // Default public gallery: active brands only, optionally filtered by category. - const brands = await prisma.brand.findMany({ - where: { - status: 'ACTIVE', - ...(brandCategoryId && { categories: { some: { id: brandCategoryId } } }), + const where = { + status: 'ACTIVE' as const, + ...(brandCategoryId && { categories: { some: { id: brandCategoryId } } }), + }; + const [brands, totalCount] = await Promise.all([ + prisma.brand.findMany({ + where, + select: brandSelect, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + }), + prisma.brand.count({ where }), + ]); + + sendSuccess({ + res, + status: 200, + message: 'brand.list', + data: { + brands: brands.map((b) => mapBrand(b as BrandRaw)), + meta: { + page, + limit, + total_count: totalCount, + has_more: skip + brands.length < totalCount, + }, }, - select: brandSelect, - orderBy: { created_at: 'desc' }, }); - - sendSuccess({ res, status: 200, message: 'brand.list', data: { brands: brands.map((b) => mapBrand(b as BrandRaw)) } }); } catch (err) { next(err); } diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index 3aee855..2103512 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -293,26 +293,48 @@ export const listPublicServices = async ( const owner_id = typeof req.query['owner_id'] === 'string' ? req.query['owner_id'] : undefined; const direct_only = req.query['direct_only'] === 'true'; const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined; + const page = Math.max(1, Number.parseInt(String(req.query['page'] ?? '1'), 10) || 1); + const limit = Math.min(60, Math.max(1, Number.parseInt(String(req.query['limit'] ?? '60'), 10) || 60)); + const skip = (page - 1) * limit; + const where = { + status: 'ACTIVE' as const, + ...(service_category_id && { service_category_id }), + ...(branch_id && { branch_id }), + ...(owner_id && { owner_id }), + ...(direct_only && { branch_id: null }), + ...(q && { + OR: [ + { title: { contains: q, mode: 'insensitive' as const } }, + { description: { contains: q, mode: 'insensitive' as const } }, + ], + }), + }; + + const [services, totalCount] = await Promise.all([ + prisma.service.findMany({ + where, + select: serviceSelect, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + }), + prisma.service.count({ where }), + ]); - const services = await prisma.service.findMany({ - where: { - status: 'ACTIVE', - ...(service_category_id && { service_category_id }), - ...(branch_id && { branch_id }), - ...(owner_id && { owner_id }), - ...(direct_only && { branch_id: null }), - ...(q && { - OR: [ - { title: { contains: q, mode: 'insensitive' } }, - { description: { contains: q, mode: 'insensitive' } }, - ], - }), + sendSuccess({ + res, + status: 200, + message: 'service.list', + data: { + services: services.map((service) => mapService(service)), + meta: { + page, + limit, + total_count: totalCount, + has_more: skip + services.length < totalCount, + }, }, - select: serviceSelect, - orderBy: { created_at: 'desc' }, }); - - sendSuccess({ res, status: 200, message: 'service.list', data: { services: services.map((service) => mapService(service)) } }); } catch (err) { next(err); } From 199412ba9d0e59ad66264552c9157bdb8906b9a9 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Mon, 4 May 2026 02:40:26 +0400 Subject: [PATCH 20/25] feat: implement marketplace search endpoint with multi-entity support --- src/controllers/marketplace.controller.ts | 295 ++++++++++++++++++++++ src/routes/v1/marketplace.route.ts | 3 +- 2 files changed, 297 insertions(+), 1 deletion(-) diff --git a/src/controllers/marketplace.controller.ts b/src/controllers/marketplace.controller.ts index 730d543..e750dfc 100644 --- a/src/controllers/marketplace.controller.ts +++ b/src/controllers/marketplace.controller.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import prisma from '../lib/prisma'; import { sendSuccess } from '../utils/response'; +import { buildFileUrl } from '../services/storage.service'; export const getMarketplaceFacets = async ( _req: Request, @@ -50,3 +51,297 @@ export const getMarketplaceFacets = async ( next(err); } }; + +function roundRating(value: number): number { + return Math.round(value * 10) / 10; +} + +function imageUrl(storagePath?: string | null) { + return storagePath ? buildFileUrl(storagePath) : null; +} + +function ratingSummary(ratings: { value: number }[]) { + if (ratings.length === 0) return { rating: null, rating_count: 0 }; + return { + rating: roundRating(ratings.reduce((sum, rating) => sum + rating.value, 0) / ratings.length), + rating_count: ratings.length, + }; +} + +function parseSearchLimit(value: unknown, fallback = 8) { + return Math.min(40, Math.max(1, Number.parseInt(String(value ?? fallback), 10) || fallback)); +} + +function buildSearchHref(type: string, id: string, brandId?: string | null) { + if (type === 'brand') return `/brands?id=${id}`; + if (type === 'branch') return brandId ? `/brands?id=${brandId}` : '/brands'; + if (type === 'service') return `/services?id=${id}`; + if (type === 'uso') return `/account?id=${id}`; + if (type === 'address') return brandId ? `/brands?id=${brandId}` : `/services?id=${id}`; + return '/search'; +} + +function searchNeedle(req: Request) { + return String(req.query['q'] ?? req.query['query'] ?? req.query['queary'] ?? '').trim(); +} + +export const searchMarketplace = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const q = searchNeedle(req); + const type = typeof req.query['type'] === 'string' ? req.query['type'] : 'all'; + const category = typeof req.query['category'] === 'string' ? req.query['category'] : undefined; + const sort = typeof req.query['sort'] === 'string' ? req.query['sort'] : 'relevance'; + const limit = parseSearchLimit(req.query['limit'], 12); + + if (q.length < 2) { + sendSuccess({ + res, + status: 200, + message: 'marketplace.search', + data: { query: q, suggestions: [], results: { brands: [], branches: [], services: [], users: [], addresses: [] } }, + }); + return; + } + + const include = (kind: string) => type === 'all' || type === kind; + + const [brands, branches, services, users, addressBranches, addressServices] = await Promise.all([ + include('brand') + ? prisma.brand.findMany({ + where: { + status: 'ACTIVE', + ...(category && { categories: { some: { id: category } } }), + OR: [ + { name: { contains: q, mode: 'insensitive' } }, + { description: { contains: q, mode: 'insensitive' } }, + { categories: { some: { key: { contains: q, mode: 'insensitive' } } } }, + ], + }, + select: { + id: true, + name: true, + description: true, + owner_id: true, + logo_media: { select: { storage_path: true } }, + gallery: { + select: { media: { select: { storage_path: true } } }, + orderBy: { order: 'asc' }, + take: 1, + }, + categories: { select: { id: true, key: true } }, + ratings: { select: { value: true } }, + }, + take: limit, + }) + : Promise.resolve([]), + include('branch') + ? prisma.branch.findMany({ + where: { + brand: { status: 'ACTIVE' }, + OR: [ + { name: { contains: q, mode: 'insensitive' } }, + { description: { contains: q, mode: 'insensitive' } }, + { address1: { contains: q, mode: 'insensitive' } }, + { address2: { contains: q, mode: 'insensitive' } }, + ], + }, + select: { + id: true, + brand_id: true, + name: true, + address1: true, + address2: true, + cover_media: { select: { storage_path: true } }, + brand: { + select: { + name: true, + categories: { select: { id: true, key: true } }, + logo_media: { select: { storage_path: true } }, + }, + }, + }, + take: limit, + }) + : Promise.resolve([]), + include('service') + ? prisma.service.findMany({ + where: { + status: 'ACTIVE', + ...(category && { service_category_id: category }), + OR: [ + { title: { contains: q, mode: 'insensitive' } }, + { description: { contains: q, mode: 'insensitive' } }, + { address: { contains: q, mode: 'insensitive' } }, + { service_category: { key: { contains: q, mode: 'insensitive' } } }, + ], + }, + select: { + id: true, + title: true, + description: true, + owner_id: true, + service_category: { select: { id: true, key: true } }, + images: { + select: { media: { select: { storage_path: true } } }, + orderBy: { order: 'asc' }, + take: 1, + }, + ratings: { select: { value: true } }, + }, + take: limit, + }) + : Promise.resolve([]), + include('uso') + ? prisma.user.findMany({ + where: { + type: 'uso', + AND: [ + { + OR: [ + { brands: { some: { status: 'ACTIVE' } } }, + { services: { some: { status: 'ACTIVE' } } }, + ], + }, + ], + OR: [ + { first_name: { contains: q, mode: 'insensitive' } }, + { last_name: { contains: q, mode: 'insensitive' } }, + { email: { contains: q, mode: 'insensitive' } }, + ], + }, + select: { + id: true, + first_name: true, + last_name: true, + email: true, + avatar_media: { select: { storage_path: true } }, + }, + take: limit, + }) + : Promise.resolve([]), + include('address') + ? prisma.branch.findMany({ + where: { + brand: { status: 'ACTIVE' }, + OR: [ + { address1: { contains: q, mode: 'insensitive' } }, + { address2: { contains: q, mode: 'insensitive' } }, + ], + }, + select: { id: true, brand_id: true, name: true, address1: true, address2: true, brand: { select: { name: true } } }, + take: limit, + }) + : Promise.resolve([]), + include('address') + ? prisma.service.findMany({ + where: { status: 'ACTIVE', address: { contains: q, mode: 'insensitive' } }, + select: { id: true, title: true, address: true }, + take: limit, + }) + : Promise.resolve([]), + ]); + + const brandItems = brands.map((brand) => ({ + id: brand.id, + type: 'brand', + title: brand.name, + subtitle: brand.description ?? brand.categories[0]?.key ?? '', + image_url: imageUrl(brand.logo_media?.storage_path) ?? imageUrl(brand.gallery[0]?.media.storage_path), + href: buildSearchHref('brand', brand.id), + category_id: brand.categories[0]?.id ?? null, + category_key: brand.categories[0]?.key ?? null, + ...ratingSummary(brand.ratings), + })); + const branchItems = branches.map((branch) => ({ + id: branch.id, + type: 'branch', + title: branch.name, + subtitle: `${branch.brand.name} · ${[branch.address1, branch.address2].filter(Boolean).join(', ')}`, + image_url: imageUrl(branch.cover_media?.storage_path) ?? imageUrl(branch.brand.logo_media?.storage_path), + href: buildSearchHref('branch', branch.id, branch.brand_id), + category_id: branch.brand.categories[0]?.id ?? null, + category_key: branch.brand.categories[0]?.key ?? null, + rating: null, + rating_count: 0, + })); + const serviceItems = services.map((service) => ({ + id: service.id, + type: 'service', + title: service.title, + subtitle: service.description ?? service.service_category?.key ?? '', + image_url: imageUrl(service.images[0]?.media.storage_path), + href: buildSearchHref('service', service.id), + category_id: service.service_category?.id ?? null, + category_key: service.service_category?.key ?? null, + ...ratingSummary(service.ratings), + })); + const userItems = users.map((user) => ({ + id: user.id, + type: 'uso', + title: `${user.first_name} ${user.last_name}`.trim(), + subtitle: user.email, + image_url: imageUrl(user.avatar_media?.storage_path), + href: buildSearchHref('uso', user.id), + category_id: null, + category_key: null, + rating: null, + rating_count: 0, + })); + const addressItems = [ + ...addressBranches.map((branch) => ({ + id: `branch-address-${branch.id}`, + type: 'address', + title: [branch.address1, branch.address2].filter(Boolean).join(', '), + subtitle: `${branch.brand.name} · ${branch.name}`, + image_url: null, + href: buildSearchHref('address', branch.id, branch.brand_id), + category_id: null, + category_key: null, + rating: null, + rating_count: 0, + })), + ...addressServices.map((service) => ({ + id: `service-address-${service.id}`, + type: 'address', + title: service.address ?? '', + subtitle: service.title, + image_url: null, + href: buildSearchHref('address', service.id), + category_id: null, + category_key: null, + rating: null, + rating_count: 0, + })), + ]; + + const sortByRating = (items: any[]) => + sort === 'rating_desc' + ? [...items].sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0) || (b.rating_count ?? 0) - (a.rating_count ?? 0)) + : items; + + const suggestions = [...brandItems, ...branchItems, ...serviceItems, ...userItems, ...addressItems].slice(0, limit); + + sendSuccess({ + res, + status: 200, + message: 'marketplace.search', + data: { + query: q, + suggestions, + results: { + brands: sortByRating(brandItems), + branches: branchItems, + services: sortByRating(serviceItems), + users: userItems, + addresses: addressItems, + }, + }, + }); + } catch (err) { + next(err); + } +}; diff --git a/src/routes/v1/marketplace.route.ts b/src/routes/v1/marketplace.route.ts index 5736ddd..2f697e5 100644 --- a/src/routes/v1/marketplace.route.ts +++ b/src/routes/v1/marketplace.route.ts @@ -1,8 +1,9 @@ import { Router } from 'express'; -import { getMarketplaceFacets } from '../../controllers/marketplace.controller'; +import { getMarketplaceFacets, searchMarketplace } from '../../controllers/marketplace.controller'; const router: Router = Router(); router.get('/marketplace/facets', getMarketplaceFacets); +router.get('/marketplace/search', searchMarketplace); export default router; From c890ca0c556136caf4d7548003a6cd4b6c9cdef1 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Tue, 5 May 2026 12:26:59 +0400 Subject: [PATCH 21/25] feat: include branch brand details with rating metrics in service controller and update query limits --- src/controllers/service.controller.ts | 47 ++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index 2103512..bd0aa68 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -73,6 +73,27 @@ const serviceSelect = { branch_id: true, service_category_id: true, service_category: { select: { id: true, key: true } }, + branch: { + select: { + id: true, + brand_id: true, + name: true, + brand: { + select: { + id: true, + name: true, + owner_id: true, + logo_media: { select: { id: true, storage_path: true } }, + ratings: { + select: { + value: true, + user_id: true, + }, + }, + }, + }, + }, + }, price: true, price_type: true, duration: true, @@ -112,6 +133,11 @@ function mapService(raw: any, requesterId?: string) { requesterId ? raw.ratings?.find((rating: { user_id: string }) => rating.user_id === requesterId)?.value ?? null : null; + const brandRatingCount = raw.branch?.brand?.ratings?.length ?? 0; + const brandRating = + brandRatingCount > 0 + ? roundRating(raw.branch.brand.ratings.reduce((sum: number, rating: { value: number }) => sum + rating.value, 0) / brandRatingCount) + : null; return { id: raw.id, @@ -119,6 +145,23 @@ function mapService(raw: any, requesterId?: string) { description: raw.description ?? undefined, owner_id: raw.owner_id, branch_id: raw.branch_id ?? null, + branch: raw.branch + ? { + id: raw.branch.id, + brand_id: raw.branch.brand_id, + name: raw.branch.name, + brand: raw.branch.brand + ? { + id: raw.branch.brand.id, + name: raw.branch.brand.name, + owner_id: raw.branch.brand.owner_id, + logo_url: raw.branch.brand.logo_media ? buildFileUrl(raw.branch.brand.logo_media.storage_path) : undefined, + rating: brandRating, + rating_count: brandRatingCount, + } + : null, + } + : null, service_category_id: raw.service_category_id ?? null, service_category: raw.service_category ?? null, price: raw.price ? Number(raw.price) : null, @@ -290,16 +333,18 @@ export const listPublicServices = async ( try { const service_category_id = typeof req.query['service_category_id'] === 'string' ? req.query['service_category_id'] : undefined; const branch_id = typeof req.query['branch_id'] === 'string' ? req.query['branch_id'] : undefined; + const brand_id = typeof req.query['brand_id'] === 'string' ? req.query['brand_id'] : undefined; const owner_id = typeof req.query['owner_id'] === 'string' ? req.query['owner_id'] : undefined; const direct_only = req.query['direct_only'] === 'true'; const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined; const page = Math.max(1, Number.parseInt(String(req.query['page'] ?? '1'), 10) || 1); - const limit = Math.min(60, Math.max(1, Number.parseInt(String(req.query['limit'] ?? '60'), 10) || 60)); + const limit = Math.min(200, Math.max(1, Number.parseInt(String(req.query['limit'] ?? '60'), 10) || 60)); const skip = (page - 1) * limit; const where = { status: 'ACTIVE' as const, ...(service_category_id && { service_category_id }), ...(branch_id && { branch_id }), + ...(brand_id && !branch_id && !direct_only && { branch: { brand_id } }), ...(owner_id && { owner_id }), ...(direct_only && { branch_id: null }), ...(q && { From 2c2638b65f70ce8e8b7eac218a90858f1fc159f1 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Tue, 5 May 2026 15:09:37 +0400 Subject: [PATCH 22/25] feat: add marketplace home endpoint, implement auth rate limiter, and constrain request body size --- package.json | 1 + pnpm-lock.yaml | 20 ++ src/app.ts | 4 +- src/controllers/marketplace.controller.ts | 348 ++++++++++++++++++++++ src/middlewares/rate-limit.middleware.ts | 14 + src/routes/v1/auth.route.ts | 5 +- src/routes/v1/marketplace.route.ts | 4 +- 7 files changed, 391 insertions(+), 5 deletions(-) create mode 100644 src/middlewares/rate-limit.middleware.ts diff --git a/package.json b/package.json index e0308e8..dffaf6c 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "express-rate-limit": "^8.5.0", "file-type": "^22.0.0", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 437611c..6515036 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + express-rate-limit: + specifier: ^8.5.0 + version: 8.5.0(express@5.2.1) file-type: specifier: ^22.0.0 version: 22.0.0 @@ -845,6 +848,12 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + express-rate-limit@8.5.0: + resolution: {integrity: sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -989,6 +998,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2324,6 +2337,11 @@ snapshots: etag@1.8.1: {} + express-rate-limit@8.5.0(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -2506,6 +2524,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} is-binary-path@2.1.0: diff --git a/src/app.ts b/src/app.ts index 9572aed..0291368 100644 --- a/src/app.ts +++ b/src/app.ts @@ -42,8 +42,8 @@ app.use( allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Accept-Language'], }), ); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.json({ limit: '256kb' })); +app.use(express.urlencoded({ extended: true, limit: '256kb' })); // Serve uploaded files — path must never expose outside storage dir app.use('/uploads', express.static(path.resolve(env.STORAGE_DIR), { index: false, dotfiles: 'deny' })); diff --git a/src/controllers/marketplace.controller.ts b/src/controllers/marketplace.controller.ts index e750dfc..084fb5a 100644 --- a/src/controllers/marketplace.controller.ts +++ b/src/controllers/marketplace.controller.ts @@ -68,6 +68,354 @@ function ratingSummary(ratings: { value: number }[]) { }; } +function shuffle(items: T[]): T[] { + return [...items].sort(() => Math.random() - 0.5); +} + +const homeBrandSelect = { + id: true, + name: true, + description: true, + status: true, + owner_id: true, + created_at: true, + updated_at: true, + logo_media: { select: { storage_path: true } }, + categories: { select: { id: true, key: true } }, + gallery: { + select: { + id: true, + media_id: true, + order: true, + media: { select: { storage_path: true } }, + }, + orderBy: { order: 'asc' as const }, + }, + ratings: { select: { value: true, user_id: true } }, +} as const; + +const homeServiceSelect = { + id: true, + title: true, + description: true, + owner_id: true, + branch_id: true, + service_category_id: true, + service_category: { select: { id: true, key: true } }, + price: true, + price_type: true, + duration: true, + address: true, + status: true, + rejection_reason: true, + created_at: true, + updated_at: true, + images: { + select: { + id: true, + media_id: true, + order: true, + media: { select: { storage_path: true } }, + }, + orderBy: { order: 'asc' as const }, + }, + ratings: { select: { value: true, user_id: true } }, + branch: { + select: { + id: true, + brand_id: true, + name: true, + brand: { + select: { + id: true, + name: true, + owner_id: true, + logo_media: { select: { storage_path: true } }, + ratings: { select: { value: true } }, + }, + }, + }, + }, +} as const; + +function mapHomeBrand(raw: any, requesterId?: string) { + const summary = ratingSummary(raw.ratings ?? []); + return { + id: raw.id, + name: raw.name, + description: raw.description ?? undefined, + status: raw.status, + owner_id: raw.owner_id, + logo_url: imageUrl(raw.logo_media?.storage_path) ?? undefined, + categories: raw.categories ?? [], + gallery: (raw.gallery ?? []).map((item: any) => ({ + id: item.id, + media_id: item.media_id, + order: item.order, + url: buildFileUrl(item.media.storage_path), + })), + rating: summary.rating, + rating_count: summary.rating_count, + my_rating: requesterId + ? raw.ratings?.find((rating: { user_id: string }) => rating.user_id === requesterId)?.value ?? null + : null, + created_at: raw.created_at.toISOString(), + updated_at: raw.updated_at.toISOString(), + }; +} + +function mapHomeService(raw: any, requesterId?: string) { + const summary = ratingSummary(raw.ratings ?? []); + const brandSummary = ratingSummary(raw.branch?.brand?.ratings ?? []); + return { + id: raw.id, + title: raw.title, + description: raw.description ?? undefined, + owner_id: raw.owner_id, + branch_id: raw.branch_id ?? null, + branch: raw.branch + ? { + id: raw.branch.id, + brand_id: raw.branch.brand_id, + name: raw.branch.name, + brand: raw.branch.brand + ? { + id: raw.branch.brand.id, + name: raw.branch.brand.name, + owner_id: raw.branch.brand.owner_id, + logo_url: imageUrl(raw.branch.brand.logo_media?.storage_path) ?? undefined, + rating: brandSummary.rating, + rating_count: brandSummary.rating_count, + } + : null, + } + : null, + service_category_id: raw.service_category_id ?? null, + service_category: raw.service_category ?? null, + price: raw.price ? Number(raw.price) : null, + price_type: raw.price_type, + duration: raw.duration ?? null, + address: raw.address ?? undefined, + status: raw.status, + rejection_reason: raw.rejection_reason ?? undefined, + images: (raw.images ?? []).map((item: any) => ({ + id: item.id, + media_id: item.media_id, + order: item.order, + url: buildFileUrl(item.media.storage_path), + })), + rating: summary.rating, + rating_count: summary.rating_count, + my_rating: requesterId + ? raw.ratings?.find((rating: { user_id: string }) => rating.user_id === requesterId)?.value ?? null + : null, + created_at: raw.created_at.toISOString(), + updated_at: raw.updated_at.toISOString(), + }; +} + +async function randomServiceIds(limit: number) { + return prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM "Service" + WHERE status = 'ACTIVE' + ORDER BY RANDOM() + LIMIT ${limit} + `; +} + +async function randomBrandIds(limit: number) { + return prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM "Brand" + WHERE status = 'ACTIVE' + ORDER BY RANDOM() + LIMIT ${limit} + `; +} + +async function servicesByIds(ids: string[]) { + if (ids.length === 0) return []; + const order = new Map(ids.map((id, index) => [id, index])); + const services = await prisma.service.findMany({ + where: { id: { in: ids }, status: 'ACTIVE' }, + select: homeServiceSelect, + }); + return services.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); +} + +async function brandsByIds(ids: string[]) { + if (ids.length === 0) return []; + const order = new Map(ids.map((id, index) => [id, index])); + const brands = await prisma.brand.findMany({ + where: { id: { in: ids }, status: 'ACTIVE' }, + select: homeBrandSelect, + }); + return brands.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); +} + +function topRated(items: T[]) { + return [...items].sort((a, b) => { + const ar = ratingSummary(a.ratings); + const br = ratingSummary(b.ratings); + return (br.rating ?? 0) - (ar.rating ?? 0) || br.rating_count - ar.rating_count || b.created_at.getTime() - a.created_at.getTime(); + }); +} + +export const getMarketplaceHome = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const userId = req.user?.sub; + + const [randomServiceRows, recentServices, recentBrands, topServicePool, topBrandPool, favoriteServices, favoriteBrands, usoPool] = + await Promise.all([ + randomServiceIds(20), + prisma.service.findMany({ + where: { status: 'ACTIVE' }, + select: homeServiceSelect, + orderBy: { created_at: 'desc' }, + take: 10, + }), + prisma.brand.findMany({ + where: { status: 'ACTIVE' }, + select: homeBrandSelect, + orderBy: { created_at: 'desc' }, + take: 10, + }), + prisma.service.findMany({ + where: { status: 'ACTIVE', ratings: { some: {} } }, + select: homeServiceSelect, + orderBy: { created_at: 'desc' }, + take: 220, + }), + prisma.brand.findMany({ + where: { status: 'ACTIVE', ratings: { some: {} } }, + select: homeBrandSelect, + orderBy: { created_at: 'desc' }, + take: 160, + }), + userId + ? prisma.favoriteService.findMany({ + where: { user_id: userId, service: { status: 'ACTIVE' } }, + select: { + service_id: true, + service: { + select: { + service_category_id: true, + branch: { select: { brand: { select: { categories: { select: { id: true } } } } } }, + }, + }, + }, + orderBy: { created_at: 'desc' }, + take: 20, + }) + : Promise.resolve([]), + userId + ? prisma.favoriteBrand.findMany({ + where: { user_id: userId, brand: { status: 'ACTIVE' } }, + select: { brand_id: true, brand: { select: { categories: { select: { id: true } } } } }, + orderBy: { created_at: 'desc' }, + take: 20, + }) + : Promise.resolve([]), + prisma.user.findMany({ + where: { + type: 'uso', + OR: [{ brands: { some: { status: 'ACTIVE' } } }, { services: { some: { status: 'ACTIVE' } } }], + }, + select: { + id: true, + first_name: true, + last_name: true, + email: true, + avatar_media: { select: { storage_path: true } }, + brands: { where: { status: 'ACTIVE' }, select: { ratings: { select: { value: true } } } }, + services: { where: { status: 'ACTIVE' }, select: { ratings: { select: { value: true } } } }, + }, + take: 160, + }), + ]); + + const randomServices = await servicesByIds(randomServiceRows.map((row) => row.id)); + const serviceCategoryIds = [ + ...new Set(favoriteServices.map((favorite: any) => favorite.service.service_category_id).filter(Boolean)), + ]; + const brandCategoryIds = [ + ...new Set([ + ...favoriteBrands.flatMap((favorite: any) => favorite.brand.categories.map((category: { id: string }) => category.id)), + ...favoriteServices.flatMap((favorite: any) => + favorite.service.branch?.brand?.categories.map((category: { id: string }) => category.id) ?? [], + ), + ]), + ]; + + const [recommendedServicePool, recommendedBrandPool] = await Promise.all([ + serviceCategoryIds.length > 0 + ? prisma.service.findMany({ + where: { + status: 'ACTIVE', + service_category_id: { in: serviceCategoryIds }, + id: { notIn: favoriteServices.map((favorite: any) => favorite.service_id) }, + }, + select: homeServiceSelect, + take: 80, + }) + : servicesByIds((await randomServiceIds(20)).map((row) => row.id)), + brandCategoryIds.length > 0 + ? prisma.brand.findMany({ + where: { + status: 'ACTIVE', + categories: { some: { id: { in: brandCategoryIds } } }, + id: { notIn: favoriteBrands.map((favorite: any) => favorite.brand_id) }, + }, + select: homeBrandSelect, + take: 60, + }) + : brandsByIds((await randomBrandIds(20)).map((row) => row.id)), + ]); + + const topUsos = usoPool + .map((user) => { + const ratings = [ + ...user.brands.flatMap((brand) => brand.ratings), + ...user.services.flatMap((service) => service.ratings), + ]; + return { + id: user.id, + first_name: user.first_name, + last_name: user.last_name, + email: user.email, + type: 'uso', + avatar_url: imageUrl(user.avatar_media?.storage_path), + ...ratingSummary(ratings), + }; + }) + .filter((user) => user.rating !== null) + .sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0) || b.rating_count - a.rating_count) + .slice(0, 10); + + sendSuccess({ + res, + status: 200, + message: 'marketplace.home', + data: { + random_services: randomServices.slice(0, 10).map((service) => mapHomeService(service, userId)), + smart_services: randomServices.slice(10, 20).map((service) => mapHomeService(service, userId)), + recent_services: recentServices.map((service) => mapHomeService(service, userId)), + recent_brands: recentBrands.map((brand) => mapHomeBrand(brand, userId)), + recommended_services: shuffle(recommendedServicePool).slice(0, 10).map((service) => mapHomeService(service, userId)), + recommended_brands: shuffle(recommendedBrandPool).slice(0, 10).map((brand) => mapHomeBrand(brand, userId)), + top_rated_services: topRated(topServicePool).slice(0, 10).map((service) => mapHomeService(service, userId)), + top_rated_brands: topRated(topBrandPool).slice(0, 10).map((brand) => mapHomeBrand(brand, userId)), + top_usos: topUsos, + }, + }); + } catch (err) { + next(err); + } +}; + function parseSearchLimit(value: unknown, fallback = 8) { return Math.min(40, Math.max(1, Number.parseInt(String(value ?? fallback), 10) || fallback)); } diff --git a/src/middlewares/rate-limit.middleware.ts b/src/middlewares/rate-limit.middleware.ts new file mode 100644 index 0000000..dfca79b --- /dev/null +++ b/src/middlewares/rate-limit.middleware.ts @@ -0,0 +1,14 @@ +import { RequestHandler } from 'express'; +import { rateLimit } from 'express-rate-limit'; + +export const authRateLimiter: RequestHandler = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 10, + standardHeaders: 'draft-8', + legacyHeaders: false, + message: { + success: false, + status: 429, + message: 'auth.too_many_attempts', + }, +}); diff --git a/src/routes/v1/auth.route.ts b/src/routes/v1/auth.route.ts index bc0019d..881e1df 100644 --- a/src/routes/v1/auth.route.ts +++ b/src/routes/v1/auth.route.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { register, login, refresh, me } from '../../controllers/auth.controller'; import { validate } from '../../middlewares/validate.middleware'; import { authenticate } from '../../middlewares/auth.middleware'; +import { authRateLimiter } from '../../middlewares/rate-limit.middleware'; import { registerSchema, loginSchema, refreshSchema } from '../../schemas/auth.schema'; const router: Router = Router(); @@ -60,7 +61,7 @@ const router: Router = Router(); * 409: * description: Email already in use */ -router.post('/register', validate(registerSchema), register); +router.post('/register', authRateLimiter, validate(registerSchema), register); /** * @openapi @@ -94,7 +95,7 @@ router.post('/register', validate(registerSchema), register); * 401: * description: Invalid email or password */ -router.post('/login', validate(loginSchema), login); +router.post('/login', authRateLimiter, validate(loginSchema), login); router.post('/refresh', validate(refreshSchema), refresh); diff --git a/src/routes/v1/marketplace.route.ts b/src/routes/v1/marketplace.route.ts index 2f697e5..1dd8da5 100644 --- a/src/routes/v1/marketplace.route.ts +++ b/src/routes/v1/marketplace.route.ts @@ -1,9 +1,11 @@ import { Router } from 'express'; -import { getMarketplaceFacets, searchMarketplace } from '../../controllers/marketplace.controller'; +import { getMarketplaceFacets, getMarketplaceHome, searchMarketplace } from '../../controllers/marketplace.controller'; +import { authenticate } from '../../middlewares/auth.middleware'; const router: Router = Router(); router.get('/marketplace/facets', getMarketplaceFacets); +router.get('/marketplace/home', authenticate, getMarketplaceHome); router.get('/marketplace/search', searchMarketplace); export default router; From 825c102f9378529b02b79f0a3e1c4a0988242322 Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Tue, 5 May 2026 18:58:58 +0400 Subject: [PATCH 23/25] fix: implement brand-level service gating, branch access validation, and rich text sanitization for brand descriptions --- package.json | 2 + pnpm-lock.yaml | 124 +++++++++++++ .../migration.sql | 2 + prisma/schema.prisma | 3 +- src/controllers/brand.controller.ts | 2 + src/controllers/favorite.controller.ts | 30 +++- src/controllers/marketplace.controller.ts | 74 ++++---- src/controllers/moderation.controller.ts | 7 + src/controllers/service.controller.ts | 170 ++++++++++-------- src/lib/rich-text.ts | 33 ++++ src/routes/v1/service.route.ts | 3 +- src/schemas/brand.schema.ts | 12 +- src/schemas/service.schema.ts | 8 +- src/services/moderation.service.ts | 13 +- 14 files changed, 361 insertions(+), 122 deletions(-) create mode 100644 prisma/migrations/20260505000000_add_brand_rejection_reason/migration.sql create mode 100644 src/lib/rich-text.ts diff --git a/package.json b/package.json index dffaf6c..3095168 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.6.0", "@prisma/client": "^7.6.0", + "@types/sanitize-html": "^2.16.1", "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.3.1", @@ -52,6 +53,7 @@ "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "pg": "^8.20.0", + "sanitize-html": "^2.17.3", "sharp": "^0.34.5", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6515036..6c341c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@prisma/client': specifier: ^7.6.0 version: 7.6.0(prisma@7.6.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2))(typescript@6.0.2) + '@types/sanitize-html': + specifier: ^2.16.1 + version: 2.16.1 bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -44,6 +47,9 @@ importers: pg: specifier: ^8.20.0 version: 8.20.0 + sanitize-html: + specifier: ^2.17.3 + version: 2.17.3 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -546,6 +552,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -763,6 +772,10 @@ packages: resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} engines: {node: '>=16.0.0'} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -789,6 +802,19 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -821,6 +847,14 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -840,6 +874,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -974,6 +1012,9 @@ packages: resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} engines: {node: '>=16.9.0'} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1022,6 +1063,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1162,6 +1207,11 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -1211,6 +1261,9 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1276,6 +1329,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -1387,6 +1444,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-html@2.17.3: + resolution: {integrity: sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1451,6 +1511,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -2061,6 +2125,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + '@types/send@1.2.1': dependencies: '@types/node': 25.5.0 @@ -2278,6 +2346,8 @@ snapshots: deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} + defu@6.1.4: {} denque@2.1.0: {} @@ -2294,6 +2364,24 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -2321,6 +2409,10 @@ snapshots: encodeurl@2.0.0: {} + entities@4.5.0: {} + + entities@7.0.1: {} + env-paths@3.0.0: {} es-define-property@1.0.1: {} @@ -2333,6 +2425,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + esutils@2.0.3: {} etag@1.8.1: {} @@ -2499,6 +2593,13 @@ snapshots: hono@4.12.9: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -2540,6 +2641,8 @@ snapshots: is-number@7.0.0: {} + is-plain-object@5.0.0: {} + is-promise@4.0.0: {} is-property@1.0.2: {} @@ -2672,6 +2775,8 @@ snapshots: dependencies: lru.min: 1.1.4 + nanoid@3.3.12: {} + negotiator@1.0.0: {} node-fetch-native@1.6.7: {} @@ -2719,6 +2824,8 @@ snapshots: openapi-types@12.1.3: {} + parse-srcset@1.0.2: {} + parseurl@1.3.3: {} path-is-absolute@1.0.1: {} @@ -2776,6 +2883,12 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postgres-array@2.0.0: {} postgres-array@3.0.4: {} @@ -2881,6 +2994,15 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-html@2.17.3: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 10.1.0 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.5.14 + scheduler@0.27.0: {} semver@7.7.4: {} @@ -2989,6 +3111,8 @@ snapshots: sisteransi@1.0.5: {} + source-map-js@1.2.1: {} + split2@4.2.0: {} sqlstring@2.3.3: {} diff --git a/prisma/migrations/20260505000000_add_brand_rejection_reason/migration.sql b/prisma/migrations/20260505000000_add_brand_rejection_reason/migration.sql new file mode 100644 index 0000000..f0ef72b --- /dev/null +++ b/prisma/migrations/20260505000000_add_brand_rejection_reason/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Brand" ADD COLUMN "rejection_reason" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6e75080..c83ea92 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -172,7 +172,8 @@ model Brand { id String @id @default(cuid()) name String description String? - status BrandStatus @default(PENDING) + status BrandStatus @default(PENDING) + rejection_reason String? owner_id String logo_media_id String? instagram_url String? diff --git a/src/controllers/brand.controller.ts b/src/controllers/brand.controller.ts index 0abeb0a..381f7a8 100644 --- a/src/controllers/brand.controller.ts +++ b/src/controllers/brand.controller.ts @@ -131,6 +131,7 @@ const brandSelect = { name: true, description: true, status: true, + rejection_reason: true, owner_id: true, logo_media_id: true, instagram_url: true, @@ -184,6 +185,7 @@ function mapBrand(raw: BrandRaw, requesterId?: string) { name: raw.name, description: raw.description ?? undefined, status: raw.status, + rejection_reason: raw.rejection_reason ?? undefined, owner_id: raw.owner_id, logo_url: raw.logo_media ? buildFileUrl(raw.logo_media.storage_path) : undefined, categories: raw.categories, diff --git a/src/controllers/favorite.controller.ts b/src/controllers/favorite.controller.ts index c323a9e..c4422e8 100644 --- a/src/controllers/favorite.controller.ts +++ b/src/controllers/favorite.controller.ts @@ -19,6 +19,23 @@ function roundRating(value: number): number { return Math.round(value * 10) / 10; } +function publicServiceWhere(extra: Record = {}) { + const { AND, ...rest } = extra as { AND?: unknown } & Record; + return { + status: 'ACTIVE' as const, + ...rest, + AND: [ + { + OR: [ + { branch_id: null }, + { branch: { brand: { status: 'ACTIVE' as const } } }, + ], + }, + ...(AND ? (Array.isArray(AND) ? AND : [AND]) : []), + ], + }; +} + const branchSelect = { id: true, brand_id: true, @@ -176,11 +193,6 @@ function mapBrand(raw: any, requesterId?: string) { } function mapService(raw: any, requesterId?: string) { - const ratingCount = raw.ratings?.length ?? 0; - const ratingAverage = - ratingCount > 0 - ? roundRating(raw.ratings.reduce((sum: number, rating: { value: number }) => sum + rating.value, 0) / ratingCount) - : null; const myRating = requesterId ? raw.ratings?.find((rating: { user_id: string }) => rating.user_id === requesterId)?.value ?? null @@ -206,8 +218,8 @@ function mapService(raw: any, requesterId?: string) { order: img.order, url: buildFileUrl(img.media.storage_path), })), - rating: ratingAverage, - rating_count: ratingCount, + rating: null, + rating_count: 0, my_rating: myRating, created_at: raw.created_at.toISOString(), updated_at: raw.updated_at.toISOString(), @@ -230,7 +242,7 @@ export const listFavorites = async ( orderBy: { created_at: 'desc' }, }), prisma.favoriteService.findMany({ - where: { user_id: userId, service: { status: 'ACTIVE' } }, + where: { user_id: userId, service: publicServiceWhere() }, include: { service: { select: serviceSelect } }, orderBy: { created_at: 'desc' }, }), @@ -319,7 +331,7 @@ export const addFavoriteService = async ( const userId = req.user.sub; const serviceId = req.params['id'] as string; - const service = await prisma.service.findFirst({ where: { id: serviceId, status: 'ACTIVE' }, select: { id: true } }); + const service = await prisma.service.findFirst({ where: publicServiceWhere({ id: serviceId }), select: { id: true } }); if (!service) { const err: AppError = new Error(); diff --git a/src/controllers/marketplace.controller.ts b/src/controllers/marketplace.controller.ts index 084fb5a..0e3b008 100644 --- a/src/controllers/marketplace.controller.ts +++ b/src/controllers/marketplace.controller.ts @@ -11,11 +11,11 @@ export const getMarketplaceFacets = async ( try { const [serviceCategories, brandCategories] = await Promise.all([ prisma.serviceCategory.findMany({ - where: { services: { some: { status: 'ACTIVE' } } }, + where: { services: { some: publicServiceWhere() } }, select: { id: true, key: true, - _count: { select: { services: { where: { status: 'ACTIVE' } } } }, + _count: { select: { services: { where: publicServiceWhere() } } }, }, orderBy: { key: 'asc' }, }), @@ -72,6 +72,25 @@ function shuffle(items: T[]): T[] { return [...items].sort(() => Math.random() - 0.5); } +function publicServiceWhere(extra: Record = {}) { + const { AND, ...rest } = extra as { AND?: unknown } & Record; + const andClauses = [ + { + OR: [ + { branch_id: null }, + { branch: { brand: { status: 'ACTIVE' as const } } }, + ], + }, + ...(AND ? (Array.isArray(AND) ? AND : [AND]) : []), + ]; + + return { + status: 'ACTIVE' as const, + ...rest, + AND: andClauses, + }; +} + const homeBrandSelect = { id: true, name: true, @@ -165,7 +184,6 @@ function mapHomeBrand(raw: any, requesterId?: string) { } function mapHomeService(raw: any, requesterId?: string) { - const summary = ratingSummary(raw.ratings ?? []); const brandSummary = ratingSummary(raw.branch?.brand?.ratings ?? []); return { id: raw.id, @@ -204,8 +222,8 @@ function mapHomeService(raw: any, requesterId?: string) { order: item.order, url: buildFileUrl(item.media.storage_path), })), - rating: summary.rating, - rating_count: summary.rating_count, + rating: null, + rating_count: 0, my_rating: requesterId ? raw.ratings?.find((rating: { user_id: string }) => rating.user_id === requesterId)?.value ?? null : null, @@ -216,8 +234,12 @@ function mapHomeService(raw: any, requesterId?: string) { async function randomServiceIds(limit: number) { return prisma.$queryRaw<{ id: string }[]>` - SELECT id FROM "Service" - WHERE status = 'ACTIVE' + SELECT service.id + FROM "Service" service + LEFT JOIN "Branch" branch ON branch.id = service.branch_id + LEFT JOIN "Brand" brand ON brand.id = branch.brand_id + WHERE service.status = 'ACTIVE' + AND (service.branch_id IS NULL OR brand.status = 'ACTIVE') ORDER BY RANDOM() LIMIT ${limit} `; @@ -236,7 +258,7 @@ async function servicesByIds(ids: string[]) { if (ids.length === 0) return []; const order = new Map(ids.map((id, index) => [id, index])); const services = await prisma.service.findMany({ - where: { id: { in: ids }, status: 'ACTIVE' }, + where: publicServiceWhere({ id: { in: ids } }), select: homeServiceSelect, }); return services.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); @@ -268,11 +290,11 @@ export const getMarketplaceHome = async ( try { const userId = req.user?.sub; - const [randomServiceRows, recentServices, recentBrands, topServicePool, topBrandPool, favoriteServices, favoriteBrands, usoPool] = + const [randomServiceRows, recentServices, recentBrands, topBrandPool, favoriteServices, favoriteBrands, usoPool] = await Promise.all([ randomServiceIds(20), prisma.service.findMany({ - where: { status: 'ACTIVE' }, + where: publicServiceWhere(), select: homeServiceSelect, orderBy: { created_at: 'desc' }, take: 10, @@ -283,12 +305,6 @@ export const getMarketplaceHome = async ( orderBy: { created_at: 'desc' }, take: 10, }), - prisma.service.findMany({ - where: { status: 'ACTIVE', ratings: { some: {} } }, - select: homeServiceSelect, - orderBy: { created_at: 'desc' }, - take: 220, - }), prisma.brand.findMany({ where: { status: 'ACTIVE', ratings: { some: {} } }, select: homeBrandSelect, @@ -297,7 +313,7 @@ export const getMarketplaceHome = async ( }), userId ? prisma.favoriteService.findMany({ - where: { user_id: userId, service: { status: 'ACTIVE' } }, + where: { user_id: userId, service: publicServiceWhere() }, select: { service_id: true, service: { @@ -322,7 +338,7 @@ export const getMarketplaceHome = async ( prisma.user.findMany({ where: { type: 'uso', - OR: [{ brands: { some: { status: 'ACTIVE' } } }, { services: { some: { status: 'ACTIVE' } } }], + OR: [{ brands: { some: { status: 'ACTIVE' } } }, { services: { some: publicServiceWhere() } }], }, select: { id: true, @@ -331,7 +347,7 @@ export const getMarketplaceHome = async ( email: true, avatar_media: { select: { storage_path: true } }, brands: { where: { status: 'ACTIVE' }, select: { ratings: { select: { value: true } } } }, - services: { where: { status: 'ACTIVE' }, select: { ratings: { select: { value: true } } } }, + services: { where: publicServiceWhere(), select: { id: true } }, }, take: 160, }), @@ -353,11 +369,10 @@ export const getMarketplaceHome = async ( const [recommendedServicePool, recommendedBrandPool] = await Promise.all([ serviceCategoryIds.length > 0 ? prisma.service.findMany({ - where: { - status: 'ACTIVE', + where: publicServiceWhere({ service_category_id: { in: serviceCategoryIds }, id: { notIn: favoriteServices.map((favorite: any) => favorite.service_id) }, - }, + }), select: homeServiceSelect, take: 80, }) @@ -379,7 +394,6 @@ export const getMarketplaceHome = async ( .map((user) => { const ratings = [ ...user.brands.flatMap((brand) => brand.ratings), - ...user.services.flatMap((service) => service.ratings), ]; return { id: user.id, @@ -406,7 +420,7 @@ export const getMarketplaceHome = async ( recent_brands: recentBrands.map((brand) => mapHomeBrand(brand, userId)), recommended_services: shuffle(recommendedServicePool).slice(0, 10).map((service) => mapHomeService(service, userId)), recommended_brands: shuffle(recommendedBrandPool).slice(0, 10).map((brand) => mapHomeBrand(brand, userId)), - top_rated_services: topRated(topServicePool).slice(0, 10).map((service) => mapHomeService(service, userId)), + top_rated_services: [], top_rated_brands: topRated(topBrandPool).slice(0, 10).map((brand) => mapHomeBrand(brand, userId)), top_usos: topUsos, }, @@ -517,8 +531,7 @@ export const searchMarketplace = async ( : Promise.resolve([]), include('service') ? prisma.service.findMany({ - where: { - status: 'ACTIVE', + where: publicServiceWhere({ ...(category && { service_category_id: category }), OR: [ { title: { contains: q, mode: 'insensitive' } }, @@ -526,7 +539,7 @@ export const searchMarketplace = async ( { address: { contains: q, mode: 'insensitive' } }, { service_category: { key: { contains: q, mode: 'insensitive' } } }, ], - }, + }), select: { id: true, title: true, @@ -551,7 +564,7 @@ export const searchMarketplace = async ( { OR: [ { brands: { some: { status: 'ACTIVE' } } }, - { services: { some: { status: 'ACTIVE' } } }, + { services: { some: publicServiceWhere() } }, ], }, ], @@ -586,7 +599,7 @@ export const searchMarketplace = async ( : Promise.resolve([]), include('address') ? prisma.service.findMany({ - where: { status: 'ACTIVE', address: { contains: q, mode: 'insensitive' } }, + where: publicServiceWhere({ address: { contains: q, mode: 'insensitive' } }), select: { id: true, title: true, address: true }, take: limit, }) @@ -625,7 +638,8 @@ export const searchMarketplace = async ( href: buildSearchHref('service', service.id), category_id: service.service_category?.id ?? null, category_key: service.service_category?.key ?? null, - ...ratingSummary(service.ratings), + rating: null, + rating_count: 0, })); const userItems = users.map((user) => ({ id: user.id, diff --git a/src/controllers/moderation.controller.ts b/src/controllers/moderation.controller.ts index 137936c..6cfecdf 100644 --- a/src/controllers/moderation.controller.ts +++ b/src/controllers/moderation.controller.ts @@ -224,6 +224,13 @@ export const approveService = async ( return next(err); } + if ('inactiveBrand' in result) { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'service.cannot_approve_inactive_brand'; + return next(err); + } + sendSuccess({ res, status: 200, message: 'service.approved' }); } catch (err) { next(err); diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index bd0aa68..dc0cd7a 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -5,7 +5,7 @@ import { AppError } from '../middlewares/error.middleware'; import { buildFileUrl } from '../services/storage.service'; import { validateAndProcessImage, writeFileToDisk } from '../services/media.service'; import { buildStoragePath, ensureUserStorageDir } from '../services/storage.service'; -import type { CreateServiceInput, UpdateServiceInput, UpsertServiceRatingInput } from '../schemas/service.schema'; +import type { CreateServiceInput, UpdateServiceInput } from '../schemas/service.schema'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -83,6 +83,7 @@ const serviceSelect = { id: true, name: true, owner_id: true, + status: true, logo_media: { select: { id: true, storage_path: true } }, ratings: { select: { @@ -133,6 +134,12 @@ function mapService(raw: any, requesterId?: string) { requesterId ? raw.ratings?.find((rating: { user_id: string }) => rating.user_id === requesterId)?.value ?? null : null; + // Public rating aggregates are suppressed until reservation-based eligibility + // gating is implemented. Only the service owner sees the raw aggregate so they + // can monitor incoming feedback. Other viewers see null/0. + const isOwner = !!requesterId && raw.owner_id === requesterId; + const publicRating = isOwner ? ratingAverage : null; + const publicRatingCount = isOwner ? ratingCount : 0; const brandRatingCount = raw.branch?.brand?.ratings?.length ?? 0; const brandRating = brandRatingCount > 0 @@ -176,8 +183,8 @@ function mapService(raw: any, requesterId?: string) { order: img.order, url: buildFileUrl(img.media.storage_path), })), - rating: ratingAverage, - rating_count: ratingCount, + rating: publicRating, + rating_count: publicRatingCount, my_rating: myRating, created_at: raw.created_at.toISOString(), updated_at: raw.updated_at.toISOString(), @@ -186,6 +193,39 @@ function mapService(raw: any, requesterId?: string) { const SIGNIFICANT_FIELDS = ['title', 'description', 'price', 'price_type', 'duration', 'address', 'branch_id', 'service_category_id'] as const; +// Verify user can attach a service to the given branch: +// either as brand owner or as ACCEPTED team member of the branch's team. +async function validateBranchAccess( + branchId: string, + userId: string, + next: NextFunction, +): Promise { + const branch = await prisma.branch.findUnique({ + where: { id: branchId }, + select: { brand: { select: { owner_id: true } } }, + }); + if (!branch) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.branch_not_found'; + next(err); + return false; + } + if (branch.brand.owner_id === userId) return true; + + const membership = await prisma.teamMember.findFirst({ + where: { team: { branch_id: branchId }, user_id: userId, status: 'ACCEPTED' }, + }); + if (!membership) { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'service.not_branch_member'; + next(err); + return false; + } + return true; +} + // ─── Media upload ───────────────────────────────────────────────────────────── export const uploadServiceMedia = async ( @@ -251,17 +291,9 @@ export const createService = async ( const userId = req.user.sub; const body = req.body as CreateServiceInput; - // Validate branch membership if branch_id provided + // Validate branch access if branch_id provided if (body.branch_id) { - const membership = await prisma.teamMember.findFirst({ - where: { team: { branch_id: body.branch_id }, user_id: userId, status: 'ACCEPTED' }, - }); - if (!membership) { - const err: AppError = new Error(); - err.statusCode = 403; - err.messageKey = 'service.not_branch_member'; - return next(err); - } + if (!(await validateBranchAccess(body.branch_id, userId, next))) return; } // Validate image media ownership @@ -347,6 +379,16 @@ export const listPublicServices = async ( ...(brand_id && !branch_id && !direct_only && { branch: { brand_id } }), ...(owner_id && { owner_id }), ...(direct_only && { branch_id: null }), + // Branch-linked services are only public if their parent brand is ACTIVE. + // Direct services (branch_id IS NULL) are unaffected. + AND: [ + { + OR: [ + { branch_id: null }, + { branch: { brand: { status: 'ACTIVE' as const } } }, + ], + }, + ], ...(q && { OR: [ { title: { contains: q, mode: 'insensitive' as const } }, @@ -404,15 +446,23 @@ export const getServiceById = async ( return next(err); } + const userId = req.user?.sub; + const isOwner = !!userId && service.owner_id === userId; + // Non-ACTIVE services are only visible to their owner - if (service.status !== 'ACTIVE') { - const userId = req.user?.sub; - if (!userId || service.owner_id !== userId) { - const err: AppError = new Error(); - err.statusCode = 404; - err.messageKey = 'service.not_found'; - return next(err); - } + if (service.status !== 'ACTIVE' && !isOwner) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); + } + + // Branch-linked services with a non-ACTIVE parent brand are hidden from non-owners. + if (service.branch && service.branch.brand.status !== 'ACTIVE' && !isOwner) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); } sendSuccess({ res, status: 200, message: 'service.found', data: { service: mapService(service, req.user?.sub) } }); @@ -425,61 +475,16 @@ export const getServiceById = async ( export const upsertServiceRating = async ( req: Request, - res: Response, + _res: Response, next: NextFunction, ): Promise => { try { if (!requireUcr(req, next)) return; - const id = req.params['id'] as string; - const userId = req.user.sub; - const body = req.body as UpsertServiceRatingInput; - - const service = await prisma.service.findUnique({ - where: { id }, - select: { id: true, status: true }, - }); - - if (!service || service.status !== 'ACTIVE') { - const err: AppError = new Error(); - err.statusCode = 404; - err.messageKey = 'service.not_found'; - return next(err); - } - - await prisma.serviceRating.upsert({ - where: { - service_id_user_id: { - service_id: id, - user_id: userId, - }, - }, - update: { value: body.value }, - create: { - service_id: id, - user_id: userId, - value: body.value, - }, - }); - - const updatedService = await prisma.service.findUnique({ - where: { id }, - select: serviceSelect, - }); - - if (!updatedService) { - const err: AppError = new Error(); - err.statusCode = 404; - err.messageKey = 'service.not_found'; - return next(err); - } - - sendSuccess({ - res, - status: 200, - message: 'service.rating_saved', - data: { service: mapService(updatedService, userId) }, - }); + const err: AppError = new Error(); + err.statusCode = 501; + err.messageKey = 'service.rating_not_available'; + return next(err); } catch (err) { next(err); } @@ -501,7 +506,7 @@ export const updateService = async ( const existing = await prisma.service.findUnique({ where: { id }, - select: { owner_id: true, status: true }, + select: { owner_id: true, status: true, branch_id: true, address: true }, }); if (!existing) { @@ -523,6 +528,20 @@ export const updateService = async ( return next(err); } + // If branch_id is being changed, validate access on the new branch + if (body.branch_id !== undefined && body.branch_id !== null) { + if (!(await validateBranchAccess(body.branch_id, userId, next))) return; + } + + const nextBranchId = body.branch_id !== undefined ? body.branch_id : existing.branch_id; + const nextAddress = body.address !== undefined ? body.address : existing.address; + if (!nextBranchId && !nextAddress?.trim()) { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'service.branch_or_address_required'; + return next(err); + } + // Determine if re-moderation is needed (ACTIVE service with significant changes) const hasSignificantFieldChange = SIGNIFICANT_FIELDS.some( (field) => body[field] !== undefined, @@ -623,7 +642,7 @@ export const submitService = async ( const existing = await prisma.service.findUnique({ where: { id }, - select: { owner_id: true, status: true }, + select: { owner_id: true, status: true, branch_id: true }, }); if (!existing) { @@ -646,6 +665,11 @@ export const submitService = async ( return next(err); } + // Re-validate branch access at submit time (membership may have changed) + if (existing.branch_id) { + if (!(await validateBranchAccess(existing.branch_id, userId, next))) return; + } + const service = await prisma.service.update({ where: { id }, data: { status: 'PENDING', rejection_reason: null }, diff --git a/src/lib/rich-text.ts b/src/lib/rich-text.ts new file mode 100644 index 0000000..cd80fb6 --- /dev/null +++ b/src/lib/rich-text.ts @@ -0,0 +1,33 @@ +import sanitizeHtml from 'sanitize-html'; + +// Allowlist matching the frontend Tiptap editor output. +// See next-app/src/lib/rich-text.ts — keep these in sync. +const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { + allowedTags: [ + 'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', + 'ol', 'ul', 'li', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'blockquote', 'hr', + 'a', 'span', + ], + allowedAttributes: { + a: ['href', 'target', 'rel'], + span: ['style'], + '*': ['class'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'tel'], + allowedSchemesAppliedToAttributes: ['href'], + disallowedTagsMode: 'discard', + allowedStyles: { + span: { + color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/], + }, + }, + transformTags: { + a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }), + }, +}; + +export function sanitizeRichHtml(html: string): string { + return sanitizeHtml(html, SANITIZE_OPTIONS); +} diff --git a/src/routes/v1/service.route.ts b/src/routes/v1/service.route.ts index c70ccbe..cafa2eb 100644 --- a/src/routes/v1/service.route.ts +++ b/src/routes/v1/service.route.ts @@ -21,7 +21,6 @@ import { validate } from '../../middlewares/validate.middleware'; import { AppError } from '../../middlewares/error.middleware'; import { createServiceSchema, - upsertServiceRatingSchema, updateServiceSchema, } from '../../schemas/service.schema'; @@ -67,6 +66,6 @@ router.post('/services/:id/pause', authenticate, pauseService); router.post('/services/:id/resume', authenticate, resumeService); router.post('/services/:id/archive', authenticate, archiveService); router.post('/services/:id/unarchive', authenticate, unarchiveService); -router.put('/services/:id/rating', authenticate, validate(upsertServiceRatingSchema), upsertServiceRating); +router.put('/services/:id/rating', authenticate, upsertServiceRating); export default router; diff --git a/src/schemas/brand.schema.ts b/src/schemas/brand.schema.ts index d98256a..76f84e3 100644 --- a/src/schemas/brand.schema.ts +++ b/src/schemas/brand.schema.ts @@ -1,4 +1,8 @@ import { z } from 'zod'; +import { sanitizeRichHtml } from '../lib/rich-text'; + +const richDescription = (max: number) => + z.string().max(max).trim().transform((v) => sanitizeRichHtml(v)); // ─── Branch ─────────────────────────────────────────────────────────────────── @@ -12,7 +16,7 @@ const branchBreakSchema = z.object({ export const createBranchSchema = z .object({ name: z.string().min(2, 'Branch name must be at least 2 characters').max(100).trim(), - description: z.string().max(1000).trim().optional(), + description: richDescription(1000).optional(), address1: z.string().min(2).max(200).trim(), address2: z.string().max(200).trim().optional(), phone: z.string().regex(/^\+?\d{7,20}$/, 'Invalid phone number').optional(), @@ -33,7 +37,7 @@ export type CreateBranchInput = z.infer; export const updateBranchSchema = z .object({ name: z.string().min(2).max(100).trim().optional(), - description: z.string().max(1000).trim().nullable().optional(), + description: richDescription(1000).nullable().optional(), address1: z.string().min(2).max(200).trim().optional(), address2: z.string().max(200).trim().nullable().optional(), phone: z.string().regex(/^\+?\d{7,20}$/, 'Invalid phone number').nullable().optional(), @@ -82,7 +86,7 @@ const socialLinksShape = { export const createBrandSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters').max(100).trim(), - description: z.string().max(1000).trim().optional(), + description: richDescription(1000).optional(), categoryIds: z.array(z.string().cuid('Invalid category id')).optional().default([]), logo_media_id: z.string().cuid('Invalid media id').optional(), gallery_media_ids: z.array(z.string().cuid('Invalid media id')).optional().default([]), @@ -94,7 +98,7 @@ export type CreateBrandInput = z.infer; export const updateBrandSchema = z.object({ name: z.string().min(2).max(100).trim().optional(), - description: z.string().max(1000).trim().nullable().optional(), + description: richDescription(1000).nullable().optional(), categoryIds: z.array(z.string().cuid('Invalid category id')).optional(), logo_media_id: z.string().cuid('Invalid media id').nullable().optional(), gallery_media_ids: z.array(z.string().cuid('Invalid media id')).optional(), diff --git a/src/schemas/service.schema.ts b/src/schemas/service.schema.ts index 0fccc14..9c93041 100644 --- a/src/schemas/service.schema.ts +++ b/src/schemas/service.schema.ts @@ -1,8 +1,12 @@ import { z } from 'zod'; +import { sanitizeRichHtml } from '../lib/rich-text'; + +const richDescription = (max: number) => + z.string().max(max).trim().transform((v) => sanitizeRichHtml(v)); export const createServiceSchema = z.object({ title: z.string().min(2, 'Title must be at least 2 characters').max(150).trim(), - description: z.string().max(2000).trim().optional(), + description: richDescription(2000).optional(), branch_id: z.string().cuid('Invalid branch id').nullable().optional(), service_category_id: z.string().cuid('Invalid category id').nullable().optional(), price: z.number().positive().optional(), @@ -19,7 +23,7 @@ export type CreateServiceInput = z.infer; export const updateServiceSchema = z.object({ title: z.string().min(2).max(150).trim().optional(), - description: z.string().max(2000).trim().nullable().optional(), + description: richDescription(2000).nullable().optional(), branch_id: z.string().cuid('Invalid branch id').nullable().optional(), service_category_id: z.string().cuid('Invalid category id').nullable().optional(), price: z.number().positive().nullable().optional(), diff --git a/src/services/moderation.service.ts b/src/services/moderation.service.ts index 497a614..70b4480 100644 --- a/src/services/moderation.service.ts +++ b/src/services/moderation.service.ts @@ -447,11 +447,22 @@ export async function approveService( ) { const service = await prisma.service.findUnique({ where: { id: serviceId }, - select: { id: true, status: true, owner_id: true, title: true }, + select: { + id: true, + status: true, + owner_id: true, + title: true, + branch: { select: { brand: { select: { status: true } } } }, + }, }); if (!service) return { notFound: true } as const; if (service.status !== 'PENDING') return { wrongStatus: true } as const; + // Branch-linked services may only be approved if their parent brand is ACTIVE. + // Direct services (no branch) bypass this check. + if (service.branch && service.branch.brand.status !== 'ACTIVE') { + return { inactiveBrand: true } as const; + } await prisma.$transaction([ prisma.service.update({ From eee4a94d478f35688e3a58529ce3cddf8ce63bdb Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Thu, 7 May 2026 12:36:58 +0400 Subject: [PATCH 24/25] feat: Team management part1, simplify service ownership by removing branches and linking services directly to brands, and add team member service assignment functionality --- .../migration.sql | 34 + .../migration.sql | 20 + .../migration.sql | 4 + prisma/schema.prisma | 266 +++--- src/controllers/brand.controller.ts | 130 ++- src/controllers/favorite.controller.ts | 18 +- src/controllers/marketplace.controller.ts | 55 +- src/controllers/service.controller.ts | 133 ++- .../team-service-assignment.controller.ts | 815 ++++++++++++++++++ src/controllers/team.controller.ts | 41 +- src/routes/v1/index.ts | 2 + .../v1/team-service-assignment.route.ts | 59 ++ src/schemas/service.schema.ts | 8 +- src/services/moderation.service.ts | 96 +-- 14 files changed, 1370 insertions(+), 311 deletions(-) create mode 100644 prisma/migrations/20260506115200_add_team_member_service_assignment/migration.sql create mode 100644 prisma/migrations/20260506124500_move_services_to_brand/migration.sql create mode 100644 prisma/migrations/20260506153000_add_assignment_proposal_fields/migration.sql create mode 100644 src/controllers/team-service-assignment.controller.ts create mode 100644 src/routes/v1/team-service-assignment.route.ts diff --git a/prisma/migrations/20260506115200_add_team_member_service_assignment/migration.sql b/prisma/migrations/20260506115200_add_team_member_service_assignment/migration.sql new file mode 100644 index 0000000..69840cf --- /dev/null +++ b/prisma/migrations/20260506115200_add_team_member_service_assignment/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "ServiceAssignmentStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED', 'WITHDRAWN'); + +-- CreateEnum +CREATE TYPE "ServiceAssignmentInitiator" AS ENUM ('MEMBER', 'OWNER'); + +-- CreateTable +CREATE TABLE "TeamMemberServiceAssignment" ( + "id" TEXT NOT NULL, + "team_member_id" TEXT NOT NULL, + "service_id" TEXT NOT NULL, + "status" "ServiceAssignmentStatus" NOT NULL DEFAULT 'PENDING', + "initiated_by" "ServiceAssignmentInitiator" NOT NULL, + "responded_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeamMemberServiceAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TeamMemberServiceAssignment_service_id_idx" ON "TeamMemberServiceAssignment"("service_id"); + +-- CreateIndex +CREATE INDEX "TeamMemberServiceAssignment_status_idx" ON "TeamMemberServiceAssignment"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMemberServiceAssignment_team_member_id_service_id_key" ON "TeamMemberServiceAssignment"("team_member_id", "service_id"); + +-- AddForeignKey +ALTER TABLE "TeamMemberServiceAssignment" ADD CONSTRAINT "TeamMemberServiceAssignment_team_member_id_fkey" FOREIGN KEY ("team_member_id") REFERENCES "TeamMember"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMemberServiceAssignment" ADD CONSTRAINT "TeamMemberServiceAssignment_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260506124500_move_services_to_brand/migration.sql b/prisma/migrations/20260506124500_move_services_to_brand/migration.sql new file mode 100644 index 0000000..63c6fde --- /dev/null +++ b/prisma/migrations/20260506124500_move_services_to_brand/migration.sql @@ -0,0 +1,20 @@ +-- Move service ownership context from Branch to Brand. +-- Existing branch-linked services inherit their branch's brand before branch_id is removed. +ALTER TABLE "Service" ADD COLUMN "brand_id" TEXT; + +UPDATE "Service" service +SET "brand_id" = branch."brand_id" +FROM "Branch" branch +WHERE service."branch_id" = branch."id"; + +DROP INDEX IF EXISTS "Service_branch_id_idx"; + +ALTER TABLE "Service" DROP CONSTRAINT IF EXISTS "Service_branch_id_fkey"; +ALTER TABLE "Service" DROP COLUMN "branch_id"; + +CREATE INDEX "Service_brand_id_idx" ON "Service"("brand_id"); + +ALTER TABLE "Service" +ADD CONSTRAINT "Service_brand_id_fkey" +FOREIGN KEY ("brand_id") REFERENCES "Brand"("id") +ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260506153000_add_assignment_proposal_fields/migration.sql b/prisma/migrations/20260506153000_add_assignment_proposal_fields/migration.sql new file mode 100644 index 0000000..3aaa80e --- /dev/null +++ b/prisma/migrations/20260506153000_add_assignment_proposal_fields/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "TeamMemberServiceAssignment" +ADD COLUMN "proposed_description" TEXT, +ADD COLUMN "proposed_price" DECIMAL(10, 2), +ADD COLUMN "proposed_duration" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c83ea92..6798ef4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,20 @@ enum TeamMemberRole { MEMBER } +// ─── Team-member ↔ Service assignment ───────────────────────────────────────── + +enum ServiceAssignmentStatus { + PENDING + ACCEPTED + REJECTED + WITHDRAWN +} + +enum ServiceAssignmentInitiator { + MEMBER + OWNER +} + enum MediaKind { avatar document @@ -63,49 +77,49 @@ enum ModerationOutcome { } model User { - id String @id @default(cuid()) - first_name String - last_name String - birthday DateTime @db.Date + id String @id @default(cuid()) + first_name String + last_name String + birthday DateTime @db.Date // Full E.164 international number, e.g. "+9941234567". Prefix and local // number are accepted separately from the frontend and combined server-side // before storage so uniqueness is enforced on the complete international number. - phone String? @unique - country String - email String @unique - hashed_password String - type UserType @default(uso) - phone_verified Boolean @default(false) - email_verified Boolean @default(false) - avatar_media_id String? - instagram_url String? - facebook_url String? - youtube_url String? - whatsapp_url String? - linkedin_url String? - x_url String? - website_url String? + phone String? @unique + country String + email String @unique + hashed_password String + type UserType @default(uso) + phone_verified Boolean @default(false) + email_verified Boolean @default(false) + avatar_media_id String? + instagram_url String? + facebook_url String? + youtube_url String? + whatsapp_url String? + linkedin_url String? + x_url String? + website_url String? + + created_at DateTime @default(now()) + updated_at DateTime @updatedAt - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - refresh_tokens RefreshToken[] - media Media[] - brands Brand[] - services Service[] - brand_transfers BrandTransfer[] @relation("BrandTransferRequester") - received_transfers BrandTransfer[] @relation("BrandTransferRecipient") - brand_ratings BrandRating[] - service_ratings ServiceRating[] - favorite_brands FavoriteBrand[] - favorite_services FavoriteService[] - notifications Notification[] - teams_created Team[] @relation("TeamCreator") - team_memberships TeamMember[] @relation("TeamMemberUser") - invitations_sent TeamMember[] @relation("TeamMemberInviter") - feed_dismissals NotificationFeedDismissal[] - feed_state NotificationFeedState? - moderation_reviews ModerationReview[] @relation("ModerationReviewer") + refresh_tokens RefreshToken[] + media Media[] + brands Brand[] + services Service[] + brand_transfers BrandTransfer[] @relation("BrandTransferRequester") + received_transfers BrandTransfer[] @relation("BrandTransferRecipient") + brand_ratings BrandRating[] + service_ratings ServiceRating[] + favorite_brands FavoriteBrand[] + favorite_services FavoriteService[] + notifications Notification[] + teams_created Team[] @relation("TeamCreator") + team_memberships TeamMember[] @relation("TeamMemberUser") + invitations_sent TeamMember[] @relation("TeamMemberInviter") + feed_dismissals NotificationFeedDismissal[] + feed_state NotificationFeedState? + moderation_reviews ModerationReview[] @relation("ModerationReviewer") avatar_media Media? @relation("UserAvatar", fields: [avatar_media_id], references: [id], onDelete: SetNull) @@ -131,10 +145,10 @@ model Media { owner User @relation(fields: [owner_id], references: [id], onDelete: Cascade) - avatar_for User[] @relation("UserAvatar") - brand_logo Brand[] @relation("BrandLogo") - brand_gallery BrandGallery[] @relation("BrandGallery") - branch_covers Branch[] @relation("BranchCover") + avatar_for User[] @relation("UserAvatar") + brand_logo Brand[] @relation("BrandLogo") + brand_gallery BrandGallery[] @relation("BrandGallery") + branch_covers Branch[] @relation("BranchCover") service_images ServiceMedia[] @@index([owner_id]) @@ -169,27 +183,28 @@ model ServiceCategory { } model Brand { - id String @id @default(cuid()) - name String - description String? + id String @id @default(cuid()) + name String + description String? status BrandStatus @default(PENDING) rejection_reason String? - owner_id String - logo_media_id String? - instagram_url String? - facebook_url String? - youtube_url String? - whatsapp_url String? - linkedin_url String? - x_url String? - website_url String? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - owner User @relation(fields: [owner_id], references: [id], onDelete: Cascade) - logo_media Media? @relation("BrandLogo", fields: [logo_media_id], references: [id], onDelete: SetNull) + owner_id String + logo_media_id String? + instagram_url String? + facebook_url String? + youtube_url String? + whatsapp_url String? + linkedin_url String? + x_url String? + website_url String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + owner User @relation(fields: [owner_id], references: [id], onDelete: Cascade) + logo_media Media? @relation("BrandLogo", fields: [logo_media_id], references: [id], onDelete: SetNull) gallery BrandGallery[] branches Branch[] + services Service[] categories BrandCategory[] ratings BrandRating[] favorites FavoriteBrand[] @@ -231,7 +246,6 @@ model Branch { breaks BranchBreak[] team Team? @relation("BranchTeam") cover_media Media? @relation("BranchCover", fields: [cover_media_id], references: [id], onDelete: SetNull) - services Service[] @@index([brand_id]) } @@ -246,17 +260,17 @@ model BranchBreak { } model BrandTransfer { - id String @id @default(cuid()) - brand_id String - from_user_id String - to_user_id String - status BrandTransferStatus @default(PENDING) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - brand Brand @relation(fields: [brand_id], references: [id], onDelete: Cascade) - from_user User @relation("BrandTransferRequester", fields: [from_user_id], references: [id], onDelete: Cascade) - to_user User @relation("BrandTransferRecipient", fields: [to_user_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + brand_id String + from_user_id String + to_user_id String + status BrandTransferStatus @default(PENDING) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + brand Brand @relation(fields: [brand_id], references: [id], onDelete: Cascade) + from_user User @relation("BrandTransferRequester", fields: [from_user_id], references: [id], onDelete: Cascade) + to_user User @relation("BrandTransferRecipient", fields: [to_user_id], references: [id], onDelete: Cascade) @@index([brand_id]) @@index([from_user_id]) @@ -349,15 +363,15 @@ model RefreshToken { // One Team per Branch. Created automatically when a Branch is created. // The brand owner is automatically the first ACCEPTED OWNER member. model Team { - id String @id @default(cuid()) - branch_id String @unique - created_by_user_id String - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(cuid()) + branch_id String @unique + created_by_user_id String + created_at DateTime @default(now()) + updated_at DateTime @updatedAt - branch Branch @relation("BranchTeam", fields: [branch_id], references: [id], onDelete: Cascade) - creator User @relation("TeamCreator", fields: [created_by_user_id], references: [id], onDelete: Cascade) - members TeamMember[] + branch Branch @relation("BranchTeam", fields: [branch_id], references: [id], onDelete: Cascade) + creator User @relation("TeamCreator", fields: [created_by_user_id], references: [id], onDelete: Cascade) + members TeamMember[] @@index([created_by_user_id]) } @@ -366,19 +380,21 @@ model Team { // Re-inviting a REJECTED/REMOVED user updates this same row (upsert pattern). // The OWNER row is immutable through normal team endpoints. model TeamMember { - id String @id @default(cuid()) - team_id String - user_id String - invited_by_user_id String - status TeamMemberStatus @default(PENDING) - role TeamMemberRole @default(MEMBER) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + id String @id @default(cuid()) + team_id String + user_id String + invited_by_user_id String + status TeamMemberStatus @default(PENDING) + role TeamMemberRole @default(MEMBER) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt team Team @relation(fields: [team_id], references: [id], onDelete: Cascade) user User @relation("TeamMemberUser", fields: [user_id], references: [id], onDelete: Cascade) invited_by User @relation("TeamMemberInviter", fields: [invited_by_user_id], references: [id], onDelete: Cascade) + service_assignments TeamMemberServiceAssignment[] + // One active membership record per (team, user). Re-invites use upsert. @@unique([team_id, user_id]) @@index([team_id]) @@ -388,30 +404,31 @@ model TeamMember { // ─── Service models ─────────────────────────────────────────────────────────── model Service { - id String @id @default(cuid()) - title String - description String? - owner_id String - branch_id String? - service_category_id String? - price Decimal? @db.Decimal(10, 2) - price_type PriceType @default(FIXED) - duration Int? - address String? - status ServiceStatus @default(DRAFT) - rejection_reason String? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - owner User @relation(fields: [owner_id], references: [id], onDelete: Cascade) - branch Branch? @relation(fields: [branch_id], references: [id], onDelete: SetNull) - service_category ServiceCategory? @relation(fields: [service_category_id], references: [id], onDelete: SetNull) - images ServiceMedia[] - ratings ServiceRating[] - favorites FavoriteService[] + id String @id @default(cuid()) + title String + description String? + owner_id String + brand_id String? + service_category_id String? + price Decimal? @db.Decimal(10, 2) + price_type PriceType @default(FIXED) + duration Int? + address String? + status ServiceStatus @default(DRAFT) + rejection_reason String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + owner User @relation(fields: [owner_id], references: [id], onDelete: Cascade) + brand Brand? @relation(fields: [brand_id], references: [id], onDelete: SetNull) + service_category ServiceCategory? @relation(fields: [service_category_id], references: [id], onDelete: SetNull) + images ServiceMedia[] + ratings ServiceRating[] + favorites FavoriteService[] + member_assignments TeamMemberServiceAssignment[] @@index([owner_id]) - @@index([branch_id]) + @@index([brand_id]) @@index([status]) @@index([service_category_id]) } @@ -472,6 +489,33 @@ model ServiceMedia { @@index([service_id]) } +// ─── Team-member service assignment ────────────────────────────────────────── + +// Bridges a TeamMember to one of its brand's Services. Either side can +// initiate (initiated_by). Status tracks the bidirectional approval flow: +// MEMBER-initiated → owner approves; OWNER-initiated → member accepts. +// One active record per (team_member, service); re-requests upsert this row. +model TeamMemberServiceAssignment { + id String @id @default(cuid()) + team_member_id String + service_id String + status ServiceAssignmentStatus @default(PENDING) + initiated_by ServiceAssignmentInitiator + proposed_description String? + proposed_price Decimal? @db.Decimal(10, 2) + proposed_duration Int? + responded_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + team_member TeamMember @relation(fields: [team_member_id], references: [id], onDelete: Cascade) + service Service @relation(fields: [service_id], references: [id], onDelete: Cascade) + + @@unique([team_member_id, service_id]) + @@index([service_id]) + @@index([status]) +} + // ─── Moderation Review ─────────────────────────────────────────────────────── model ModerationReview { @@ -481,7 +525,7 @@ model ModerationReview { reviewer_id String outcome ModerationOutcome rejection_reason String? - checklist Json? // Array of {key: string, label: string, passed: boolean} + checklist Json? // Array of {key: string, label: string, passed: boolean} created_at DateTime @default(now()) reviewer User @relation("ModerationReviewer", fields: [reviewer_id], references: [id], onDelete: Cascade) diff --git a/src/controllers/brand.controller.ts b/src/controllers/brand.controller.ts index 381f7a8..9664ea1 100644 --- a/src/controllers/brand.controller.ts +++ b/src/controllers/brand.controller.ts @@ -143,6 +143,15 @@ const brandSelect = { website_url: true, created_at: true, updated_at: true, + owner: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + avatar_media: { select: { storage_path: true } }, + }, + }, categories: { select: { id: true, key: true } }, logo_media: { select: { id: true, storage_path: true } }, gallery: { @@ -163,6 +172,13 @@ const brandSelect = { } as const; type BrandRaw = Awaited> & { + owner?: { + id: string; + first_name: string; + last_name: string; + email: string; + avatar_media?: { storage_path: string } | null; + }; categories: { id: string; key: string }[]; logo_media: { id: string; storage_path: string } | null; gallery: { id: string; media_id: string; order: number; media: { id: string; storage_path: string } }[]; @@ -187,6 +203,17 @@ function mapBrand(raw: BrandRaw, requesterId?: string) { status: raw.status, rejection_reason: raw.rejection_reason ?? undefined, owner_id: raw.owner_id, + owner: raw.owner + ? { + id: raw.owner.id, + first_name: raw.owner.first_name, + last_name: raw.owner.last_name, + email: raw.owner.email, + avatar_url: raw.owner.avatar_media + ? buildFileUrl(raw.owner.avatar_media.storage_path) + : null, + } + : undefined, logo_url: raw.logo_media ? buildFileUrl(raw.logo_media.storage_path) : undefined, categories: raw.categories, gallery: raw.gallery.map((g) => ({ @@ -454,13 +481,62 @@ export const getMyBrands = async ( const userId = req.user.sub; + // Brands the user owns OR is an ACCEPTED MEMBER of (via any of its branches' teams). const brands = await prisma.brand.findMany({ - where: { owner_id: userId }, - select: brandSelect, + where: { + OR: [ + { owner_id: userId }, + { + branches: { + some: { + team: { + members: { + some: { user_id: userId, status: 'ACCEPTED', role: 'MEMBER' }, + }, + }, + }, + }, + }, + ], + }, + select: { + ...brandSelect, + branches: { + select: { + id: true, + team: { + select: { + members: { + where: { user_id: userId, status: 'ACCEPTED' }, + select: { id: true, role: true }, + }, + }, + }, + }, + }, + }, orderBy: { created_at: 'desc' }, }); - sendSuccess({ res, status: 200, message: 'brand.list', data: { brands: brands.map((b) => mapBrand(b as BrandRaw, userId)) } }); + const mapped = brands.map((b) => { + const isOwner = b.owner_id === userId; + const memberBranch = b.branches.find( + (br) => br.team?.members.some((m) => m.role === 'MEMBER'), + ); + const viewer_role: 'OWNER' | 'MEMBER' = isOwner ? 'OWNER' : 'MEMBER'; + const viewer_branch_id = isOwner ? null : memberBranch?.id ?? null; + // Strip the helper `branches` shape — getMyBrands historically returned only + // top-level brand fields. We attach viewer metadata instead. + const { branches: _branches, ...brandFields } = b; + void _branches; + return { + ...mapBrand(brandFields as BrandRaw, userId), + viewer_role, + viewer_branch_id, + }; + }); + + sendSuccess({ res, status: 200, message: 'brand.list', data: { brands: mapped } }); } catch (err) { next(err); } @@ -473,10 +549,28 @@ export const getBrandById = async ( ): Promise => { try { const id = req.params['id'] as string; + const requesterId = req.user?.sub; const brand = await prisma.brand.findUnique({ where: { id }, - select: { ...brandSelect, branches: { select: branchSelect } }, + select: { + ...brandSelect, + branches: { + select: { + ...branchSelect, + team: { + select: { + members: { + where: requesterId + ? { user_id: requesterId, status: 'ACCEPTED' } + : { id: '__never__' }, + select: { id: true, role: true }, + }, + }, + }, + }, + }, + }, }); if (!brand) { @@ -486,11 +580,29 @@ export const getBrandById = async ( return next(err); } - // Non-ACTIVE brands are visible to their owner and admins. + // Resolve viewer role + member branch (if any) before status gating, since + // a member of a non-ACTIVE brand should still see the brand from inside. + const isOwner = !!requesterId && brand.owner_id === requesterId; + const memberBranch = requesterId + ? brand.branches.find( + (br) => + br.team?.members.some( + (m: { id: string; role: string }) => m.role === 'MEMBER', + ), + ) + : undefined; + const viewer_role: 'OWNER' | 'MEMBER' | 'NONE' = isOwner + ? 'OWNER' + : memberBranch + ? 'MEMBER' + : 'NONE'; + const viewer_branch_id = memberBranch?.id ?? null; + + // Non-ACTIVE brands are visible to their owner, admins, and accepted members. if (brand.status !== 'ACTIVE') { - const userId = req.user?.sub; const isAdmin = req.user?.type === 'admin'; - if (!isAdmin && (!userId || brand.owner_id !== userId)) { + const isMember = viewer_role === 'MEMBER'; + if (!isAdmin && !isOwner && !isMember) { const err: AppError = new Error(); err.statusCode = 404; err.messageKey = 'brand.not_found'; @@ -504,8 +616,10 @@ export const getBrandById = async ( message: 'brand.found', data: { brand: { - ...mapBrand(brand as BrandRaw, req.user?.sub), + ...mapBrand(brand as BrandRaw, requesterId), branches: brand.branches.map((b) => mapBranch(b as BranchRaw)), + viewer_role, + viewer_branch_id, }, }, }); diff --git a/src/controllers/favorite.controller.ts b/src/controllers/favorite.controller.ts index c4422e8..c362c9c 100644 --- a/src/controllers/favorite.controller.ts +++ b/src/controllers/favorite.controller.ts @@ -27,8 +27,8 @@ function publicServiceWhere(extra: Record = {}) { AND: [ { OR: [ - { branch_id: null }, - { branch: { brand: { status: 'ACTIVE' as const } } }, + { brand_id: null }, + { brand: { status: 'ACTIVE' as const } }, ], }, ...(AND ? (Array.isArray(AND) ? AND : [AND]) : []), @@ -96,7 +96,7 @@ const serviceSelect = { title: true, description: true, owner_id: true, - branch_id: true, + brand_id: true, service_category_id: true, service_category: { select: { id: true, key: true } }, price: true, @@ -122,12 +122,7 @@ const serviceSelect = { user_id: true, }, }, - branch: { - select: { - id: true, - brand: { select: brandSelect }, - }, - }, + brand: { select: brandSelect }, } as const; function mapBranch(raw: any) { @@ -203,7 +198,8 @@ function mapService(raw: any, requesterId?: string) { title: raw.title, description: raw.description ?? undefined, owner_id: raw.owner_id, - branch_id: raw.branch_id ?? null, + brand_id: raw.brand_id ?? null, + brand: raw.brand ? mapBrand(raw.brand, requesterId) : null, service_category_id: raw.service_category_id ?? null, service_category: raw.service_category ?? null, price: raw.price ? Number(raw.price) : null, @@ -250,7 +246,7 @@ export const listFavorites = async ( const serviceBrandMap = new Map(); for (const favorite of favoriteServices) { - const brand = (favorite.service as any).branch?.brand; + const brand = (favorite.service as any).brand; if (brand) serviceBrandMap.set(brand.id, brand); } diff --git a/src/controllers/marketplace.controller.ts b/src/controllers/marketplace.controller.ts index 0e3b008..dcb30cb 100644 --- a/src/controllers/marketplace.controller.ts +++ b/src/controllers/marketplace.controller.ts @@ -77,8 +77,8 @@ function publicServiceWhere(extra: Record = {}) { const andClauses = [ { OR: [ - { branch_id: null }, - { branch: { brand: { status: 'ACTIVE' as const } } }, + { brand_id: null }, + { brand: { status: 'ACTIVE' as const } }, ], }, ...(AND ? (Array.isArray(AND) ? AND : [AND]) : []), @@ -118,7 +118,7 @@ const homeServiceSelect = { title: true, description: true, owner_id: true, - branch_id: true, + brand_id: true, service_category_id: true, service_category: { select: { id: true, key: true } }, price: true, @@ -139,20 +139,13 @@ const homeServiceSelect = { orderBy: { order: 'asc' as const }, }, ratings: { select: { value: true, user_id: true } }, - branch: { + brand: { select: { id: true, - brand_id: true, name: true, - brand: { - select: { - id: true, - name: true, - owner_id: true, - logo_media: { select: { storage_path: true } }, - ratings: { select: { value: true } }, - }, - }, + owner_id: true, + logo_media: { select: { storage_path: true } }, + ratings: { select: { value: true } }, }, }, } as const; @@ -184,28 +177,21 @@ function mapHomeBrand(raw: any, requesterId?: string) { } function mapHomeService(raw: any, requesterId?: string) { - const brandSummary = ratingSummary(raw.branch?.brand?.ratings ?? []); + const brandSummary = ratingSummary(raw.brand?.ratings ?? []); return { id: raw.id, title: raw.title, description: raw.description ?? undefined, owner_id: raw.owner_id, - branch_id: raw.branch_id ?? null, - branch: raw.branch + brand_id: raw.brand_id ?? null, + brand: raw.brand ? { - id: raw.branch.id, - brand_id: raw.branch.brand_id, - name: raw.branch.name, - brand: raw.branch.brand - ? { - id: raw.branch.brand.id, - name: raw.branch.brand.name, - owner_id: raw.branch.brand.owner_id, - logo_url: imageUrl(raw.branch.brand.logo_media?.storage_path) ?? undefined, - rating: brandSummary.rating, - rating_count: brandSummary.rating_count, - } - : null, + id: raw.brand.id, + name: raw.brand.name, + owner_id: raw.brand.owner_id, + logo_url: imageUrl(raw.brand.logo_media?.storage_path) ?? undefined, + rating: brandSummary.rating, + rating_count: brandSummary.rating_count, } : null, service_category_id: raw.service_category_id ?? null, @@ -236,10 +222,9 @@ async function randomServiceIds(limit: number) { return prisma.$queryRaw<{ id: string }[]>` SELECT service.id FROM "Service" service - LEFT JOIN "Branch" branch ON branch.id = service.branch_id - LEFT JOIN "Brand" brand ON brand.id = branch.brand_id + LEFT JOIN "Brand" brand ON brand.id = service.brand_id WHERE service.status = 'ACTIVE' - AND (service.branch_id IS NULL OR brand.status = 'ACTIVE') + AND (service.brand_id IS NULL OR brand.status = 'ACTIVE') ORDER BY RANDOM() LIMIT ${limit} `; @@ -319,7 +304,7 @@ export const getMarketplaceHome = async ( service: { select: { service_category_id: true, - branch: { select: { brand: { select: { categories: { select: { id: true } } } } } }, + brand: { select: { categories: { select: { id: true } } } }, }, }, }, @@ -361,7 +346,7 @@ export const getMarketplaceHome = async ( ...new Set([ ...favoriteBrands.flatMap((favorite: any) => favorite.brand.categories.map((category: { id: string }) => category.id)), ...favoriteServices.flatMap((favorite: any) => - favorite.service.branch?.brand?.categories.map((category: { id: string }) => category.id) ?? [], + favorite.service.brand?.categories.map((category: { id: string }) => category.id) ?? [], ), ]), ]; diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index dc0cd7a..7bc9136 100644 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -70,27 +70,20 @@ const serviceSelect = { title: true, description: true, owner_id: true, - branch_id: true, + brand_id: true, service_category_id: true, service_category: { select: { id: true, key: true } }, - branch: { + brand: { select: { id: true, - brand_id: true, name: true, - brand: { + owner_id: true, + status: true, + logo_media: { select: { id: true, storage_path: true } }, + ratings: { select: { - id: true, - name: true, - owner_id: true, - status: true, - logo_media: { select: { id: true, storage_path: true } }, - ratings: { - select: { - value: true, - user_id: true, - }, - }, + value: true, + user_id: true, }, }, }, @@ -140,10 +133,10 @@ function mapService(raw: any, requesterId?: string) { const isOwner = !!requesterId && raw.owner_id === requesterId; const publicRating = isOwner ? ratingAverage : null; const publicRatingCount = isOwner ? ratingCount : 0; - const brandRatingCount = raw.branch?.brand?.ratings?.length ?? 0; + const brandRatingCount = raw.brand?.ratings?.length ?? 0; const brandRating = brandRatingCount > 0 - ? roundRating(raw.branch.brand.ratings.reduce((sum: number, rating: { value: number }) => sum + rating.value, 0) / brandRatingCount) + ? roundRating(raw.brand.ratings.reduce((sum: number, rating: { value: number }) => sum + rating.value, 0) / brandRatingCount) : null; return { @@ -151,22 +144,15 @@ function mapService(raw: any, requesterId?: string) { title: raw.title, description: raw.description ?? undefined, owner_id: raw.owner_id, - branch_id: raw.branch_id ?? null, - branch: raw.branch + brand_id: raw.brand_id ?? null, + brand: raw.brand ? { - id: raw.branch.id, - brand_id: raw.branch.brand_id, - name: raw.branch.name, - brand: raw.branch.brand - ? { - id: raw.branch.brand.id, - name: raw.branch.brand.name, - owner_id: raw.branch.brand.owner_id, - logo_url: raw.branch.brand.logo_media ? buildFileUrl(raw.branch.brand.logo_media.storage_path) : undefined, - rating: brandRating, - rating_count: brandRatingCount, - } - : null, + id: raw.brand.id, + name: raw.brand.name, + owner_id: raw.brand.owner_id, + logo_url: raw.brand.logo_media ? buildFileUrl(raw.brand.logo_media.storage_path) : undefined, + rating: brandRating, + rating_count: brandRatingCount, } : null, service_category_id: raw.service_category_id ?? null, @@ -191,35 +177,30 @@ function mapService(raw: any, requesterId?: string) { }; } -const SIGNIFICANT_FIELDS = ['title', 'description', 'price', 'price_type', 'duration', 'address', 'branch_id', 'service_category_id'] as const; +const SIGNIFICANT_FIELDS = ['title', 'description', 'price', 'price_type', 'duration', 'address', 'brand_id', 'service_category_id'] as const; -// Verify user can attach a service to the given branch: -// either as brand owner or as ACCEPTED team member of the branch's team. -async function validateBranchAccess( - branchId: string, +// Verify user can attach a service to the given brand. Brand-owned services are +// created by the owner; team members can later request assignment to them. +async function validateBrandOwnership( + brandId: string, userId: string, next: NextFunction, ): Promise { - const branch = await prisma.branch.findUnique({ - where: { id: branchId }, - select: { brand: { select: { owner_id: true } } }, + const brand = await prisma.brand.findUnique({ + where: { id: brandId }, + select: { owner_id: true }, }); - if (!branch) { + if (!brand) { const err: AppError = new Error(); err.statusCode = 404; - err.messageKey = 'service.branch_not_found'; + err.messageKey = 'brand.not_found'; next(err); return false; } - if (branch.brand.owner_id === userId) return true; - - const membership = await prisma.teamMember.findFirst({ - where: { team: { branch_id: branchId }, user_id: userId, status: 'ACCEPTED' }, - }); - if (!membership) { + if (brand.owner_id !== userId) { const err: AppError = new Error(); err.statusCode = 403; - err.messageKey = 'service.not_branch_member'; + err.messageKey = 'brand.not_owner'; next(err); return false; } @@ -291,9 +272,9 @@ export const createService = async ( const userId = req.user.sub; const body = req.body as CreateServiceInput; - // Validate branch access if branch_id provided - if (body.branch_id) { - if (!(await validateBranchAccess(body.branch_id, userId, next))) return; + // Validate brand ownership if brand_id provided + if (body.brand_id) { + if (!(await validateBrandOwnership(body.brand_id, userId, next))) return; } // Validate image media ownership @@ -305,7 +286,7 @@ export const createService = async ( title: body.title, description: body.description, owner_id: userId, - branch_id: body.branch_id ?? null, + brand_id: body.brand_id ?? null, service_category_id: body.service_category_id ?? null, price: body.price !== undefined ? body.price : null, price_type: body.price_type ?? 'FIXED', @@ -375,17 +356,24 @@ export const listPublicServices = async ( const where = { status: 'ACTIVE' as const, ...(service_category_id && { service_category_id }), - ...(branch_id && { branch_id }), - ...(brand_id && !branch_id && !direct_only && { branch: { brand_id } }), + ...(brand_id && !direct_only && { brand_id }), ...(owner_id && { owner_id }), - ...(direct_only && { branch_id: null }), - // Branch-linked services are only public if their parent brand is ACTIVE. - // Direct services (branch_id IS NULL) are unaffected. + ...(direct_only && { brand_id: null }), + ...(branch_id && { + member_assignments: { + some: { + status: 'ACCEPTED' as const, + team_member: { status: 'ACCEPTED' as const, team: { branch_id } }, + }, + }, + }), + // Brand-linked services are only public if their parent brand is ACTIVE. + // Direct services (brand_id IS NULL) are unaffected. AND: [ { OR: [ - { branch_id: null }, - { branch: { brand: { status: 'ACTIVE' as const } } }, + { brand_id: null }, + { brand: { status: 'ACTIVE' as const } }, ], }, ], @@ -457,8 +445,8 @@ export const getServiceById = async ( return next(err); } - // Branch-linked services with a non-ACTIVE parent brand are hidden from non-owners. - if (service.branch && service.branch.brand.status !== 'ACTIVE' && !isOwner) { + // Brand-linked services with a non-ACTIVE parent brand are hidden from non-owners. + if (service.brand && service.brand.status !== 'ACTIVE' && !isOwner) { const err: AppError = new Error(); err.statusCode = 404; err.messageKey = 'service.not_found'; @@ -506,7 +494,7 @@ export const updateService = async ( const existing = await prisma.service.findUnique({ where: { id }, - select: { owner_id: true, status: true, branch_id: true, address: true }, + select: { owner_id: true, status: true, brand_id: true, address: true }, }); if (!existing) { @@ -528,14 +516,14 @@ export const updateService = async ( return next(err); } - // If branch_id is being changed, validate access on the new branch - if (body.branch_id !== undefined && body.branch_id !== null) { - if (!(await validateBranchAccess(body.branch_id, userId, next))) return; + // If brand_id is being changed, validate ownership on the new brand + if (body.brand_id !== undefined && body.brand_id !== null) { + if (!(await validateBrandOwnership(body.brand_id, userId, next))) return; } - const nextBranchId = body.branch_id !== undefined ? body.branch_id : existing.branch_id; + const nextBrandId = body.brand_id !== undefined ? body.brand_id : existing.brand_id; const nextAddress = body.address !== undefined ? body.address : existing.address; - if (!nextBranchId && !nextAddress?.trim()) { + if (!nextBrandId && !nextAddress?.trim()) { const err: AppError = new Error(); err.statusCode = 400; err.messageKey = 'service.branch_or_address_required'; @@ -562,7 +550,7 @@ export const updateService = async ( data: { ...(body.title !== undefined && { title: body.title }), ...(body.description !== undefined && { description: body.description }), - ...(body.branch_id !== undefined && { branch_id: body.branch_id }), + ...(body.brand_id !== undefined && { brand_id: body.brand_id }), ...(body.service_category_id !== undefined && { service_category_id: body.service_category_id }), ...(body.price !== undefined && { price: body.price }), ...(body.price_type !== undefined && { price_type: body.price_type }), @@ -642,7 +630,7 @@ export const submitService = async ( const existing = await prisma.service.findUnique({ where: { id }, - select: { owner_id: true, status: true, branch_id: true }, + select: { owner_id: true, status: true }, }); if (!existing) { @@ -665,11 +653,6 @@ export const submitService = async ( return next(err); } - // Re-validate branch access at submit time (membership may have changed) - if (existing.branch_id) { - if (!(await validateBranchAccess(existing.branch_id, userId, next))) return; - } - const service = await prisma.service.update({ where: { id }, data: { status: 'PENDING', rejection_reason: null }, diff --git a/src/controllers/team-service-assignment.controller.ts b/src/controllers/team-service-assignment.controller.ts new file mode 100644 index 0000000..f033699 --- /dev/null +++ b/src/controllers/team-service-assignment.controller.ts @@ -0,0 +1,815 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../lib/prisma'; +import { sendSuccess } from '../utils/response'; +import { AppError } from '../middlewares/error.middleware'; +import { buildFileUrl } from '../services/storage.service'; +import { ServiceStatus } from '../generated/prisma/enums'; + +// Statuses that hide a service from member-facing assignment UI. +const HIDDEN_FROM_MEMBERS: ServiceStatus[] = [ + ServiceStatus.DRAFT, + ServiceStatus.REJECTED, + ServiceStatus.ARCHIVED, +]; + +function requireUso(req: Request, next: NextFunction): boolean { + if (req.user.type !== 'uso') { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'errors.forbidden'; + next(err); + return false; + } + return true; +} + +const assignmentSelect = { + id: true, + team_member_id: true, + service_id: true, + status: true, + initiated_by: true, + proposed_description: true, + proposed_price: true, + proposed_duration: true, + responded_at: true, + created_at: true, + updated_at: true, +} as const; + +type AssignmentRow = { + id: string; + team_member_id: string; + service_id: string; + status: string; + initiated_by: string; + proposed_description: string | null; + proposed_price: unknown | null; + proposed_duration: number | null; + responded_at: Date | null; + created_at: Date; + updated_at: Date; +}; + +function mapAssignment(a: AssignmentRow) { + return { + id: a.id, + team_member_id: a.team_member_id, + service_id: a.service_id, + status: a.status, + initiated_by: a.initiated_by, + proposed_description: a.proposed_description ?? null, + proposed_price: a.proposed_price == null ? null : Number(a.proposed_price), + proposed_duration: a.proposed_duration ?? null, + responded_at: a.responded_at?.toISOString() ?? null, + created_at: a.created_at.toISOString(), + updated_at: a.updated_at.toISOString(), + }; +} + +function coerceOptionalText(value: unknown, fallback: string | null): string | null { + if (typeof value !== 'string') return fallback; + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, 4000) : null; +} + +function coerceOptionalPrice(value: unknown, fallback: unknown | null): number | null { + if (value === '' || value === null || value === undefined) { + return fallback == null ? null : Number(fallback); + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return fallback == null ? null : Number(fallback); + return Math.round(parsed * 100) / 100; +} + +function coerceOptionalDuration(value: unknown, fallback: number | null): number | null { + if (value === '' || value === null || value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(Math.round(parsed), 1440); +} + +// ─── POST /brands/:brandId/services/:serviceId/assignment-request ───────────── +// Authenticated member requests a self-assignment on a brand-owned service. + +export const requestAssignment = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUso(req, next)) return; + + const brandId = req.params['brandId'] as string; + const serviceId = req.params['serviceId'] as string; + const userId = req.user.sub; + + const service = await prisma.service.findUnique({ + where: { id: serviceId }, + select: { + id: true, + title: true, + description: true, + price: true, + duration: true, + status: true, + brand_id: true, + }, + }); + + if (!service || service.brand_id !== brandId) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'service.not_found'; + return next(err); + } + + if ((HIDDEN_FROM_MEMBERS as readonly string[]).includes(service.status)) { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'service.not_assignable'; + return next(err); + } + + const brand = await prisma.brand.findUnique({ + where: { id: brandId }, + select: { owner_id: true, name: true }, + }); + const isBrandOwner = brand?.owner_id === userId; + + // Caller must be an ACCEPTED team participant of any branch under this + // brand. Owners can also provide services, so OWNER membership is valid. + const membership = await prisma.teamMember.findFirst({ + where: { + user_id: userId, + status: 'ACCEPTED', + role: isBrandOwner ? { in: ['OWNER', 'MEMBER'] } : 'MEMBER', + team: { branch: { brand_id: brandId } }, + }, + select: { id: true }, + }); + + if (!membership) { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'assignment.not_team_member'; + return next(err); + } + + const existing = await prisma.teamMemberServiceAssignment.findUnique({ + where: { + team_member_id_service_id: { + team_member_id: membership.id, + service_id: serviceId, + }, + }, + select: assignmentSelect, + }); + + if (existing && existing.status === 'PENDING') { + const err: AppError = new Error(); + err.statusCode = 409; + err.messageKey = 'assignment.already_pending'; + return next(err); + } + if (existing && existing.status === 'ACCEPTED') { + const err: AppError = new Error(); + err.statusCode = 409; + err.messageKey = 'assignment.already_accepted'; + return next(err); + } + + const nextStatus = isBrandOwner ? 'ACCEPTED' : 'PENDING'; + const nextInitiator = isBrandOwner ? 'OWNER' : 'MEMBER'; + const respondedAt = isBrandOwner ? new Date() : null; + const requestBody = (req.body ?? {}) as Record; + const proposedDescription = coerceOptionalText( + requestBody['proposed_description'], + service.description ?? null, + ); + const proposedPrice = coerceOptionalPrice(requestBody['proposed_price'], service.price); + const proposedDuration = coerceOptionalDuration( + requestBody['proposed_duration'], + service.duration ?? null, + ); + + // Re-request: REJECTED / WITHDRAWN → reset; otherwise create. + const assignment = existing + ? await prisma.teamMemberServiceAssignment.update({ + where: { id: existing.id }, + data: { + status: nextStatus, + initiated_by: nextInitiator, + proposed_description: proposedDescription, + proposed_price: proposedPrice, + proposed_duration: proposedDuration, + responded_at: respondedAt, + }, + select: assignmentSelect, + }) + : await prisma.teamMemberServiceAssignment.create({ + data: { + team_member_id: membership.id, + service_id: serviceId, + status: nextStatus, + initiated_by: nextInitiator, + proposed_description: proposedDescription, + proposed_price: proposedPrice, + proposed_duration: proposedDuration, + responded_at: respondedAt, + }, + select: assignmentSelect, + }); + + // Notify the brand owner + const requester = await prisma.user.findUnique({ + where: { id: userId }, + select: { first_name: true, last_name: true }, + }); + if (brand && requester && !isBrandOwner) { + await prisma.notification.create({ + data: { + user_id: brand.owner_id, + type: 'service_assignment_requested', + title: 'Service assignment request', + body: `${requester.first_name} ${requester.last_name} requested to be assigned to a service in "${brand.name}".`, + data: { + assignment_id: assignment.id, + service_id: serviceId, + brand_id: brandId, + proposed_description: proposedDescription, + proposed_price: proposedPrice, + proposed_duration: proposedDuration, + }, + }, + }); + } + + sendSuccess({ + res, + status: 201, + message: 'assignment.requested', + data: { assignment: mapAssignment(assignment as AssignmentRow) }, + }); + } catch (err) { + next(err); + } +}; + +// ─── PATCH /team-member-services/:assignmentId/approve ──────────────────────── +// Brand owner approves a PENDING member-initiated assignment. + +export const approveAssignment = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUso(req, next)) return; + + const assignmentId = req.params['assignmentId'] as string; + const userId = req.user.sub; + + const assignment = await prisma.teamMemberServiceAssignment.findUnique({ + where: { id: assignmentId }, + select: { + ...assignmentSelect, + team_member: { + select: { + user_id: true, + team: { select: { branch: { select: { brand: { select: { id: true, owner_id: true, name: true } } } } } }, + }, + }, + service: { select: { id: true, title: true } }, + }, + }); + + if (!assignment) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'assignment.not_found'; + return next(err); + } + + const brandOwnerId = assignment.team_member.team.branch.brand.owner_id; + if (brandOwnerId !== userId) { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'assignment.not_brand_owner'; + return next(err); + } + + if (assignment.status !== 'PENDING') { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'assignment.not_pending'; + return next(err); + } + + const updated = await prisma.teamMemberServiceAssignment.update({ + where: { id: assignmentId }, + data: { status: 'ACCEPTED', responded_at: new Date() }, + select: assignmentSelect, + }); + + await prisma.notification.create({ + data: { + user_id: assignment.team_member.user_id, + type: 'service_assignment_approved', + title: 'Service assignment approved', + body: `Your request for "${assignment.service.title}" was approved.`, + data: { + assignment_id: assignmentId, + service_id: assignment.service.id, + brand_id: assignment.team_member.team.branch.brand.id, + }, + }, + }); + + sendSuccess({ + res, + status: 200, + message: 'assignment.approved', + data: { assignment: mapAssignment(updated as AssignmentRow) }, + }); + } catch (err) { + next(err); + } +}; + +// ─── PATCH /team-member-services/:assignmentId/reject ───────────────────────── +// Brand owner rejects a PENDING member-initiated assignment. + +export const rejectAssignment = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUso(req, next)) return; + + const assignmentId = req.params['assignmentId'] as string; + const userId = req.user.sub; + + const assignment = await prisma.teamMemberServiceAssignment.findUnique({ + where: { id: assignmentId }, + select: { + ...assignmentSelect, + team_member: { + select: { + user_id: true, + team: { select: { branch: { select: { brand: { select: { id: true, owner_id: true } } } } } }, + }, + }, + service: { select: { id: true, title: true } }, + }, + }); + + if (!assignment) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'assignment.not_found'; + return next(err); + } + + const brandOwnerId = assignment.team_member.team.branch.brand.owner_id; + if (brandOwnerId !== userId) { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'assignment.not_brand_owner'; + return next(err); + } + + if (assignment.status !== 'PENDING') { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'assignment.not_pending'; + return next(err); + } + + const updated = await prisma.teamMemberServiceAssignment.update({ + where: { id: assignmentId }, + data: { status: 'REJECTED', responded_at: new Date() }, + select: assignmentSelect, + }); + + await prisma.notification.create({ + data: { + user_id: assignment.team_member.user_id, + type: 'service_assignment_rejected', + title: 'Service assignment rejected', + body: `Your request for "${assignment.service.title}" was rejected.`, + data: { + assignment_id: assignmentId, + service_id: assignment.service.id, + brand_id: assignment.team_member.team.branch.brand.id, + }, + }, + }); + + sendSuccess({ + res, + status: 200, + message: 'assignment.rejected', + data: { assignment: mapAssignment(updated as AssignmentRow) }, + }); + } catch (err) { + next(err); + } +}; + +// ─── DELETE /team-member-services/:assignmentId ─────────────────────────────── +// Withdraw / remove. Member can withdraw own PENDING or step away from ACCEPTED. +// Brand owner can remove any record (sets status WITHDRAWN to keep audit trail). + +export const withdrawAssignment = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUso(req, next)) return; + + const assignmentId = req.params['assignmentId'] as string; + const userId = req.user.sub; + + const assignment = await prisma.teamMemberServiceAssignment.findUnique({ + where: { id: assignmentId }, + select: { + ...assignmentSelect, + team_member: { + select: { + user_id: true, + team: { select: { branch: { select: { brand: { select: { id: true, owner_id: true } } } } } }, + }, + }, + }, + }); + + if (!assignment) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'assignment.not_found'; + return next(err); + } + + const isMember = assignment.team_member.user_id === userId; + const isOwner = assignment.team_member.team.branch.brand.owner_id === userId; + if (!isMember && !isOwner) { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'assignment.forbidden'; + return next(err); + } + + if (assignment.status === 'WITHDRAWN' || assignment.status === 'REJECTED') { + const err: AppError = new Error(); + err.statusCode = 400; + err.messageKey = 'assignment.already_inactive'; + return next(err); + } + + const updated = await prisma.teamMemberServiceAssignment.update({ + where: { id: assignmentId }, + data: { status: 'WITHDRAWN', responded_at: new Date() }, + select: assignmentSelect, + }); + + sendSuccess({ + res, + status: 200, + message: 'assignment.withdrawn', + data: { assignment: mapAssignment(updated as AssignmentRow) }, + }); + } catch (err) { + next(err); + } +}; + +// ─── GET /brands/:brandId/service-assignment-requests ─────────────────────── +// Pending member proposals for the brand owner, including proposed values. + +export const listBrandAssignmentRequests = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUso(req, next)) return; + + const brandId = req.params['brandId'] as string; + const userId = req.user.sub; + + const brand = await prisma.brand.findUnique({ + where: { id: brandId }, + select: { owner_id: true }, + }); + if (!brand) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'brand.not_found'; + return next(err); + } + if (brand.owner_id !== userId) { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'assignment.not_brand_owner'; + return next(err); + } + + const rows = await prisma.teamMemberServiceAssignment.findMany({ + where: { + status: 'PENDING', + initiated_by: 'MEMBER', + team_member: { team: { branch: { brand_id: brandId } } }, + }, + select: { + ...assignmentSelect, + service: { + select: { + id: true, + title: true, + description: true, + price: true, + price_type: true, + duration: true, + images: { + select: { id: true, order: true, media: { select: { storage_path: true } } }, + orderBy: { order: 'asc' as const }, + }, + }, + }, + team_member: { + select: { + id: true, + user: { + select: { + id: true, + first_name: true, + last_name: true, + email: true, + avatar_media: { select: { storage_path: true } }, + }, + }, + team: { select: { branch: { select: { id: true, name: true } } } }, + }, + }, + }, + orderBy: { created_at: 'desc' }, + }); + + sendSuccess({ + res, + status: 200, + message: 'assignment.requests', + data: { + requests: rows.map((r) => ({ + assignment: mapAssignment(r as AssignmentRow), + service: { + id: r.service.id, + title: r.service.title, + description: r.service.description ?? null, + price: r.service.price ? Number(r.service.price) : null, + price_type: r.service.price_type, + duration: r.service.duration ?? null, + images: r.service.images.map((img) => ({ + id: img.id, + order: img.order, + url: buildFileUrl(img.media.storage_path), + })), + }, + team_member: { + id: r.team_member.id, + user_id: r.team_member.user.id, + first_name: r.team_member.user.first_name, + last_name: r.team_member.user.last_name, + email: r.team_member.user.email, + avatar_url: r.team_member.user.avatar_media + ? buildFileUrl(r.team_member.user.avatar_media.storage_path) + : null, + }, + branch: r.team_member.team.branch, + })), + }, + }); + } catch (err) { + next(err); + } +}; + +// ─── GET /services/assigned/mine ────────────────────────────────────────────── +// All ACCEPTED assignments for the calling member, with service + brand context. + +export const listMyAssignedServices = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUso(req, next)) return; + + const userId = req.user.sub; + + const rows = await prisma.teamMemberServiceAssignment.findMany({ + where: { + status: 'ACCEPTED', + team_member: { user_id: userId, status: 'ACCEPTED' }, + }, + select: { + ...assignmentSelect, + service: { + select: { + id: true, + title: true, + description: true, + price: true, + price_type: true, + duration: true, + status: true, + images: { + select: { id: true, order: true, media: { select: { storage_path: true } } }, + orderBy: { order: 'asc' as const }, + }, + brand: { + select: { + id: true, + name: true, + logo_media: { select: { storage_path: true } }, + }, + }, + }, + }, + team_member: { + select: { + team: { select: { branch: { select: { id: true, name: true } } } }, + }, + }, + }, + orderBy: { updated_at: 'desc' }, + }); + + sendSuccess({ + res, + status: 200, + message: 'assignment.list', + data: { + assignments: rows.map((r) => ({ + ...mapAssignment(r as AssignmentRow), + service: { + id: r.service.id, + title: r.service.title, + description: r.service.description ?? null, + price: r.service.price ? Number(r.service.price) : null, + price_type: r.service.price_type, + duration: r.service.duration ?? null, + status: r.service.status, + images: r.service.images.map((img) => ({ + id: img.id, + order: img.order, + url: buildFileUrl(img.media.storage_path), + })), + brand: r.service.brand + ? { + id: r.service.brand.id, + name: r.service.brand.name, + logo_url: r.service.brand.logo_media + ? buildFileUrl(r.service.brand.logo_media.storage_path) + : null, + } + : null, + branch: r.team_member.team.branch, + }, + })), + }, + }); + } catch (err) { + next(err); + } +}; + +// ─── GET /brands/:brandId/assignable-services ───────────────────────────────── +// Brand-owned services visible to a team member for self-assignment. +// Each row carries the caller's current assignment record (if any). + +export const listAssignableServices = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + if (!requireUso(req, next)) return; + + const brandId = req.params['brandId'] as string; + const userId = req.user.sub; + + const brand = await prisma.brand.findUnique({ + where: { id: brandId }, + select: { id: true, owner_id: true }, + }); + if (!brand) { + const err: AppError = new Error(); + err.statusCode = 404; + err.messageKey = 'brand.not_found'; + return next(err); + } + + const isBrandOwner = brand.owner_id === userId; + + // Caller must be an ACCEPTED team participant of any branch within this + // brand. Owners can self-assign as service providers too. + const membership = await prisma.teamMember.findFirst({ + where: { + user_id: userId, + status: 'ACCEPTED', + role: isBrandOwner ? { in: ['OWNER', 'MEMBER'] } : 'MEMBER', + team: { branch: { brand_id: brandId } }, + }, + select: { id: true }, + }); + if (!membership) { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'assignment.not_team_member'; + return next(err); + } + + const services = await prisma.service.findMany({ + where: { + brand_id: brandId, + status: { notIn: HIDDEN_FROM_MEMBERS }, + }, + select: { + id: true, + title: true, + description: true, + price: true, + price_type: true, + duration: true, + status: true, + brand_id: true, + images: { + select: { id: true, order: true, media: { select: { storage_path: true } } }, + orderBy: { order: 'asc' as const }, + }, + member_assignments: { + select: { + ...assignmentSelect, + team_member: { + select: { + status: true, + team: { select: { branch_id: true } }, + }, + }, + }, + }, + }, + orderBy: { created_at: 'desc' }, + }); + + sendSuccess({ + res, + status: 200, + message: 'service.list', + data: { + services: services.map((s) => { + const acceptedAssignments = s.member_assignments.filter( + (assignment) => + assignment.status === 'ACCEPTED' && + assignment.team_member.status === 'ACCEPTED', + ); + const assignedBranchIds = new Set( + acceptedAssignments.map( + (assignment) => assignment.team_member.team.branch_id, + ), + ); + const myAssignment = s.member_assignments.find( + (assignment) => assignment.team_member_id === membership.id, + ); + + return { + id: s.id, + title: s.title, + description: s.description ?? null, + price: s.price ? Number(s.price) : null, + price_type: s.price_type, + duration: s.duration ?? null, + status: s.status, + assigned_team_members_count: acceptedAssignments.length, + assigned_branches_count: assignedBranchIds.size, + images: s.images.map((img) => ({ + id: img.id, + order: img.order, + url: buildFileUrl(img.media.storage_path), + })), + my_assignment: + myAssignment != null + ? mapAssignment(myAssignment as AssignmentRow) + : null, + }; + }), + }, + }); + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/team.controller.ts b/src/controllers/team.controller.ts index c867718..798c54b 100644 --- a/src/controllers/team.controller.ts +++ b/src/controllers/team.controller.ts @@ -82,7 +82,8 @@ const teamMemberSelect = { } as const; // ─── GET /brands/:id/team-workspace ────────────────────────────────────────── -// Returns brand summary + all branches with their team state. Owner only. +// Returns brand summary + all branches with their team state. Brand owners and +// accepted brand team members can read it; mutations remain owner-only. export const getTeamWorkspace = async ( req: Request, @@ -135,7 +136,20 @@ export const getTeamWorkspace = async ( return next(err); } - if (!requireOwner(brand.owner_id, userId, next)) return; + const canView = + brand.owner_id === userId || + brand.branches.some((branch) => + (branch.team?.members ?? []).some( + (member) => member.user_id === userId && member.status === 'ACCEPTED', + ), + ); + + if (!canView) { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'brand.not_owner'; + return next(err); + } const workspace = { brand_id: brand.id, @@ -175,7 +189,8 @@ export const getTeamWorkspace = async ( }; // ─── GET /brands/:id/branches/:branchId/team ───────────────────────────────── -// Returns one branch's team in detail. Owner only. +// Returns one branch's team in detail. Brand owners and accepted brand team +// members can read it; mutations remain owner-only. export const getBranchTeam = async ( req: Request, @@ -201,7 +216,25 @@ export const getBranchTeam = async ( return next(err); } - if (!requireOwner(brand.owner_id, userId, next)) return; + const canView = + brand.owner_id === userId || + Boolean( + await prisma.teamMember.findFirst({ + where: { + user_id: userId, + status: 'ACCEPTED', + team: { branch: { brand_id: brandId } }, + }, + select: { id: true }, + }), + ); + + if (!canView) { + const err: AppError = new Error(); + err.statusCode = 403; + err.messageKey = 'brand.not_owner'; + return next(err); + } const branch = await prisma.branch.findUnique({ where: { id: branchId }, diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index 6891572..3c76190 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -10,6 +10,7 @@ import serviceRoute from './service.route'; import moderationRoute from './moderation.route'; import marketplaceRoute from './marketplace.route'; import favoriteRoute from './favorite.route'; +import teamServiceAssignmentRoute from './team-service-assignment.route'; const router: Router = Router(); @@ -21,6 +22,7 @@ router.use('/', brandRoute); router.use('/notifications', notificationRoute); router.use('/', teamRoute); router.use('/', serviceRoute); +router.use('/', teamServiceAssignmentRoute); router.use('/', moderationRoute); router.use('/', marketplaceRoute); router.use('/', favoriteRoute); diff --git a/src/routes/v1/team-service-assignment.route.ts b/src/routes/v1/team-service-assignment.route.ts new file mode 100644 index 0000000..525eee4 --- /dev/null +++ b/src/routes/v1/team-service-assignment.route.ts @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import { + requestAssignment, + approveAssignment, + rejectAssignment, + withdrawAssignment, + listBrandAssignmentRequests, + listMyAssignedServices, + listAssignableServices, +} from '../../controllers/team-service-assignment.controller'; +import { authenticate } from '../../middlewares/auth.middleware'; + +const router: Router = Router(); + +// ─── Member-side ────────────────────────────────────────────────────────────── + +router.get( + '/brands/:brandId/assignable-services', + authenticate, + listAssignableServices, +); + +router.post( + '/brands/:brandId/services/:serviceId/assignment-request', + authenticate, + requestAssignment, +); + +router.get('/services/assigned/mine', authenticate, listMyAssignedServices); + +// ─── Owner-side ─────────────────────────────────────────────────────────────── + +router.patch( + '/team-member-services/:assignmentId/approve', + authenticate, + approveAssignment, +); + +router.get( + '/brands/:brandId/service-assignment-requests', + authenticate, + listBrandAssignmentRequests, +); + +router.patch( + '/team-member-services/:assignmentId/reject', + authenticate, + rejectAssignment, +); + +// ─── Either side ────────────────────────────────────────────────────────────── + +router.delete( + '/team-member-services/:assignmentId', + authenticate, + withdrawAssignment, +); + +export default router; diff --git a/src/schemas/service.schema.ts b/src/schemas/service.schema.ts index 9c93041..c1f5f64 100644 --- a/src/schemas/service.schema.ts +++ b/src/schemas/service.schema.ts @@ -7,7 +7,7 @@ const richDescription = (max: number) => export const createServiceSchema = z.object({ title: z.string().min(2, 'Title must be at least 2 characters').max(150).trim(), description: richDescription(2000).optional(), - branch_id: z.string().cuid('Invalid branch id').nullable().optional(), + brand_id: z.string().cuid('Invalid brand id').nullable().optional(), service_category_id: z.string().cuid('Invalid category id').nullable().optional(), price: z.number().positive().optional(), price_type: z.enum(['FIXED', 'STARTING_FROM', 'FREE']).default('FIXED'), @@ -15,8 +15,8 @@ export const createServiceSchema = z.object({ address: z.string().max(500).trim().optional(), image_media_ids: z.array(z.string().cuid('Invalid media id')).optional().default([]), }).refine( - (data) => data.branch_id || data.address, - { message: 'Either branch_id or address is required for an individual service', path: ['address'] }, + (data) => data.brand_id || data.address, + { message: 'Either brand_id or address is required for an individual service', path: ['address'] }, ); export type CreateServiceInput = z.infer; @@ -24,7 +24,7 @@ export type CreateServiceInput = z.infer; export const updateServiceSchema = z.object({ title: z.string().min(2).max(150).trim().optional(), description: richDescription(2000).nullable().optional(), - branch_id: z.string().cuid('Invalid branch id').nullable().optional(), + brand_id: z.string().cuid('Invalid brand id').nullable().optional(), service_category_id: z.string().cuid('Invalid category id').nullable().optional(), price: z.number().positive().nullable().optional(), price_type: z.enum(['FIXED', 'STARTING_FROM', 'FREE']).optional(), diff --git a/src/services/moderation.service.ts b/src/services/moderation.service.ts index 70b4480..3dcdf42 100644 --- a/src/services/moderation.service.ts +++ b/src/services/moderation.service.ts @@ -72,20 +72,12 @@ export async function getModerationQueue(type?: 'brand' | 'service') { avatar_media: { select: { storage_path: true } }, }, }, - branch: { + brand: { select: { id: true, name: true, - address1: true, - address2: true, - brand: { - select: { - id: true, - name: true, - logo_media: { select: { storage_path: true } }, - ratings: { select: { value: true } }, - }, - }, + logo_media: { select: { storage_path: true } }, + ratings: { select: { value: true } }, }, }, }, @@ -124,20 +116,20 @@ export async function getModerationQueue(type?: 'brand' | 'service') { })); const serviceItems = services.map((s) => ({ - ...(s.branch?.brand + ...(s.brand ? { brand: { - id: s.branch.brand.id, - name: s.branch.brand.name, - logo_url: s.branch.brand.logo_media - ? buildFileUrl(s.branch.brand.logo_media.storage_path) + id: s.brand.id, + name: s.brand.name, + logo_url: s.brand.logo_media + ? buildFileUrl(s.brand.logo_media.storage_path) : null, rating: - s.branch.brand.ratings.length > 0 - ? s.branch.brand.ratings.reduce((sum, rating) => sum + rating.value, 0) / - s.branch.brand.ratings.length + s.brand.ratings.length > 0 + ? s.brand.ratings.reduce((sum: number, rating: { value: number }) => sum + rating.value, 0) / + s.brand.ratings.length : null, - rating_count: s.branch.brand.ratings.length, + rating_count: s.brand.ratings.length, }, } : {}), @@ -147,17 +139,8 @@ export async function getModerationQueue(type?: 'brand' | 'service') { description: s.description ?? undefined, status: s.status, service_category: s.service_category ?? null, - address: s.branch - ? [s.branch.address1, s.branch.address2].filter(Boolean).join(', ') - : (s.address ?? null), - branch: s.branch - ? { - id: s.branch.id, - name: s.branch.name, - address1: s.branch.address1, - address2: s.branch.address2, - } - : null, + address: s.address ?? null, + branch: null, price: s.price ? Number(s.price) : null, price_type: s.price_type, owner: mapOwner(s.owner), @@ -280,20 +263,12 @@ export async function getServiceModerationDetail(serviceId: string) { }, orderBy: { order: 'asc' }, }, - branch: { + brand: { select: { id: true, name: true, - address1: true, - address2: true, - brand: { - select: { - id: true, - name: true, - logo_media: { select: { storage_path: true } }, - ratings: { select: { value: true } }, - }, - }, + logo_media: { select: { storage_path: true } }, + ratings: { select: { value: true } }, }, }, service_category: { select: { id: true, key: true } }, @@ -325,27 +300,22 @@ export async function getServiceModerationDetail(serviceId: string) { order: img.order, url: buildFileUrl(img.media.storage_path), })), - branch: service.branch + brand: service.brand ? { - id: service.branch.id, - name: service.branch.name, - address1: service.branch.address1, - address2: service.branch.address2 ?? undefined, - brand: { - id: service.branch.brand.id, - name: service.branch.brand.name, - logo_url: service.branch.brand.logo_media - ? buildFileUrl(service.branch.brand.logo_media.storage_path) + id: service.brand.id, + name: service.brand.name, + logo_url: service.brand.logo_media + ? buildFileUrl(service.brand.logo_media.storage_path) + : null, + rating: + service.brand.ratings.length > 0 + ? service.brand.ratings.reduce((sum: number, rating: { value: number }) => sum + rating.value, 0) / + service.brand.ratings.length : null, - rating: - service.branch.brand.ratings.length > 0 - ? service.branch.brand.ratings.reduce((sum, rating) => sum + rating.value, 0) / - service.branch.brand.ratings.length - : null, - rating_count: service.branch.brand.ratings.length, - }, + rating_count: service.brand.ratings.length, } : null, + branch: null, created_at: service.created_at.toISOString(), updated_at: service.updated_at.toISOString(), }; @@ -452,15 +422,15 @@ export async function approveService( status: true, owner_id: true, title: true, - branch: { select: { brand: { select: { status: true } } } }, + brand: { select: { status: true } }, }, }); if (!service) return { notFound: true } as const; if (service.status !== 'PENDING') return { wrongStatus: true } as const; - // Branch-linked services may only be approved if their parent brand is ACTIVE. - // Direct services (no branch) bypass this check. - if (service.branch && service.branch.brand.status !== 'ACTIVE') { + // Brand-linked services may only be approved if their parent brand is ACTIVE. + // Direct services (no brand) bypass this check. + if (service.brand && service.brand.status !== 'ACTIVE') { return { inactiveBrand: true } as const; } From 334ee419dce37b85c27d0a329837b430e8e5eb5a Mon Sep 17 00:00:00 2001 From: Vugar Safarzada Date: Tue, 19 May 2026 11:46:18 +0400 Subject: [PATCH 25/25] fix: update brand_id logic in service creation and correct whitespace formatting in seed logs --- src/lib/seed-marketplace.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/seed-marketplace.ts b/src/lib/seed-marketplace.ts index ad9ead1..945f187 100644 --- a/src/lib/seed-marketplace.ts +++ b/src/lib/seed-marketplace.ts @@ -606,7 +606,7 @@ async function seedUser( title: svcDef.title, description: svcDef.description, owner_id: userId, - branch_id: branchId, + brand_id: branchId ? (brandId ?? null) : null, service_category_id: catId, price: svcDef.price ?? null, price_type: svcDef.price_type, @@ -634,9 +634,9 @@ async function seedUser( console.log( ` ✓ ${userDef.first_name} ${userDef.last_name}` + - (userDef.has_brand ? ` → ${userDef.brand_name}` : ' → direct-only owner') + - ` | ${userDef.branches.length} branches | ${userDef.services.length} brand services` + - ` | ${userDef.direct_services.length} direct services`, + (userDef.has_brand ? ` → ${userDef.brand_name}` : ' → direct-only owner') + + ` | ${userDef.branches.length} branches | ${userDef.services.length} brand services` + + ` | ${userDef.direct_services.length} direct services`, ); return { userId, brandId, serviceIds }; @@ -765,9 +765,9 @@ async function main(): Promise { console.log( `\nDone. Created ${totals.users} users, ${totals.brands} brands,` + - ` ${totals.branches} branches, ${totals.brandServices} brand services,` + - ` ${totals.directServices} direct user services, ${totals.brandRatings} brand ratings,` + - ` ${totals.serviceRatings} service ratings.`, + ` ${totals.branches} branches, ${totals.brandServices} brand services,` + + ` ${totals.directServices} direct user services, ${totals.brandRatings} brand ratings,` + + ` ${totals.serviceRatings} service ratings.`, ); }