From 1514601bac1dc219a8b6663db3f658a773808742 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 18 Jun 2026 10:14:07 +1000 Subject: [PATCH 01/22] PM-2648: Support wrapped PM role lookups for application emails What was broken PM users could still be missed by copilot application emails when the Identity API returned role lists or role subjects in the legacy wrapped response shape. Root cause The PM recipient lookup only parsed direct array/direct subjects payloads and requested subjects with the newer selector parameter. Legacy Identity responses expose role data under result.content and role subjects under result.content.subjects, so the lookup could resolve no PM subjects and skip the notification. What was changed Updated MemberService role-subject parsing to support direct and legacy wrapped Identity responses, include the legacy fields=subjects expansion parameter, and accept subjectId as well as subjectID/userId when normalizing recipients. Any added/updated tests Added MemberService coverage for legacy wrapped Project Manager role and subject responses to verify PM recipients are still resolved for copilot application notifications. --- src/shared/services/member.service.spec.ts | 59 +++++++++++ src/shared/services/member.service.ts | 112 ++++++++++++++++++--- 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/src/shared/services/member.service.spec.ts b/src/shared/services/member.service.spec.ts index a032f7b..d09644c 100644 --- a/src/shared/services/member.service.spec.ts +++ b/src/shared/services/member.service.spec.ts @@ -88,6 +88,65 @@ describe('MemberService', () => { ); }); + it('returns subjects from legacy wrapped Identity role responses', async () => { + httpServiceMock.get.mockImplementation( + (url: string, options: { params?: { filter?: string } }) => { + if ( + url === 'https://identity.test/roles' && + options.params?.filter === 'roleName=Project Manager' + ) { + return of({ + data: { + result: { + content: [{ id: '201', roleName: 'Project Manager' }], + }, + }, + }); + } + + if (url === 'https://identity.test/roles/201') { + return of({ + data: { + result: { + content: { + subjects: [ + { + subjectId: 5001, + handle: 'pm-one', + email: 'PM.One@Topcoder.com', + }, + ], + }, + }, + }, + }); + } + + return of({ data: {} }); + }, + ); + + const result = await service.getRoleSubjects('Project Manager'); + + expect(result).toEqual([ + { + userId: 5001, + handle: 'pm-one', + email: 'pm.one@topcoder.com', + }, + ]); + expect(httpServiceMock.get).toHaveBeenCalledWith( + 'https://identity.test/roles/201', + expect.objectContaining({ + params: expect.objectContaining({ + fields: 'subjects', + selector: 'subjects', + perPage: 200, + }), + }), + ); + }); + it('returns empty list when role list lookup fails', async () => { httpServiceMock.get.mockImplementation((url: string) => { if (url === 'https://identity.test/roles') { diff --git a/src/shared/services/member.service.ts b/src/shared/services/member.service.ts index b8f68e0..8c82139 100644 --- a/src/shared/services/member.service.ts +++ b/src/shared/services/member.service.ts @@ -13,15 +13,12 @@ export interface MemberRoleRecord { type RoleSubjectRecord = { subjectID?: string | number; + subjectId?: string | number; userId?: string | number; handle?: string; email?: string; }; -type RoleDetailResponse = { - subjects?: RoleSubjectRecord[]; -}; - /** * Member and identity enrichment service. * @@ -213,9 +210,9 @@ export class MemberService { * Looks up role members from Identity API by role name. * * Resolves `/roles?filter=roleName=` and then - * `/roles/:id?selector=subjects&perPage=200`, returning the merged - * `subjects` list - * normalized to `MemberDetail` shape. + * `/roles/:id` with subject expansion, returning the merged `subjects` list + * normalized to `MemberDetail` shape. Supports both direct array responses + * and the legacy `{ result: { content } }` Identity response wrapper. * * Role-detail lookups are tolerant to partial failures; one failing role id * does not prevent subjects from other matching roles from being returned. @@ -250,9 +247,9 @@ export class MemberService { }), ); - const roles = Array.isArray(rolesResponse.data) - ? (rolesResponse.data as MemberRoleRecord[]) - : []; + const roles = this.extractArrayPayload( + rolesResponse.data, + ); const roleIds = roles .filter( @@ -276,14 +273,14 @@ export class MemberService { 'Content-Type': 'application/json', }, params: { + fields: 'subjects', selector: 'subjects', perPage: 200, }, }), ); - const payload = roleResponse.data as RoleDetailResponse; - return Array.isArray(payload?.subjects) ? payload.subjects : []; + return this.extractRoleSubjects(roleResponse.data); }), ); @@ -310,7 +307,7 @@ export class MemberService { .trim() .toLowerCase(); const subjectId = String( - subject.subjectID || subject.userId || '', + subject.subjectID || subject.subjectId || subject.userId || '', ).trim(); const key = email || subjectId; @@ -319,7 +316,8 @@ export class MemberService { } uniqueSubjects.set(key, { - userId: subject.subjectID || subject.userId || null, + userId: + subject.subjectID || subject.subjectId || subject.userId || null, handle: subject.handle || null, email, }); @@ -337,4 +335,90 @@ export class MemberService { async getRoleSubjectsByRoleName(roleName: string): Promise { return this.getRoleSubjects(roleName); } + + /** + * Extracts an array payload from Identity responses. + * + * @param payload Identity API response body in direct or legacy wrapped form. + * @returns Extracted array or an empty array when the shape is unsupported. + */ + private extractArrayPayload(payload: unknown): T[] { + if (Array.isArray(payload)) { + return payload as T[]; + } + + if (!payload || typeof payload !== 'object') { + return []; + } + + const response = payload as { + content?: unknown; + result?: { + content?: unknown; + }; + }; + + if (Array.isArray(response.result?.content)) { + return response.result.content as T[]; + } + + if (Array.isArray(response.content)) { + return response.content as T[]; + } + + return []; + } + + /** + * Extracts role subjects from Identity role-detail responses. + * + * @param payload Identity role-detail body in direct, legacy wrapped, or list form. + * @returns Role subject records or an empty array when no subjects are present. + */ + private extractRoleSubjects(payload: unknown): RoleSubjectRecord[] { + const directSubjects = this.extractSubjectsFromObject(payload); + if (directSubjects.length > 0) { + return directSubjects; + } + + const listPayload = this.extractArrayPayload(payload); + if (listPayload.length > 0) { + return listPayload; + } + + if (!payload || typeof payload !== 'object') { + return []; + } + + const response = payload as { + content?: unknown; + result?: { + content?: unknown; + }; + }; + + const resultSubjects = this.extractSubjectsFromObject( + response.result?.content, + ); + if (resultSubjects.length > 0) { + return resultSubjects; + } + + return this.extractSubjectsFromObject(response.content); + } + + /** + * Extracts a `subjects` array from an object-like response fragment. + * + * @param payload Response fragment that may contain a `subjects` property. + * @returns Subjects array or an empty array when absent. + */ + private extractSubjectsFromObject(payload: unknown): RoleSubjectRecord[] { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return []; + } + + const subjects = (payload as { subjects?: unknown }).subjects; + return Array.isArray(subjects) ? (subjects as RoleSubjectRecord[]) : []; + } } From 9010b5f0c312a8979223a8ea77ddf2a14104de5d Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 24 Jun 2026 16:13:25 +0300 Subject: [PATCH 02/22] initialize prisma migrations --- prisma/migrations/0_init/migration.sql | 935 +++++++++++++++++++++++++ 1 file changed, 935 insertions(+) create mode 100644 prisma/migrations/0_init/migration.sql diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..2750fc3 --- /dev/null +++ b/prisma/migrations/0_init/migration.sql @@ -0,0 +1,935 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "projects"; + +-- CreateEnum +CREATE TYPE "ProjectStatus" AS ENUM ('draft', 'in_review', 'reviewed', 'active', 'completed', 'paused', 'cancelled'); + +-- CreateEnum +CREATE TYPE "InviteStatus" AS ENUM ('pending', 'accepted', 'refused', 'requested', 'request_rejected', 'request_approved', 'canceled'); + +-- CreateEnum +CREATE TYPE "WorkStreamStatus" AS ENUM ('draft', 'reviewed', 'active', 'completed', 'paused'); + +-- CreateEnum +CREATE TYPE "CopilotRequestStatus" AS ENUM ('new', 'approved', 'rejected', 'seeking', 'canceled', 'fulfilled'); + +-- CreateEnum +CREATE TYPE "CopilotApplicationStatus" AS ENUM ('pending', 'invited', 'accepted', 'canceled'); + +-- CreateEnum +CREATE TYPE "CopilotOpportunityStatus" AS ENUM ('active', 'completed', 'canceled'); + +-- CreateEnum +CREATE TYPE "CopilotOpportunityType" AS ENUM ('dev', 'qa', 'design', 'ai', 'datascience'); + +-- CreateEnum +CREATE TYPE "ScopeChangeRequestStatus" AS ENUM ('pending', 'approved', 'rejected', 'activated', 'canceled'); + +-- CreateEnum +CREATE TYPE "CustomerPaymentStatus" AS ENUM ('canceled', 'processing', 'requires_action', 'requires_capture', 'requires_confirmation', 'requires_payment_method', 'succeeded', 'refunded', 'refund_failed', 'refund_pending'); + +-- CreateEnum +CREATE TYPE "ProjectMemberRole" AS ENUM ('manager', 'observer', 'customer', 'copilot', 'account_manager', 'program_manager', 'account_executive', 'solution_architect', 'project_manager'); + +-- CreateEnum +CREATE TYPE "AttachmentType" AS ENUM ('file', 'link'); + +-- CreateEnum +CREATE TYPE "EstimationType" AS ENUM ('fee', 'community', 'topcoder_service'); + +-- CreateEnum +CREATE TYPE "ValueType" AS ENUM ('int', 'double', 'string', 'percentage'); + +-- CreateEnum +CREATE TYPE "TimelineReference" AS ENUM ('project', 'phase', 'product', 'work'); + +-- CreateEnum +CREATE TYPE "PhaseApprovalDecision" AS ENUM ('approve', 'reject'); + +-- CreateEnum +CREATE TYPE "CustomerPaymentCurrency" AS ENUM ('USD', 'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNY', 'COP', 'CRC', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK', 'HTG', 'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'JMD', 'JPY', 'KES', 'KGS', 'KHR', 'KMF', 'KRW', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT', 'MOP', 'MRO', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK', 'NPR', 'NZD', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD', 'SCR', 'SEK', 'SGD', 'SHP', 'SLL', 'SOS', 'SRD', 'STD', 'SZL', 'THB', 'TJS', 'TOP', 'TRY', 'TTD', 'TWD', 'TZS', 'UAH', 'UGX', 'UYU', 'UZS', 'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW'); + +-- CreateTable +CREATE TABLE "projects" ( + "id" BIGSERIAL NOT NULL, + "directProjectId" BIGINT, + "billingAccountId" BIGINT, + "name" TEXT NOT NULL, + "description" TEXT, + "external" JSONB, + "bookmarks" JSONB, + "utm" JSONB, + "estimatedPrice" DECIMAL(10,2), + "actualPrice" DECIMAL(10,2), + "terms" TEXT[] DEFAULT ARRAY[]::TEXT[], + "groups" TEXT[] DEFAULT ARRAY[]::TEXT[], + "type" TEXT NOT NULL, + "status" "ProjectStatus" NOT NULL, + "details" JSONB, + "challengeEligibility" JSONB, + "cancelReason" TEXT, + "templateId" BIGINT, + "version" VARCHAR(3) NOT NULL DEFAULT 'v3', + "lastActivityAt" TIMESTAMP(3) NOT NULL, + "lastActivityUserId" TEXT NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "projects_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_members" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT NOT NULL, + "userId" BIGINT NOT NULL, + "role" "ProjectMemberRole" NOT NULL, + "isPrimary" BOOLEAN NOT NULL DEFAULT false, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_members_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_member_invites" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT NOT NULL, + "userId" BIGINT, + "email" TEXT, + "applicationId" BIGINT, + "role" "ProjectMemberRole" NOT NULL, + "status" "InviteStatus" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + "deletedBy" BIGINT, + + CONSTRAINT "project_member_invites_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_phases" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT NOT NULL, + "name" TEXT, + "description" TEXT, + "requirements" TEXT, + "status" "ProjectStatus", + "startDate" TIMESTAMP(3), + "endDate" TIMESTAMP(3), + "duration" INTEGER, + "budget" DOUBLE PRECISION NOT NULL DEFAULT 0, + "spentBudget" DOUBLE PRECISION NOT NULL DEFAULT 0, + "progress" DOUBLE PRECISION NOT NULL DEFAULT 0, + "details" JSONB NOT NULL DEFAULT '{}', + "order" INTEGER, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_phases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "phase_products" ( + "id" BIGSERIAL NOT NULL, + "phaseId" BIGINT NOT NULL, + "projectId" BIGINT NOT NULL, + "directProjectId" BIGINT, + "billingAccountId" BIGINT, + "templateId" BIGINT NOT NULL DEFAULT 0, + "name" TEXT, + "type" TEXT, + "estimatedPrice" DOUBLE PRECISION NOT NULL DEFAULT 0, + "actualPrice" DOUBLE PRECISION NOT NULL DEFAULT 0, + "details" JSONB NOT NULL DEFAULT '{}', + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "phase_products_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_phase_member" ( + "id" BIGSERIAL NOT NULL, + "phaseId" BIGINT NOT NULL, + "userId" BIGINT NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_phase_member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_phase_approval" ( + "id" BIGSERIAL NOT NULL, + "phaseId" BIGINT NOT NULL, + "decision" "PhaseApprovalDecision" NOT NULL, + "comment" TEXT, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3), + "expectedEndDate" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_phase_approval_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_attachments" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT NOT NULL, + "title" TEXT, + "type" "AttachmentType" NOT NULL, + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "size" INTEGER, + "category" TEXT, + "description" TEXT, + "path" VARCHAR(2048) NOT NULL, + "contentType" TEXT, + "allowedUsers" INTEGER[] DEFAULT ARRAY[]::INTEGER[], + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_attachments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "timelines" ( + "id" BIGSERIAL NOT NULL, + "name" VARCHAR(255) NOT NULL, + "description" VARCHAR(255), + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3), + "reference" "TimelineReference" NOT NULL, + "referenceId" BIGINT NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" BIGINT NOT NULL, + "updatedBy" BIGINT NOT NULL, + + CONSTRAINT "timelines_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "milestones" ( + "id" BIGSERIAL NOT NULL, + "timelineId" BIGINT NOT NULL, + "name" VARCHAR(255) NOT NULL, + "description" VARCHAR(255), + "duration" INTEGER NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "actualStartDate" TIMESTAMP(3), + "endDate" TIMESTAMP(3), + "completionDate" TIMESTAMP(3), + "status" "ProjectStatus" NOT NULL, + "type" VARCHAR(45) NOT NULL, + "details" JSONB, + "order" INTEGER NOT NULL, + "plannedText" VARCHAR(512), + "activeText" VARCHAR(512), + "completedText" VARCHAR(512), + "blockedText" VARCHAR(512), + "hidden" BOOLEAN NOT NULL DEFAULT false, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" BIGINT NOT NULL, + "updatedBy" BIGINT NOT NULL, + + CONSTRAINT "milestones_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "status_history" ( + "id" BIGSERIAL NOT NULL, + "reference" TEXT NOT NULL, + "referenceId" BIGINT NOT NULL, + "status" "ProjectStatus" NOT NULL, + "comment" TEXT, + "createdBy" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedBy" INTEGER NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "status_history_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_templates" ( + "id" BIGSERIAL NOT NULL, + "name" VARCHAR(255) NOT NULL, + "key" VARCHAR(45) NOT NULL, + "category" VARCHAR(45) NOT NULL, + "subCategory" VARCHAR(45), + "metadata" JSONB NOT NULL DEFAULT '{}', + "icon" VARCHAR(255) NOT NULL, + "question" VARCHAR(255) NOT NULL, + "info" VARCHAR(1024) NOT NULL, + "aliases" JSONB NOT NULL, + "scope" JSONB, + "phases" JSONB, + "form" JSONB, + "planConfig" JSONB, + "priceConfig" JSONB, + "disabled" BOOLEAN NOT NULL DEFAULT false, + "hidden" BOOLEAN NOT NULL DEFAULT false, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" BIGINT NOT NULL, + "updatedBy" BIGINT NOT NULL, + + CONSTRAINT "project_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "product_templates" ( + "id" BIGSERIAL NOT NULL, + "name" VARCHAR(255) NOT NULL, + "productKey" VARCHAR(45) NOT NULL, + "category" VARCHAR(45) NOT NULL, + "subCategory" VARCHAR(45) NOT NULL, + "icon" VARCHAR(255) NOT NULL, + "brief" VARCHAR(45) NOT NULL, + "details" VARCHAR(255) NOT NULL, + "aliases" JSONB NOT NULL, + "template" JSONB, + "form" JSONB, + "deletedAt" TIMESTAMP(3), + "disabled" BOOLEAN NOT NULL DEFAULT false, + "hidden" BOOLEAN NOT NULL DEFAULT false, + "isAddOn" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" BIGINT NOT NULL, + "updatedBy" BIGINT NOT NULL, + + CONSTRAINT "product_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "milestone_templates" ( + "id" BIGSERIAL NOT NULL, + "name" VARCHAR(255) NOT NULL, + "description" VARCHAR(255), + "duration" INTEGER NOT NULL, + "type" VARCHAR(45) NOT NULL, + "order" INTEGER NOT NULL, + "plannedText" VARCHAR(512) NOT NULL, + "activeText" VARCHAR(512) NOT NULL, + "completedText" VARCHAR(512) NOT NULL, + "blockedText" VARCHAR(512) NOT NULL, + "hidden" BOOLEAN NOT NULL DEFAULT false, + "reference" VARCHAR(45) NOT NULL, + "referenceId" BIGINT NOT NULL, + "metadata" JSONB NOT NULL DEFAULT '{}', + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" BIGINT NOT NULL, + "updatedBy" BIGINT NOT NULL, + + CONSTRAINT "milestone_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_types" ( + "key" VARCHAR(45) NOT NULL, + "displayName" VARCHAR(255) NOT NULL, + "icon" VARCHAR(255) NOT NULL, + "question" VARCHAR(255) NOT NULL, + "info" VARCHAR(1024) NOT NULL, + "aliases" JSONB NOT NULL, + "disabled" BOOLEAN NOT NULL DEFAULT false, + "hidden" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_types_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "product_categories" ( + "key" VARCHAR(45) NOT NULL, + "displayName" VARCHAR(255) NOT NULL, + "icon" VARCHAR(255) NOT NULL, + "question" VARCHAR(255) NOT NULL, + "info" VARCHAR(1024) NOT NULL, + "aliases" JSONB NOT NULL, + "disabled" BOOLEAN NOT NULL DEFAULT false, + "hidden" BOOLEAN NOT NULL DEFAULT false, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "product_categories_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "org_config" ( + "id" BIGSERIAL NOT NULL, + "orgId" VARCHAR(45) NOT NULL, + "configName" VARCHAR(45) NOT NULL, + "configValue" VARCHAR(512), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" BIGINT NOT NULL, + "updatedBy" BIGINT NOT NULL, + + CONSTRAINT "org_config_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "form" ( + "id" BIGSERIAL NOT NULL, + "key" VARCHAR(45) NOT NULL, + "version" BIGINT NOT NULL DEFAULT 1, + "revision" BIGINT NOT NULL DEFAULT 1, + "config" JSONB NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "form_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "plan_config" ( + "id" BIGSERIAL NOT NULL, + "key" VARCHAR(45) NOT NULL, + "version" BIGINT NOT NULL DEFAULT 1, + "revision" BIGINT NOT NULL DEFAULT 1, + "config" JSONB NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "plan_config_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "price_config" ( + "id" BIGSERIAL NOT NULL, + "key" VARCHAR(45) NOT NULL, + "version" BIGINT NOT NULL DEFAULT 1, + "revision" BIGINT NOT NULL DEFAULT 1, + "config" JSONB NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "price_config_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "work_streams" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT NOT NULL, + "name" VARCHAR(255) NOT NULL, + "type" VARCHAR(45) NOT NULL, + "status" "WorkStreamStatus" NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" BIGINT NOT NULL, + "updatedBy" BIGINT NOT NULL, + + CONSTRAINT "work_streams_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "phase_work_streams" ( + "workStreamId" BIGINT NOT NULL, + "phaseId" BIGINT NOT NULL, + + CONSTRAINT "phase_work_streams_pkey" PRIMARY KEY ("workStreamId","phaseId") +); + +-- CreateTable +CREATE TABLE "work_management_permissions" ( + "id" BIGSERIAL NOT NULL, + "policy" VARCHAR(255) NOT NULL, + "permission" JSONB NOT NULL, + "projectTemplateId" BIGINT NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "work_management_permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "copilot_requests" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT, + "status" "CopilotRequestStatus" NOT NULL DEFAULT 'new', + "data" JSONB NOT NULL DEFAULT '{}', + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "copilot_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "copilot_opportunities" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT, + "copilotRequestId" BIGINT, + "status" "CopilotOpportunityStatus" NOT NULL DEFAULT 'active', + "type" "CopilotOpportunityType" NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "copilot_opportunities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "copilot_applications" ( + "id" BIGSERIAL NOT NULL, + "opportunityId" BIGINT NOT NULL, + "userId" BIGINT NOT NULL, + "notes" TEXT, + "status" "CopilotApplicationStatus" NOT NULL DEFAULT 'pending', + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "copilot_applications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_settings" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT NOT NULL, + "key" VARCHAR(255) NOT NULL, + "value" VARCHAR(255), + "valueType" "ValueType", + "metadata" JSONB NOT NULL DEFAULT '{}', + "readPermission" JSONB NOT NULL DEFAULT '{}', + "writePermission" JSONB NOT NULL DEFAULT '{}', + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_settings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_estimations" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT NOT NULL, + "buildingBlockKey" TEXT NOT NULL, + "conditions" TEXT NOT NULL, + "price" DOUBLE PRECISION NOT NULL, + "quantity" INTEGER, + "minTime" INTEGER NOT NULL, + "maxTime" INTEGER NOT NULL, + "metadata" JSONB NOT NULL DEFAULT '{}', + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_estimations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_estimation_items" ( + "id" BIGSERIAL NOT NULL, + "projectEstimationId" BIGINT NOT NULL, + "price" DOUBLE PRECISION NOT NULL, + "type" "EstimationType" NOT NULL, + "markupUsedReference" TEXT NOT NULL, + "markupUsedReferenceId" BIGINT NOT NULL, + "metadata" JSONB NOT NULL DEFAULT '{}', + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_estimation_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "building_blocks" ( + "id" BIGSERIAL NOT NULL, + "key" VARCHAR(255) NOT NULL, + "config" JSONB NOT NULL DEFAULT '{}', + "privateConfig" JSONB NOT NULL DEFAULT '{}', + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" BIGINT, + "createdBy" BIGINT NOT NULL, + "updatedBy" BIGINT NOT NULL, + + CONSTRAINT "building_blocks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "scope_change_requests" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT NOT NULL, + "oldScope" JSONB NOT NULL, + "newScope" JSONB NOT NULL, + "status" "ScopeChangeRequestStatus" NOT NULL, + "approvedAt" TIMESTAMP(3), + "approvedBy" INTEGER, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedBy" INTEGER, + "createdBy" INTEGER NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "scope_change_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_history" ( + "id" BIGSERIAL NOT NULL, + "projectId" BIGINT NOT NULL, + "status" TEXT NOT NULL, + "cancelReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" INTEGER NOT NULL, + + CONSTRAINT "project_history_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "customer_payments" ( + "id" BIGSERIAL NOT NULL, + "reference" VARCHAR(45), + "referenceId" VARCHAR(255), + "amount" INTEGER NOT NULL, + "currency" "CustomerPaymentCurrency" NOT NULL, + "paymentIntentId" VARCHAR(255) NOT NULL, + "clientSecret" VARCHAR(255), + "status" "CustomerPaymentStatus" NOT NULL, + "deletedAt" TIMESTAMP(3), + "deletedBy" BIGINT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdBy" BIGINT NOT NULL, + "updatedBy" BIGINT NOT NULL, + + CONSTRAINT "customer_payments_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "projects_createdAt_idx" ON "projects"("createdAt"); + +-- CreateIndex +CREATE INDEX "projects_name_idx" ON "projects"("name"); + +-- CreateIndex +CREATE INDEX "projects_type_idx" ON "projects"("type"); + +-- CreateIndex +CREATE INDEX "projects_status_idx" ON "projects"("status"); + +-- CreateIndex +CREATE INDEX "projects_directProjectId_idx" ON "projects"("directProjectId"); + +-- CreateIndex +CREATE INDEX "project_members_deletedAt_idx" ON "project_members"("deletedAt"); + +-- CreateIndex +CREATE INDEX "project_members_userId_idx" ON "project_members"("userId"); + +-- CreateIndex +CREATE INDEX "project_members_role_idx" ON "project_members"("role"); + +-- CreateIndex +CREATE INDEX "project_members_projectId_idx" ON "project_members"("projectId"); + +-- CreateIndex +CREATE INDEX "project_member_invites_projectId_idx" ON "project_member_invites"("projectId"); + +-- CreateIndex +CREATE INDEX "project_member_invites_status_idx" ON "project_member_invites"("status"); + +-- CreateIndex +CREATE INDEX "project_member_invites_deletedAt_idx" ON "project_member_invites"("deletedAt"); + +-- CreateIndex +CREATE INDEX "project_phases_projectId_idx" ON "project_phases"("projectId"); + +-- CreateIndex +CREATE INDEX "phase_products_phaseId_idx" ON "phase_products"("phaseId"); + +-- CreateIndex +CREATE INDEX "phase_products_projectId_idx" ON "phase_products"("projectId"); + +-- CreateIndex +CREATE INDEX "project_phase_member_phaseId_idx" ON "project_phase_member"("phaseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "project_phase_member_phaseId_userId_key" ON "project_phase_member"("phaseId", "userId"); + +-- CreateIndex +CREATE INDEX "project_phase_approval_phaseId_idx" ON "project_phase_approval"("phaseId"); + +-- CreateIndex +CREATE INDEX "project_attachments_projectId_idx" ON "project_attachments"("projectId"); + +-- CreateIndex +CREATE INDEX "timelines_reference_referenceId_idx" ON "timelines"("reference", "referenceId"); + +-- CreateIndex +CREATE INDEX "milestones_timelineId_idx" ON "milestones"("timelineId"); + +-- CreateIndex +CREATE INDEX "milestones_status_idx" ON "milestones"("status"); + +-- CreateIndex +CREATE INDEX "status_history_reference_referenceId_idx" ON "status_history"("reference", "referenceId"); + +-- CreateIndex +CREATE INDEX "project_templates_key_idx" ON "project_templates"("key"); + +-- CreateIndex +CREATE INDEX "project_templates_category_idx" ON "project_templates"("category"); + +-- CreateIndex +CREATE INDEX "product_templates_productKey_idx" ON "product_templates"("productKey"); + +-- CreateIndex +CREATE INDEX "product_templates_category_idx" ON "product_templates"("category"); + +-- CreateIndex +CREATE INDEX "milestone_templates_reference_referenceId_idx" ON "milestone_templates"("reference", "referenceId"); + +-- CreateIndex +CREATE INDEX "form_key_idx" ON "form"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "form_key_version_revision_key" ON "form"("key", "version", "revision"); + +-- CreateIndex +CREATE INDEX "plan_config_key_idx" ON "plan_config"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "plan_config_key_version_revision_key" ON "plan_config"("key", "version", "revision"); + +-- CreateIndex +CREATE INDEX "price_config_key_idx" ON "price_config"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "price_config_key_version_revision_key" ON "price_config"("key", "version", "revision"); + +-- CreateIndex +CREATE INDEX "work_streams_projectId_idx" ON "work_streams"("projectId"); + +-- CreateIndex +CREATE INDEX "work_streams_status_idx" ON "work_streams"("status"); + +-- CreateIndex +CREATE INDEX "work_management_permissions_projectTemplateId_idx" ON "work_management_permissions"("projectTemplateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_management_permissions_policy_projectTemplateId_key" ON "work_management_permissions"("policy", "projectTemplateId"); + +-- CreateIndex +CREATE INDEX "copilot_requests_projectId_idx" ON "copilot_requests"("projectId"); + +-- CreateIndex +CREATE INDEX "copilot_requests_status_idx" ON "copilot_requests"("status"); + +-- CreateIndex +CREATE INDEX "copilot_opportunities_projectId_idx" ON "copilot_opportunities"("projectId"); + +-- CreateIndex +CREATE INDEX "copilot_opportunities_copilotRequestId_idx" ON "copilot_opportunities"("copilotRequestId"); + +-- CreateIndex +CREATE INDEX "copilot_opportunities_status_idx" ON "copilot_opportunities"("status"); + +-- CreateIndex +CREATE INDEX "copilot_applications_opportunityId_idx" ON "copilot_applications"("opportunityId"); + +-- CreateIndex +CREATE INDEX "copilot_applications_userId_idx" ON "copilot_applications"("userId"); + +-- CreateIndex +CREATE INDEX "copilot_applications_status_idx" ON "copilot_applications"("status"); + +-- CreateIndex +CREATE INDEX "project_settings_projectId_idx" ON "project_settings"("projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "project_settings_key_projectId_key" ON "project_settings"("key", "projectId"); + +-- CreateIndex +CREATE INDEX "project_estimations_projectId_idx" ON "project_estimations"("projectId"); + +-- CreateIndex +CREATE INDEX "project_estimation_items_projectEstimationId_idx" ON "project_estimation_items"("projectEstimationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "building_blocks_key_key" ON "building_blocks"("key"); + +-- CreateIndex +CREATE INDEX "scope_change_requests_projectId_idx" ON "scope_change_requests"("projectId"); + +-- CreateIndex +CREATE INDEX "scope_change_requests_status_idx" ON "scope_change_requests"("status"); + +-- CreateIndex +CREATE INDEX "project_history_projectId_idx" ON "project_history"("projectId"); + +-- CreateIndex +CREATE INDEX "customer_payments_paymentIntentId_idx" ON "customer_payments"("paymentIntentId"); + +-- CreateIndex +CREATE INDEX "customer_payments_status_idx" ON "customer_payments"("status"); + +-- AddForeignKey +ALTER TABLE "project_members" ADD CONSTRAINT "project_members_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_member_invites" ADD CONSTRAINT "project_member_invites_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_member_invites" ADD CONSTRAINT "project_member_invites_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "copilot_applications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_phases" ADD CONSTRAINT "project_phases_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "phase_products" ADD CONSTRAINT "phase_products_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "project_phases"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_phase_member" ADD CONSTRAINT "project_phase_member_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "project_phases"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_phase_approval" ADD CONSTRAINT "project_phase_approval_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "project_phases"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_attachments" ADD CONSTRAINT "project_attachments_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "milestones" ADD CONSTRAINT "milestones_timelineId_fkey" FOREIGN KEY ("timelineId") REFERENCES "timelines"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "status_history" ADD CONSTRAINT "status_history_referenceId_fkey" FOREIGN KEY ("referenceId") REFERENCES "milestones"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_streams" ADD CONSTRAINT "work_streams_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "phase_work_streams" ADD CONSTRAINT "phase_work_streams_workStreamId_fkey" FOREIGN KEY ("workStreamId") REFERENCES "work_streams"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "phase_work_streams" ADD CONSTRAINT "phase_work_streams_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES "project_phases"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_management_permissions" ADD CONSTRAINT "work_management_permissions_projectTemplateId_fkey" FOREIGN KEY ("projectTemplateId") REFERENCES "project_templates"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "copilot_requests" ADD CONSTRAINT "copilot_requests_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "copilot_opportunities" ADD CONSTRAINT "copilot_opportunities_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "copilot_opportunities" ADD CONSTRAINT "copilot_opportunities_copilotRequestId_fkey" FOREIGN KEY ("copilotRequestId") REFERENCES "copilot_requests"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "copilot_applications" ADD CONSTRAINT "copilot_applications_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "copilot_opportunities"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_settings" ADD CONSTRAINT "project_settings_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_estimations" ADD CONSTRAINT "project_estimations_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_estimation_items" ADD CONSTRAINT "project_estimation_items_projectEstimationId_fkey" FOREIGN KEY ("projectEstimationId") REFERENCES "project_estimations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "scope_change_requests" ADD CONSTRAINT "scope_change_requests_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_history" ADD CONSTRAINT "project_history_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + From 4de30beda31a419bcc1b73a3014a71f198c74e5c Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 24 Jun 2026 16:13:43 +0300 Subject: [PATCH 03/22] PM-5306 - data model for project showcase posts --- prisma/add_types.sql | 13 +++ .../migration.sql | 60 ++++++++++++++ prisma/schema.prisma | 80 +++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql diff --git a/prisma/add_types.sql b/prisma/add_types.sql index 75f7b91..401bcb8 100644 --- a/prisma/add_types.sql +++ b/prisma/add_types.sql @@ -214,6 +214,19 @@ BEGIN ); END IF; + IF NOT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'projects' AND t.typname = 'ProjectShowcasePostStatus' + ) THEN + CREATE TYPE projects."ProjectShowcasePostStatus" AS ENUM ( + 'draft', + 'published', + 'archived' + ); + END IF; + IF NOT EXISTS ( SELECT 1 FROM pg_type t diff --git a/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql b/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql new file mode 100644 index 0000000..16c7b96 --- /dev/null +++ b/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql @@ -0,0 +1,60 @@ +-- Create enum for project showcase post status +CREATE TYPE projects."ProjectShowcasePostStatus" AS ENUM ('draft', 'published', 'archived'); + +-- Create project showcase posts table +CREATE TABLE projects."project_showcase_posts" ( + "id" BIGSERIAL PRIMARY KEY, + "title" VARCHAR(255) NOT NULL, + "content" TEXT NOT NULL, + "status" projects."ProjectShowcasePostStatus" NOT NULL DEFAULT 'draft', + "projectId" BIGINT NOT NULL, + "challengeIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdById" INTEGER NOT NULL, + "updatedById" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT now() +); + +-- Create taxonomy tables +CREATE TABLE projects."project_post_industries" ( + "id" BIGSERIAL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE projects."project_post_categories" ( + "id" BIGSERIAL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL UNIQUE +); + +-- Create join tables for many-to-many relationships +CREATE TABLE projects."project_showcase_post_industries" ( + "id" BIGSERIAL PRIMARY KEY, + "projectShowcasePostId" BIGINT NOT NULL, + "industryId" BIGINT NOT NULL, + CONSTRAINT "project_showcase_post_industries_project_showcase_post_fkey" + FOREIGN KEY ("projectShowcasePostId") REFERENCES projects."project_showcase_posts"("id") ON DELETE CASCADE, + CONSTRAINT "project_showcase_post_industries_industry_fkey" + FOREIGN KEY ("industryId") REFERENCES projects."project_post_industries"("id") ON DELETE CASCADE, + CONSTRAINT "project_showcase_post_industries_unique" + UNIQUE ("projectShowcasePostId", "industryId") +); + +CREATE TABLE projects."project_showcase_post_categories" ( + "id" BIGSERIAL PRIMARY KEY, + "projectShowcasePostId" BIGINT NOT NULL, + "categoryId" BIGINT NOT NULL, + CONSTRAINT "project_showcase_post_categories_project_showcase_post_fkey" + FOREIGN KEY ("projectShowcasePostId") REFERENCES projects."project_showcase_posts"("id") ON DELETE CASCADE, + CONSTRAINT "project_showcase_post_categories_category_fkey" + FOREIGN KEY ("categoryId") REFERENCES projects."project_post_categories"("id") ON DELETE CASCADE, + CONSTRAINT "project_showcase_post_categories_unique" + UNIQUE ("projectShowcasePostId", "categoryId") +); + +-- Indexes for query performance +CREATE INDEX "project_showcase_posts_status_idx" ON projects."project_showcase_posts"("status"); +CREATE INDEX "project_showcase_posts_project_id_idx" ON projects."project_showcase_posts"("projectId"); +CREATE INDEX "project_showcase_post_industries_project_showcase_post_id_idx" ON projects."project_showcase_post_industries"("projectShowcasePostId"); +CREATE INDEX "project_showcase_post_industries_industry_id_idx" ON projects."project_showcase_post_industries"("industryId"); +CREATE INDEX "project_showcase_post_categories_project_showcase_post_id_idx" ON projects."project_showcase_post_categories"("projectShowcasePostId"); +CREATE INDEX "project_showcase_post_categories_category_id_idx" ON projects."project_showcase_post_categories"("categoryId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c87015..e02adff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -317,6 +317,7 @@ model Project { settings ProjectSetting[] estimations ProjectEstimation[] history ProjectHistory[] + showcasePosts ProjectShowcasePost[] @@index([createdAt]) @@index([name]) @@ -1050,3 +1051,82 @@ model CustomerPayment { @@index([status]) @@map("customer_payments") } + +/// Project showcase post lifecycle status. +enum ProjectShowcasePostStatus { + draft + published + archived +} + +/// Showcase content posts linked to a project. +model ProjectShowcasePost { + id BigInt @id @default(autoincrement()) + title String + content String @db.Text + status ProjectShowcasePostStatus @default(draft) + projectId BigInt + challengeIds String[] @default([]) + createdById Int + updatedById Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + industries ProjectShowcasePostIndustry[] + categories ProjectShowcasePostCategory[] + + @@index([status]) + @@index([projectId]) + @@map("project_showcase_posts") +} + +/// Project showcase post industries taxonomy. +model ProjectPostIndustry { + id BigInt @id @default(autoincrement()) + name String @unique + + posts ProjectShowcasePostIndustry[] + + @@map("project_post_industries") +} + +/// Project showcase post categories taxonomy. +model ProjectPostCategory { + id BigInt @id @default(autoincrement()) + name String @unique + + posts ProjectShowcasePostCategory[] + + @@map("project_post_categories") +} + +/// Join table linking showcase posts to industries. +model ProjectShowcasePostIndustry { + id BigInt @id @default(autoincrement()) + projectShowcasePostId BigInt + industryId BigInt + + projectShowcasePost ProjectShowcasePost @relation(fields: [projectShowcasePostId], references: [id], onDelete: Cascade) + industry ProjectPostIndustry @relation(fields: [industryId], references: [id], onDelete: Cascade) + + @@unique([projectShowcasePostId, industryId]) + @@index([projectShowcasePostId]) + @@index([industryId]) + @@map("project_showcase_post_industries") +} + +/// Join table linking showcase posts to categories. +model ProjectShowcasePostCategory { + id BigInt @id @default(autoincrement()) + projectShowcasePostId BigInt + categoryId BigInt + + projectShowcasePost ProjectShowcasePost @relation(fields: [projectShowcasePostId], references: [id], onDelete: Cascade) + category ProjectPostCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + + @@unique([projectShowcasePostId, categoryId]) + @@index([projectShowcasePostId]) + @@index([categoryId]) + @@map("project_showcase_post_categories") +} From 4afb37c135ea62b140461ef30cba4d1345d3fae8 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 25 Jun 2026 11:21:27 +0300 Subject: [PATCH 04/22] PM-5307 - project showcase post API --- src/api/api.module.ts | 2 + src/api/metadata/metadata.module.ts | 6 + .../dto/create-project-post-category.dto.ts | 9 + .../dto/project-post-category-response.dto.ts | 9 + .../dto/update-project-post-category.dto.ts | 6 + .../project-post-category.controller.ts | 96 ++++ .../project-post-category.module.ts | 12 + .../project-post-category.service.ts | 177 +++++++ .../dto/create-project-post-industry.dto.ts | 9 + .../dto/project-post-industry-response.dto.ts | 9 + .../dto/update-project-post-industry.dto.ts | 6 + .../project-post-industry.controller.ts | 96 ++++ .../project-post-industry.module.ts | 12 + .../project-post-industry.service.ts | 177 +++++++ .../dto/create-project-showcase-post.dto.ts | 38 ++ .../project-showcase-post-list-query.dto.ts | 67 +++ .../dto/project-showcase-post-response.dto.ts | 39 ++ .../dto/update-project-showcase-post.dto.ts | 6 + .../project-showcase-post.controller.ts | 175 +++++++ .../project-showcase-post.module.ts | 12 + .../project-showcase-post.service.ts | 486 ++++++++++++++++++ src/main.ts | 3 +- src/shared/constants/event.constants.ts | 4 + test/fixtures/database-seed.ts | 1 - 24 files changed, 1455 insertions(+), 2 deletions(-) create mode 100644 src/api/metadata/project-post-category/dto/create-project-post-category.dto.ts create mode 100644 src/api/metadata/project-post-category/dto/project-post-category-response.dto.ts create mode 100644 src/api/metadata/project-post-category/dto/update-project-post-category.dto.ts create mode 100644 src/api/metadata/project-post-category/project-post-category.controller.ts create mode 100644 src/api/metadata/project-post-category/project-post-category.module.ts create mode 100644 src/api/metadata/project-post-category/project-post-category.service.ts create mode 100644 src/api/metadata/project-post-industry/dto/create-project-post-industry.dto.ts create mode 100644 src/api/metadata/project-post-industry/dto/project-post-industry-response.dto.ts create mode 100644 src/api/metadata/project-post-industry/dto/update-project-post-industry.dto.ts create mode 100644 src/api/metadata/project-post-industry/project-post-industry.controller.ts create mode 100644 src/api/metadata/project-post-industry/project-post-industry.module.ts create mode 100644 src/api/metadata/project-post-industry/project-post-industry.service.ts create mode 100644 src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts create mode 100644 src/api/project-showcase-post/dto/project-showcase-post-list-query.dto.ts create mode 100644 src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts create mode 100644 src/api/project-showcase-post/dto/update-project-showcase-post.dto.ts create mode 100644 src/api/project-showcase-post/project-showcase-post.controller.ts create mode 100644 src/api/project-showcase-post/project-showcase-post.module.ts create mode 100644 src/api/project-showcase-post/project-showcase-post.service.ts diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 86c3780..50e5c63 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -12,6 +12,7 @@ import { ProjectInviteModule } from './project-invite/project-invite.module'; import { ProjectMemberModule } from './project-member/project-member.module'; import { ProjectPhaseModule } from './project-phase/project-phase.module'; import { ProjectSettingModule } from './project-setting/project-setting.module'; +import { ProjectShowcasePostModule } from './project-showcase-post/project-showcase-post.module'; import { ProjectModule } from './project/project.module'; /** @@ -39,6 +40,7 @@ import { ProjectModule } from './project/project.module'; GlobalProvidersModule, CopilotModule, MetadataModule, + ProjectShowcasePostModule, ProjectModule, ProjectMemberModule, ProjectInviteModule, diff --git a/src/api/metadata/metadata.module.ts b/src/api/metadata/metadata.module.ts index 957ee1b..53f77ef 100644 --- a/src/api/metadata/metadata.module.ts +++ b/src/api/metadata/metadata.module.ts @@ -11,6 +11,8 @@ import { ProductTemplateModule } from './product-template/product-template.modul import { ProjectTemplateModule } from './project-template/project-template.module'; import { ProjectTypeModule } from './project-type/project-type.module'; import { WorkManagementPermissionModule } from './work-management-permission/work-management-permission.module'; +import { ProjectPostIndustryModule } from './project-post-industry/project-post-industry.module'; +import { ProjectPostCategoryModule } from './project-post-category/project-post-category.module'; /** * Aggregates all metadata sub-modules used by the projects API. @@ -26,6 +28,8 @@ import { WorkManagementPermissionModule } from './work-management-permission/wor * - `PlanConfigModule`: versioned plan configuration definitions. * - `PriceConfigModule`: versioned pricing configuration definitions. * - `WorkManagementPermissionModule`: permission policy records by template. + * - `ProjectPostIndustryModule`: project showcase post industry taxonomy. + * - `ProjectPostCategoryModule`: project showcase post category taxonomy. * * It also registers `MetadataListController` and `MetadataListService` for the * consolidated metadata list endpoint. @@ -42,6 +46,8 @@ import { WorkManagementPermissionModule } from './work-management-permission/wor PlanConfigModule, PriceConfigModule, WorkManagementPermissionModule, + ProjectPostIndustryModule, + ProjectPostCategoryModule, ], controllers: [MetadataListController], providers: [MetadataListService], diff --git a/src/api/metadata/project-post-category/dto/create-project-post-category.dto.ts b/src/api/metadata/project-post-category/dto/create-project-post-category.dto.ts new file mode 100644 index 0000000..5ee08d3 --- /dev/null +++ b/src/api/metadata/project-post-category/dto/create-project-post-category.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateProjectPostCategoryDto { + @ApiProperty({ description: 'Category name' }) + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/src/api/metadata/project-post-category/dto/project-post-category-response.dto.ts b/src/api/metadata/project-post-category/dto/project-post-category-response.dto.ts new file mode 100644 index 0000000..99d14d0 --- /dev/null +++ b/src/api/metadata/project-post-category/dto/project-post-category-response.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ProjectPostCategoryResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; +} diff --git a/src/api/metadata/project-post-category/dto/update-project-post-category.dto.ts b/src/api/metadata/project-post-category/dto/update-project-post-category.dto.ts new file mode 100644 index 0000000..2d5aaf5 --- /dev/null +++ b/src/api/metadata/project-post-category/dto/update-project-post-category.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateProjectPostCategoryDto } from './create-project-post-category.dto'; + +export class UpdateProjectPostCategoryDto extends PartialType( + CreateProjectPostCategoryDto, +) {} diff --git a/src/api/metadata/project-post-category/project-post-category.controller.ts b/src/api/metadata/project-post-category/project-post-category.controller.ts new file mode 100644 index 0000000..74c699c --- /dev/null +++ b/src/api/metadata/project-post-category/project-post-category.controller.ts @@ -0,0 +1,96 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; +import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; +import { AnyAuthenticated } from 'src/shared/guards/tokenRoles.guard'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { getAuditUserIdNumber } from '../utils/metadata-utils'; +import { CreateProjectPostCategoryDto } from './dto/create-project-post-category.dto'; +import { ProjectPostCategoryResponseDto } from './dto/project-post-category-response.dto'; +import { UpdateProjectPostCategoryDto } from './dto/update-project-post-category.dto'; +import { ProjectPostCategoryService } from './project-post-category.service'; + +@ApiTags('Metadata - Project Post Categories') +@ApiBearerAuth() +@AnyAuthenticated() +@Controller('/projects/metadata/projectPostCategories') +export class ProjectPostCategoryController { + constructor( + private readonly service: ProjectPostCategoryService, + ) {} + + @Get() + @ApiOperation({ summary: 'List project showcase post categories' }) + @ApiResponse({ status: 200, type: [ProjectPostCategoryResponseDto] }) + async list(): Promise { + return this.service.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get project showcase post category by id' }) + @ApiParam({ name: 'id', description: 'Category id' }) + @ApiResponse({ status: 200, type: ProjectPostCategoryResponseDto }) + @ApiResponse({ status: 404, description: 'Not found' }) + async getOne( + @Param('id') id: string, + ): Promise { + return this.service.findById(id); + } + + @Post() + @AdminOnly() + @ApiOperation({ summary: 'Create project showcase post category' }) + @ApiResponse({ status: 201, type: ProjectPostCategoryResponseDto }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + async create( + @Body() dto: CreateProjectPostCategoryDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.create(dto, getAuditUserIdNumber(user)); + } + + @Patch(':id') + @AdminOnly() + @ApiOperation({ summary: 'Update project showcase post category' }) + @ApiParam({ name: 'id', description: 'Category id' }) + @ApiResponse({ status: 200, type: ProjectPostCategoryResponseDto }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Not found' }) + async update( + @Param('id') id: string, + @Body() dto: UpdateProjectPostCategoryDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.update(id, dto, getAuditUserIdNumber(user)); + } + + @Delete(':id') + @HttpCode(204) + @AdminOnly() + @ApiOperation({ summary: 'Delete project showcase post category (soft delete)' }) + @ApiParam({ name: 'id', description: 'Category id' }) + @ApiResponse({ status: 204, description: 'Deleted' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Not found' }) + async delete( + @Param('id') id: string, + @CurrentUser() user: JwtUser, + ): Promise { + await this.service.delete(id, getAuditUserIdNumber(user)); + } +} diff --git a/src/api/metadata/project-post-category/project-post-category.module.ts b/src/api/metadata/project-post-category/project-post-category.module.ts new file mode 100644 index 0000000..6ba8f86 --- /dev/null +++ b/src/api/metadata/project-post-category/project-post-category.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; +import { ProjectPostCategoryController } from './project-post-category.controller'; +import { ProjectPostCategoryService } from './project-post-category.service'; + +@Module({ + imports: [GlobalProvidersModule], + controllers: [ProjectPostCategoryController], + providers: [ProjectPostCategoryService], + exports: [ProjectPostCategoryService], +}) +export class ProjectPostCategoryModule {} diff --git a/src/api/metadata/project-post-category/project-post-category.service.ts b/src/api/metadata/project-post-category/project-post-category.service.ts new file mode 100644 index 0000000..d41dd00 --- /dev/null +++ b/src/api/metadata/project-post-category/project-post-category.service.ts @@ -0,0 +1,177 @@ +import { + ConflictException, + HttpException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, ProjectPostCategory } from '@prisma/client'; +import { PrismaErrorService } from 'src/shared/modules/global/prisma-error.service'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; +import { EventBusService } from 'src/shared/modules/global/eventBus.service'; +import { + PROJECT_METADATA_RESOURCE, + publishMetadataEvent, +} from '../utils/metadata-event.utils'; +import { toSerializable } from '../utils/metadata-utils'; +import { CreateProjectPostCategoryDto } from './dto/create-project-post-category.dto'; +import { ProjectPostCategoryResponseDto } from './dto/project-post-category-response.dto'; +import { UpdateProjectPostCategoryDto } from './dto/update-project-post-category.dto'; + +@Injectable() +export class ProjectPostCategoryService { + constructor( + private readonly prisma: PrismaService, + private readonly prismaErrorService: PrismaErrorService, + private readonly eventBusService: EventBusService, + ) {} + + async findAll(): Promise { + const records = await this.prisma.projectPostCategory.findMany({ + orderBy: [{ id: 'asc' }], + }); + + return records.map((record) => this.toDto(record)); + } + + async findById(id: string): Promise { + const parsedId = parseInt(id, 10); + if (Number.isNaN(parsedId)) { + throw new NotFoundException(`Category not found for id ${id}.`); + } + + const record = await this.prisma.projectPostCategory.findFirst({ + where: { + id: BigInt(parsedId), + }, + }); + + if (!record) { + throw new NotFoundException(`Category not found for id ${id}.`); + } + + return this.toDto(record); + } + + async create( + dto: CreateProjectPostCategoryDto, + userId: number, + ): Promise { + try { + const existing = await this.prisma.projectPostCategory.findFirst({ + where: { + name: dto.name, + }, + }); + + if (existing) { + throw new ConflictException( + `Project showcase post category already exists for name ${dto.name}.`, + ); + } + + const created = await this.prisma.projectPostCategory.create({ + data: { + name: dto.name, + }, + }); + + await publishMetadataEvent( + this.eventBusService, + 'PROJECT_METADATA_CREATE', + PROJECT_METADATA_RESOURCE.PROJECT_POST_CATEGORY, + String(created.id), + created, + userId, + ); + + return this.toDto(created); + } catch (error) { + this.handleError(error, `create project showcase post category ${dto.name}`); + } + } + + async update( + id: string, + dto: UpdateProjectPostCategoryDto, + userId: number, + ): Promise { + const parsedId = parseInt(id, 10); + if (Number.isNaN(parsedId)) { + throw new NotFoundException(`Category not found for id ${id}.`); + } + + const existing = await this.prisma.projectPostCategory.findFirst({ + where: { + id: BigInt(parsedId), + }, + }); + + if (!existing) { + throw new NotFoundException(`Category not found for id ${id}.`); + } + + const updated = await this.prisma.projectPostCategory.update({ + where: { id: BigInt(parsedId) }, + data: { + ...(typeof dto.name === 'undefined' ? {} : { name: dto.name }), + }, + }); + + await publishMetadataEvent( + this.eventBusService, + 'PROJECT_METADATA_UPDATE', + PROJECT_METADATA_RESOURCE.PROJECT_POST_CATEGORY, + String(updated.id), + updated, + userId, + ); + + return this.toDto(updated); + } + + async delete(id: string, userId: number): Promise { + const parsedId = parseInt(id, 10); + if (Number.isNaN(parsedId)) { + throw new NotFoundException(`Category not found for id ${id}.`); + } + + const existing = await this.prisma.projectPostCategory.findFirst({ + where: { + id: BigInt(parsedId), + }, + select: { id: true }, + }); + + if (!existing) { + throw new NotFoundException(`Category not found for id ${id}.`); + } + + await this.prisma.projectPostCategory.delete({ + where: { id: BigInt(parsedId) }, + }); + + await publishMetadataEvent( + this.eventBusService, + 'PROJECT_METADATA_DELETE', + PROJECT_METADATA_RESOURCE.PROJECT_POST_CATEGORY, + String(parsedId), + { id: parsedId }, + userId, + ); + } + + private toDto(record: ProjectPostCategory): ProjectPostCategoryResponseDto { + return { + id: String(record.id), + name: record.name, + }; + } + + private handleError(error: unknown, operation: string): never { + if (error instanceof HttpException) { + throw error; + } + + this.prismaErrorService.handleError(error, operation); + } +} diff --git a/src/api/metadata/project-post-industry/dto/create-project-post-industry.dto.ts b/src/api/metadata/project-post-industry/dto/create-project-post-industry.dto.ts new file mode 100644 index 0000000..232c21c --- /dev/null +++ b/src/api/metadata/project-post-industry/dto/create-project-post-industry.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateProjectPostIndustryDto { + @ApiProperty({ description: 'Industry name' }) + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/src/api/metadata/project-post-industry/dto/project-post-industry-response.dto.ts b/src/api/metadata/project-post-industry/dto/project-post-industry-response.dto.ts new file mode 100644 index 0000000..0fa30c6 --- /dev/null +++ b/src/api/metadata/project-post-industry/dto/project-post-industry-response.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ProjectPostIndustryResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; +} diff --git a/src/api/metadata/project-post-industry/dto/update-project-post-industry.dto.ts b/src/api/metadata/project-post-industry/dto/update-project-post-industry.dto.ts new file mode 100644 index 0000000..58a720a --- /dev/null +++ b/src/api/metadata/project-post-industry/dto/update-project-post-industry.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateProjectPostIndustryDto } from './create-project-post-industry.dto'; + +export class UpdateProjectPostIndustryDto extends PartialType( + CreateProjectPostIndustryDto, +) {} diff --git a/src/api/metadata/project-post-industry/project-post-industry.controller.ts b/src/api/metadata/project-post-industry/project-post-industry.controller.ts new file mode 100644 index 0000000..814e9ed --- /dev/null +++ b/src/api/metadata/project-post-industry/project-post-industry.controller.ts @@ -0,0 +1,96 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; +import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; +import { AnyAuthenticated } from 'src/shared/guards/tokenRoles.guard'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { getAuditUserIdNumber } from '../utils/metadata-utils'; +import { CreateProjectPostIndustryDto } from './dto/create-project-post-industry.dto'; +import { ProjectPostIndustryResponseDto } from './dto/project-post-industry-response.dto'; +import { UpdateProjectPostIndustryDto } from './dto/update-project-post-industry.dto'; +import { ProjectPostIndustryService } from './project-post-industry.service'; + +@ApiTags('Metadata - Project Post Industries') +@ApiBearerAuth() +@AnyAuthenticated() +@Controller('/projects/metadata/projectPostIndustries') +export class ProjectPostIndustryController { + constructor( + private readonly service: ProjectPostIndustryService, + ) {} + + @Get() + @ApiOperation({ summary: 'List project showcase post industries' }) + @ApiResponse({ status: 200, type: [ProjectPostIndustryResponseDto] }) + async list(): Promise { + return this.service.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get project showcase post industry by id' }) + @ApiParam({ name: 'id', description: 'Industry id' }) + @ApiResponse({ status: 200, type: ProjectPostIndustryResponseDto }) + @ApiResponse({ status: 404, description: 'Not found' }) + async getOne( + @Param('id') id: string, + ): Promise { + return this.service.findById(id); + } + + @Post() + @AdminOnly() + @ApiOperation({ summary: 'Create project showcase post industry' }) + @ApiResponse({ status: 201, type: ProjectPostIndustryResponseDto }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + async create( + @Body() dto: CreateProjectPostIndustryDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.create(dto, getAuditUserIdNumber(user)); + } + + @Patch(':id') + @AdminOnly() + @ApiOperation({ summary: 'Update project showcase post industry' }) + @ApiParam({ name: 'id', description: 'Industry id' }) + @ApiResponse({ status: 200, type: ProjectPostIndustryResponseDto }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Not found' }) + async update( + @Param('id') id: string, + @Body() dto: UpdateProjectPostIndustryDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.update(id, dto, getAuditUserIdNumber(user)); + } + + @Delete(':id') + @HttpCode(204) + @AdminOnly() + @ApiOperation({ summary: 'Delete project showcase post industry (soft delete)' }) + @ApiParam({ name: 'id', description: 'Industry id' }) + @ApiResponse({ status: 204, description: 'Deleted' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Not found' }) + async delete( + @Param('id') id: string, + @CurrentUser() user: JwtUser, + ): Promise { + await this.service.delete(id, getAuditUserIdNumber(user)); + } +} diff --git a/src/api/metadata/project-post-industry/project-post-industry.module.ts b/src/api/metadata/project-post-industry/project-post-industry.module.ts new file mode 100644 index 0000000..a44d213 --- /dev/null +++ b/src/api/metadata/project-post-industry/project-post-industry.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; +import { ProjectPostIndustryController } from './project-post-industry.controller'; +import { ProjectPostIndustryService } from './project-post-industry.service'; + +@Module({ + imports: [GlobalProvidersModule], + controllers: [ProjectPostIndustryController], + providers: [ProjectPostIndustryService], + exports: [ProjectPostIndustryService], +}) +export class ProjectPostIndustryModule {} diff --git a/src/api/metadata/project-post-industry/project-post-industry.service.ts b/src/api/metadata/project-post-industry/project-post-industry.service.ts new file mode 100644 index 0000000..0ee7d8d --- /dev/null +++ b/src/api/metadata/project-post-industry/project-post-industry.service.ts @@ -0,0 +1,177 @@ +import { + ConflictException, + HttpException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, ProjectPostIndustry } from '@prisma/client'; +import { PrismaErrorService } from 'src/shared/modules/global/prisma-error.service'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; +import { EventBusService } from 'src/shared/modules/global/eventBus.service'; +import { + PROJECT_METADATA_RESOURCE, + publishMetadataEvent, +} from '../utils/metadata-event.utils'; +import { toSerializable } from '../utils/metadata-utils'; +import { CreateProjectPostIndustryDto } from './dto/create-project-post-industry.dto'; +import { ProjectPostIndustryResponseDto } from './dto/project-post-industry-response.dto'; +import { UpdateProjectPostIndustryDto } from './dto/update-project-post-industry.dto'; + +@Injectable() +export class ProjectPostIndustryService { + constructor( + private readonly prisma: PrismaService, + private readonly prismaErrorService: PrismaErrorService, + private readonly eventBusService: EventBusService, + ) {} + + async findAll(): Promise { + const records = await this.prisma.projectPostIndustry.findMany({ + orderBy: [{ id: 'asc' }], + }); + + return records.map((record) => this.toDto(record)); + } + + async findById(id: string): Promise { + const parsedId = parseInt(id, 10); + if (Number.isNaN(parsedId)) { + throw new NotFoundException(`Industry not found for id ${id}.`); + } + + const record = await this.prisma.projectPostIndustry.findFirst({ + where: { + id: BigInt(parsedId), + }, + }); + + if (!record) { + throw new NotFoundException(`Industry not found for id ${id}.`); + } + + return this.toDto(record); + } + + async create( + dto: CreateProjectPostIndustryDto, + userId: number, + ): Promise { + try { + const existing = await this.prisma.projectPostIndustry.findFirst({ + where: { + name: dto.name, + }, + }); + + if (existing) { + throw new ConflictException( + `Project showcase post industry already exists for name ${dto.name}.`, + ); + } + + const created = await this.prisma.projectPostIndustry.create({ + data: { + name: dto.name, + }, + }); + + await publishMetadataEvent( + this.eventBusService, + 'PROJECT_METADATA_CREATE', + PROJECT_METADATA_RESOURCE.PROJECT_POST_INDUSTRY, + String(created.id), + created, + userId, + ); + + return this.toDto(created); + } catch (error) { + this.handleError(error, `create project showcase post industry ${dto.name}`); + } + } + + async update( + id: string, + dto: UpdateProjectPostIndustryDto, + userId: number, + ): Promise { + const parsedId = parseInt(id, 10); + if (Number.isNaN(parsedId)) { + throw new NotFoundException(`Industry not found for id ${id}.`); + } + + const existing = await this.prisma.projectPostIndustry.findFirst({ + where: { + id: BigInt(parsedId), + }, + }); + + if (!existing) { + throw new NotFoundException(`Industry not found for id ${id}.`); + } + + const updated = await this.prisma.projectPostIndustry.update({ + where: { id: BigInt(parsedId) }, + data: { + ...(typeof dto.name === 'undefined' ? {} : { name: dto.name }), + }, + }); + + await publishMetadataEvent( + this.eventBusService, + 'PROJECT_METADATA_UPDATE', + PROJECT_METADATA_RESOURCE.PROJECT_POST_INDUSTRY, + String(updated.id), + updated, + userId, + ); + + return this.toDto(updated); + } + + async delete(id: string, userId: number): Promise { + const parsedId = parseInt(id, 10); + if (Number.isNaN(parsedId)) { + throw new NotFoundException(`Industry not found for id ${id}.`); + } + + const existing = await this.prisma.projectPostIndustry.findFirst({ + where: { + id: BigInt(parsedId), + }, + select: { id: true }, + }); + + if (!existing) { + throw new NotFoundException(`Industry not found for id ${id}.`); + } + + await this.prisma.projectPostIndustry.delete({ + where: { id: BigInt(parsedId) }, + }); + + await publishMetadataEvent( + this.eventBusService, + 'PROJECT_METADATA_DELETE', + PROJECT_METADATA_RESOURCE.PROJECT_POST_INDUSTRY, + String(parsedId), + { id: parsedId }, + userId, + ); + } + + private toDto(record: ProjectPostIndustry): ProjectPostIndustryResponseDto { + return { + id: String(record.id), + name: record.name, + }; + } + + private handleError(error: unknown, operation: string): never { + if (error instanceof HttpException) { + throw error; + } + + this.prismaErrorService.handleError(error, operation); + } +} diff --git a/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts new file mode 100644 index 0000000..ea950a9 --- /dev/null +++ b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ProjectShowcasePostStatus } from '@prisma/client'; + +export class CreateProjectShowcasePostDto { + @ApiProperty({ description: 'Post title.' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: 'Post content.' }) + @IsString() + @IsNotEmpty() + content: string; + + @ApiPropertyOptional({ enum: ProjectShowcasePostStatus }) + @IsOptional() + @IsEnum(ProjectShowcasePostStatus) + status?: ProjectShowcasePostStatus; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + industryIds?: string[]; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + categoryIds?: string[]; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + challengeIds?: string[]; +} diff --git a/src/api/project-showcase-post/dto/project-showcase-post-list-query.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-list-query.dto.ts new file mode 100644 index 0000000..0d469e8 --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-list-query.dto.ts @@ -0,0 +1,67 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsOptional, IsString } from 'class-validator'; +import { PaginationDto } from '../../project/dto/pagination.dto'; + +function parseFilterInput( + value: unknown, +): string | string[] | Record | undefined { + if (typeof value === 'undefined' || value === null || value === '') { + return undefined; + } + + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + return value.map((entry) => String(entry)); + } + + if (value && typeof value === 'object') { + return value as Record; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return `${value}`; + } + + return undefined; +} + +export class ProjectShowcasePostListQueryDto extends PaginationDto { + @ApiPropertyOptional({ description: 'Sort expression. Example: "updatedAt desc"' }) + @IsOptional() + @IsString() + sort?: string; + + @ApiPropertyOptional({ description: 'Filter by post status (draft, published, archived)' }) + @IsOptional() + @Transform(({ value }) => parseFilterInput(value)) + status?: string | string[] | Record; + + @ApiPropertyOptional({ description: 'Filter by project id (exact or $in pattern)' }) + @IsOptional() + @Transform(({ value }) => parseFilterInput(value)) + projectId?: string | string[] | Record; + + @ApiPropertyOptional({ description: 'Filter by industry id (exact or $in pattern)' }) + @IsOptional() + @Transform(({ value }) => parseFilterInput(value)) + industryId?: string | string[] | Record; + + @ApiPropertyOptional({ description: 'Filter by category id (exact or $in pattern)' }) + @IsOptional() + @Transform(({ value }) => parseFilterInput(value)) + categoryId?: string | string[] | Record; + + @ApiPropertyOptional({ description: 'Filter by linked challenge id' }) + @IsOptional() + @IsString() + challengeId?: string; + + @ApiPropertyOptional({ description: 'Search text in title or content' }) + @IsOptional() + @IsString() + keyword?: string; +} diff --git a/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts new file mode 100644 index 0000000..a940bb1 --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ProjectShowcasePostResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + title: string; + + @ApiProperty() + content: string; + + @ApiProperty() + status: string; + + @ApiProperty() + projectId: string; + + @ApiProperty({ type: [String] }) + challengeIds: string[]; + + @ApiProperty({ type: [Object] }) + industries: Array<{ id: string; name: string }>; + + @ApiProperty({ type: [Object] }) + categories: Array<{ id: string; name: string }>; + + @ApiProperty() + createdById: number; + + @ApiProperty() + updatedById: number; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} diff --git a/src/api/project-showcase-post/dto/update-project-showcase-post.dto.ts b/src/api/project-showcase-post/dto/update-project-showcase-post.dto.ts new file mode 100644 index 0000000..75f60ab --- /dev/null +++ b/src/api/project-showcase-post/dto/update-project-showcase-post.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateProjectShowcasePostDto } from './create-project-showcase-post.dto'; + +export class UpdateProjectShowcasePostDto extends PartialType( + CreateProjectShowcasePostDto, +) {} diff --git a/src/api/project-showcase-post/project-showcase-post.controller.ts b/src/api/project-showcase-post/project-showcase-post.controller.ts new file mode 100644 index 0000000..01b6ea7 --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.controller.ts @@ -0,0 +1,175 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Permission } from 'src/shared/constants/permissions'; +import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; +import { RequirePermission } from 'src/shared/decorators/requirePermission.decorator'; +import { Scopes } from 'src/shared/decorators/scopes.decorator'; +import { Scope } from 'src/shared/enums/scopes.enum'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { PermissionGuard } from 'src/shared/guards/permission.guard'; +import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { ProjectShowcasePostResponseDto } from './dto/project-showcase-post-response.dto'; +import { ProjectShowcasePostListQueryDto } from './dto/project-showcase-post-list-query.dto'; +import { CreateProjectShowcasePostDto } from './dto/create-project-showcase-post.dto'; +import { UpdateProjectShowcasePostDto } from './dto/update-project-showcase-post.dto'; +import { ProjectShowcasePostService } from './project-showcase-post.service'; + +@ApiTags('Project Showcase Posts') +@ApiBearerAuth() +@Controller('/projects') +export class ProjectShowcasePostController { + constructor(private readonly service: ProjectShowcasePostService) {} + + @Get('posts') + @UseGuards(PermissionGuard) + @Roles(...Object.values(UserRole)) + @Scopes(Scope.PROJECTS_READ, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) + @RequirePermission(Permission.READ_PROJECT_ANY) + @ApiOperation({ summary: 'Search project showcase posts' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'perPage', required: false, type: Number }) + @ApiQuery({ name: 'sort', required: false, type: String }) + @ApiQuery({ name: 'status', required: false, type: String }) + @ApiQuery({ name: 'projectId', required: false, type: String }) + @ApiQuery({ name: 'industryId', required: false, type: String }) + @ApiQuery({ name: 'categoryId', required: false, type: String }) + @ApiQuery({ name: 'challengeId', required: false, type: String }) + @ApiQuery({ name: 'keyword', required: false, type: String }) + @ApiResponse({ + status: 200, + description: 'Paginated showcase posts list', + type: [ProjectShowcasePostResponseDto], + }) + async searchPosts( + @Query() query: ProjectShowcasePostListQueryDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.listPosts(query, user); + } + + @Get(':projectId/posts') + @UseGuards(PermissionGuard) + @Roles(...Object.values(UserRole)) + @Scopes( + Scope.PROJECTS_READ, + Scope.PROJECTS_WRITE, + Scope.PROJECTS_ALL, + Scope.CONNECT_PROJECT_ADMIN, + ) + @RequirePermission(Permission.VIEW_PROJECT) + @ApiOperation({ summary: 'List showcase posts for a project' }) + @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'perPage', required: false, type: Number }) + @ApiQuery({ name: 'sort', required: false, type: String }) + @ApiQuery({ name: 'status', required: false, type: String }) + @ApiQuery({ name: 'industryId', required: false, type: String }) + @ApiQuery({ name: 'categoryId', required: false, type: String }) + @ApiQuery({ name: 'challengeId', required: false, type: String }) + @ApiQuery({ name: 'keyword', required: false, type: String }) + @ApiResponse({ + status: 200, + description: 'Project showcase posts list', + type: [ProjectShowcasePostResponseDto], + }) + async listProjectPosts( + @Param('projectId') projectId: string, + @Query() query: ProjectShowcasePostListQueryDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.listProjectPosts(projectId, query, user); + } + + @Get(':projectId/posts/:id') + @UseGuards(PermissionGuard) + @Roles(...Object.values(UserRole)) + @Scopes( + Scope.PROJECTS_READ, + Scope.PROJECTS_WRITE, + Scope.PROJECTS_ALL, + Scope.CONNECT_PROJECT_ADMIN, + ) + @RequirePermission(Permission.VIEW_PROJECT) + @ApiOperation({ summary: 'Get a project showcase post' }) + @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) + @ApiParam({ name: 'id', required: true, description: 'Showcase post id' }) + @ApiResponse({ status: 200, type: ProjectShowcasePostResponseDto }) + async getProjectPost( + @Param('projectId') projectId: string, + @Param('id') id: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.getPost(projectId, id, user); + } + + @Post(':projectId/posts') + @UseGuards(PermissionGuard) + @Roles(...Object.values(UserRole)) + @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) + @RequirePermission(Permission.EDIT_PROJECT) + @ApiOperation({ summary: 'Create a project showcase post' }) + @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) + @ApiResponse({ status: 201, type: ProjectShowcasePostResponseDto }) + async createProjectPost( + @Param('projectId') projectId: string, + @Body() dto: CreateProjectShowcasePostDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.createPost(projectId, dto, user); + } + + @Patch(':projectId/posts/:id') + @UseGuards(PermissionGuard) + @Roles(...Object.values(UserRole)) + @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) + @RequirePermission(Permission.EDIT_PROJECT) + @ApiOperation({ summary: 'Update a project showcase post' }) + @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) + @ApiParam({ name: 'id', required: true, description: 'Showcase post id' }) + @ApiResponse({ status: 200, type: ProjectShowcasePostResponseDto }) + async updateProjectPost( + @Param('projectId') projectId: string, + @Param('id') id: string, + @Body() dto: UpdateProjectShowcasePostDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.updatePost(projectId, id, dto, user); + } + + @Delete(':projectId/posts/:id') + @HttpCode(204) + @UseGuards(PermissionGuard) + @Roles(...Object.values(UserRole)) + @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) + @RequirePermission(Permission.EDIT_PROJECT) + @ApiOperation({ summary: 'Delete a project showcase post' }) + @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) + @ApiParam({ name: 'id', required: true, description: 'Showcase post id' }) + @ApiResponse({ status: 204, description: 'Deleted' }) + async deleteProjectPost( + @Param('projectId') projectId: string, + @Param('id') id: string, + @CurrentUser() user: JwtUser, + ): Promise { + await this.service.deletePost(projectId, id, user); + } +} diff --git a/src/api/project-showcase-post/project-showcase-post.module.ts b/src/api/project-showcase-post/project-showcase-post.module.ts new file mode 100644 index 0000000..5e88b28 --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; +import { ProjectShowcasePostController } from './project-showcase-post.controller'; +import { ProjectShowcasePostService } from './project-showcase-post.service'; + +@Module({ + imports: [GlobalProvidersModule], + controllers: [ProjectShowcasePostController], + providers: [ProjectShowcasePostService], + exports: [ProjectShowcasePostService], +}) +export class ProjectShowcasePostModule {} diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts new file mode 100644 index 0000000..ffe46d6 --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -0,0 +1,486 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, ProjectShowcasePost, ProjectShowcasePostStatus } from '@prisma/client'; +import { Permission } from 'src/shared/constants/permissions'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; +import { PermissionService } from 'src/shared/services/permission.service'; +import { + ensureProjectNamedPermission, + getAuditUserId, + loadProjectPermissionContextBase, + parseNumericStringId, + parseOptionalNumericStringId, +} from 'src/shared/utils/service.utils'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { CreateProjectShowcasePostDto } from './dto/create-project-showcase-post.dto'; +import { ProjectShowcasePostListQueryDto } from './dto/project-showcase-post-list-query.dto'; +import { ProjectShowcasePostResponseDto } from './dto/project-showcase-post-response.dto'; +import { UpdateProjectShowcasePostDto } from './dto/update-project-showcase-post.dto'; + +interface PaginatedPostResponse { + data: ProjectShowcasePostResponseDto[]; + page: number; + perPage: number; + total: number; +} + +@Injectable() +export class ProjectShowcasePostService { + constructor( + private readonly prisma: PrismaService, + private readonly permissionService: PermissionService, + ) {} + + async listPosts( + criteria: ProjectShowcasePostListQueryDto, + user: JwtUser, + ): Promise { + const page = criteria.page || 1; + const perPage = criteria.perPage || 20; + const skip = (page - 1) * perPage; + const where = this.buildWhere(criteria); + const orderBy = this.resolveSort(criteria.sort); + + const posts = await this.prisma.projectShowcasePost.findMany({ + where, + include: { + industries: { + include: { industry: true }, + }, + categories: { + include: { category: true }, + }, + }, + orderBy, + skip, + take: perPage, + }); + + return posts.map((post) => this.toDto(post)); + } + + async listProjectPosts( + projectId: string, + criteria: ProjectShowcasePostListQueryDto, + user: JwtUser, + ): Promise { + const parsedProjectId = parseNumericStringId(projectId, 'Project id'); + const project = await loadProjectPermissionContextBase( + this.prisma, + parsedProjectId, + ); + ensureProjectNamedPermission( + this.permissionService, + Permission.VIEW_PROJECT, + user, + project.members, + ); + + const page = criteria.page || 1; + const perPage = criteria.perPage || 20; + const skip = (page - 1) * perPage; + const where = this.buildWhere(criteria, parsedProjectId); + const orderBy = this.resolveSort(criteria.sort); + + const posts = await this.prisma.projectShowcasePost.findMany({ + where, + include: { + industries: { + include: { industry: true }, + }, + categories: { + include: { category: true }, + }, + }, + orderBy, + skip, + take: perPage, + }); + + return posts.map((post) => this.toDto(post)); + } + + async getPost( + projectId: string, + id: string, + user: JwtUser, + ): Promise { + const parsedProjectId = parseNumericStringId(projectId, 'Project id'); + const parsedId = parseNumericStringId(id, 'Showcase post id'); + + const project = await loadProjectPermissionContextBase( + this.prisma, + parsedProjectId, + ); + ensureProjectNamedPermission( + this.permissionService, + Permission.VIEW_PROJECT, + user, + project.members, + ); + + const post = await this.prisma.projectShowcasePost.findFirst({ + where: { + id: parsedId, + projectId: parsedProjectId, + }, + include: { + industries: { + include: { industry: true }, + }, + categories: { + include: { category: true }, + }, + }, + }); + + if (!post) { + throw new NotFoundException( + `Showcase post ${id} was not found for project ${projectId}.`, + ); + } + + return this.toDto(post); + } + + async createPost( + projectId: string, + dto: CreateProjectShowcasePostDto, + user: JwtUser, + ): Promise { + const parsedProjectId = parseNumericStringId(projectId, 'Project id'); + const project = await loadProjectPermissionContextBase( + this.prisma, + parsedProjectId, + ); + ensureProjectNamedPermission( + this.permissionService, + Permission.EDIT_PROJECT, + user, + project.members, + ); + + const auditUserId = getAuditUserId(user); + + const created = await this.prisma.projectShowcasePost.create({ + data: { + title: dto.title, + content: dto.content, + status: dto.status ?? 'draft', + projectId: parsedProjectId, + challengeIds: dto.challengeIds || [], + createdById: auditUserId, + updatedById: auditUserId, + industries: { + create: (dto.industryIds || []).map((industryId) => ({ + industryId: parseNumericStringId( + String(industryId), + 'Industry id', + ), + })), + }, + categories: { + create: (dto.categoryIds || []).map((categoryId) => ({ + categoryId: parseNumericStringId( + String(categoryId), + 'Category id', + ), + })), + }, + }, + include: { + industries: { + include: { industry: true }, + }, + categories: { + include: { category: true }, + }, + }, + }); + + return this.toDto(created); + } + + async updatePost( + projectId: string, + id: string, + dto: UpdateProjectShowcasePostDto, + user: JwtUser, + ): Promise { + const parsedProjectId = parseNumericStringId(projectId, 'Project id'); + const parsedId = parseNumericStringId(id, 'Showcase post id'); + + const project = await loadProjectPermissionContextBase( + this.prisma, + parsedProjectId, + ); + ensureProjectNamedPermission( + this.permissionService, + Permission.EDIT_PROJECT, + user, + project.members, + ); + + const existing = await this.prisma.projectShowcasePost.findFirst({ + where: { + id: parsedId, + projectId: parsedProjectId, + }, + }); + + if (!existing) { + throw new NotFoundException( + `Showcase post ${id} was not found for project ${projectId}.`, + ); + } + + const updateData: Prisma.ProjectShowcasePostUpdateInput = { + ...(typeof dto.title === 'undefined' ? {} : { title: dto.title }), + ...(typeof dto.content === 'undefined' ? {} : { content: dto.content }), + ...(typeof dto.status === 'undefined' ? {} : { status: dto.status }), + ...(typeof dto.challengeIds === 'undefined' + ? {} + : { challengeIds: dto.challengeIds }), + updatedById: getAuditUserId(user), + }; + + if (typeof dto.industryIds !== 'undefined') { + updateData.industries = { + deleteMany: {}, + create: dto.industryIds.map((industryId) => ({ + industryId: parseNumericStringId( + String(industryId), + 'Industry id', + ), + })), + }; + } + + if (typeof dto.categoryIds !== 'undefined') { + updateData.categories = { + deleteMany: {}, + create: dto.categoryIds.map((categoryId) => ({ + categoryId: parseNumericStringId( + String(categoryId), + 'Category id', + ), + })), + }; + } + + const updated = await this.prisma.projectShowcasePost.update({ + where: { + id: parsedId, + }, + data: updateData, + include: { + industries: { + include: { industry: true }, + }, + categories: { + include: { category: true }, + }, + }, + }); + + return this.toDto(updated); + } + + async deletePost( + projectId: string, + id: string, + user: JwtUser, + ): Promise { + const parsedProjectId = parseNumericStringId(projectId, 'Project id'); + const parsedId = parseNumericStringId(id, 'Showcase post id'); + + const project = await loadProjectPermissionContextBase( + this.prisma, + parsedProjectId, + ); + ensureProjectNamedPermission( + this.permissionService, + Permission.EDIT_PROJECT, + user, + project.members, + ); + + const existing = await this.prisma.projectShowcasePost.findFirst({ + where: { + id: parsedId, + projectId: parsedProjectId, + }, + }); + + if (!existing) { + throw new NotFoundException( + `Showcase post ${id} was not found for project ${projectId}.`, + ); + } + + await this.prisma.projectShowcasePost.delete({ + where: { + id: parsedId, + }, + }); + } + + private buildWhere( + criteria: ProjectShowcasePostListQueryDto, + projectId?: bigint, + ): Prisma.ProjectShowcasePostWhereInput { + const where: Prisma.ProjectShowcasePostWhereInput = {}; + + if (projectId) { + where.projectId = projectId; + } else if (criteria.projectId) { + const ids = this.toBigIntFilter(criteria.projectId); + if (ids.length === 1) { + where.projectId = ids[0]; + } else if (ids.length > 1) { + where.projectId = { in: ids }; + } + } + + const status = this.toStringListFilter(criteria.status) as ProjectShowcasePostStatus[]; + if (status.length === 1) { + where.status = status[0]; + } else if (status.length > 1) { + where.status = { in: status }; + } + + const industryIds = this.toBigIntFilter(criteria.industryId); + if (industryIds.length > 0) { + where.industries = { + some: { + industryId: { in: industryIds }, + }, + }; + } + + const categoryIds = this.toBigIntFilter(criteria.categoryId); + if (categoryIds.length > 0) { + where.categories = { + some: { + categoryId: { in: categoryIds }, + }, + }; + } + + if (criteria.challengeId) { + where.challengeIds = { has: criteria.challengeId }; + } + + if (criteria.keyword) { + where.OR = [ + { title: { contains: criteria.keyword, mode: 'insensitive' } }, + { content: { contains: criteria.keyword, mode: 'insensitive' } }, + ]; + } + + return where; + } + + private resolveSort( + sort?: string, + ): Prisma.Enumerable { + if (!sort || sort.trim().length === 0) { + return [{ updatedAt: 'desc' }]; + } + + const [field, direction = 'desc'] = sort.trim().split(/\s+/gi).filter(Boolean); + const normalizedDirection = direction.toLowerCase().includes('asc') + ? 'asc' + : 'desc'; + + switch (field) { + case 'title': + case 'status': + case 'createdAt': + case 'updatedAt': + return [{ [field]: normalizedDirection }]; + default: + return [{ updatedAt: 'desc' }]; + } + } + + private toDto( + post: ProjectShowcasePost & { + industries: { industry: { id: bigint; name: string } }[]; + categories: { category: { id: bigint; name: string } }[]; + }, + ): ProjectShowcasePostResponseDto { + return { + id: String(post.id), + title: post.title, + content: post.content, + status: post.status, + projectId: String(post.projectId), + challengeIds: post.challengeIds, + createdById: post.createdById, + updatedById: post.updatedById, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + industries: post.industries.map((entry) => ({ + id: String(entry.industry.id), + name: entry.industry.name, + })), + categories: post.categories.map((entry) => ({ + id: String(entry.category.id), + name: entry.category.name, + })), + }; + } + + private toBigIntFilter( + value: string | string[] | Record | undefined, + ): bigint[] { + if (!value) { + return []; + } + + const values = + typeof value === 'string' + ? [value] + : Array.isArray(value) + ? value.map((entry) => String(entry)) + : value && typeof value === 'object' && 'in' in value && Array.isArray(value.in) + ? value.in.map((entry) => String(entry)) + : []; + + return values + .map((entry) => { + try { + return parseNumericStringId(String(entry), 'Filter id'); + } catch { + return undefined; + } + }) + .filter((entry): entry is bigint => typeof entry === 'bigint'); + } + + private toStringListFilter( + value: string | string[] | Record | undefined, + ): string[] { + if (!value) { + return []; + } + + if (typeof value === 'string') { + return [value]; + } + + if (Array.isArray(value)) { + return value.map((entry) => String(entry)); + } + + if (value && typeof value === 'object' && 'in' in value && Array.isArray(value.in)) { + return value.in.map((entry) => String(entry)); + } + + return []; + } +} diff --git a/src/main.ts b/src/main.ts index 8af8af2..4bc600d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,7 @@ import { WorkStreamModule } from './api/workstream/workstream.module'; import { AppModule } from './app.module'; import { enrichSwaggerAuthDocumentation } from './shared/utils/swagger.utils'; import { LoggerService } from './shared/modules/global/logger.service'; +import { MetadataModule } from './api/metadata/metadata.module'; // TODO (quality): Move serializeBigInt to src/shared/utils/serialization.utils.ts /** @@ -255,7 +256,7 @@ curl --request POST \\ // Swagger setup. const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig, { // TODO (quality): WorkStreamModule is included in the Swagger document but is not imported in ApiModule. Either add WorkStreamModule to ApiModule's imports array, or remove it from the Swagger include list to avoid documentation drift. - include: [ApiModule, WorkStreamModule], + include: [ApiModule, WorkStreamModule, MetadataModule], deepScanRoutes: true, extraModels: [...EVENT_SWAGGER_MODELS], }); diff --git a/src/shared/constants/event.constants.ts b/src/shared/constants/event.constants.ts index 6db93ec..6ef215b 100644 --- a/src/shared/constants/event.constants.ts +++ b/src/shared/constants/event.constants.ts @@ -28,6 +28,10 @@ export const PROJECT_METADATA_RESOURCE = { MILESTONE_TEMPLATE: 'milestone.template', /** Work-management permission metadata updates from work settings module. */ WORK_MANAGEMENT_PERMISSION: 'project.workManagementPermission', + /** Project showcase post industry metadata updates from the project showcase post industry module. */ + PROJECT_POST_INDUSTRY: 'project.postIndustry', + /** Project showcase post category metadata updates from the project showcase post category module. */ + PROJECT_POST_CATEGORY: 'project.postCategory', } as const; /** diff --git a/test/fixtures/database-seed.ts b/test/fixtures/database-seed.ts index eb86f9b..11c491f 100644 --- a/test/fixtures/database-seed.ts +++ b/test/fixtures/database-seed.ts @@ -512,7 +512,6 @@ export async function seedDatabaseFixtures( data: { projectId: project.id, status, - createdBy: FIXTURE_AUDIT_USER_ID, updatedBy: FIXTURE_AUDIT_USER_ID, }, }); From 9fd009ab1ca265af1110822bd59ccf2889a3fc6a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 25 Jun 2026 14:30:06 +0300 Subject: [PATCH 05/22] PM-5307 - move metadata, rename status enum --- .../migration.sql | 4 +- prisma/schema.prisma | 8 +-- src/api/metadata/metadata.module.ts | 4 -- .../dto/create-project-post-category.dto.ts | 0 .../dto/project-post-category-response.dto.ts | 0 .../dto/update-project-post-category.dto.ts | 0 .../project-post-category.controller.ts | 6 +- .../project-post-category.module.ts | 0 .../project-post-category.service.ts | 4 +- .../dto/create-project-post-industry.dto.ts | 0 .../dto/project-post-industry-response.dto.ts | 0 .../dto/update-project-post-industry.dto.ts | 0 .../project-post-industry.controller.ts | 6 +- .../project-post-industry.module.ts | 0 .../project-post-industry.service.ts | 4 +- .../project-showcase-post.controller.ts | 3 +- .../project-showcase-post.module.ts | 16 ++++- .../project-showcase-post.service.ts | 68 ++++++++----------- 18 files changed, 61 insertions(+), 62 deletions(-) rename src/api/{metadata => project-showcase-post}/project-post-category/dto/create-project-post-category.dto.ts (100%) rename src/api/{metadata => project-showcase-post}/project-post-category/dto/project-post-category-response.dto.ts (100%) rename src/api/{metadata => project-showcase-post}/project-post-category/dto/update-project-post-category.dto.ts (100%) rename src/api/{metadata => project-showcase-post}/project-post-category/project-post-category.controller.ts (95%) rename src/api/{metadata => project-showcase-post}/project-post-category/project-post-category.module.ts (100%) rename src/api/{metadata => project-showcase-post}/project-post-category/project-post-category.service.ts (97%) rename src/api/{metadata => project-showcase-post}/project-post-industry/dto/create-project-post-industry.dto.ts (100%) rename src/api/{metadata => project-showcase-post}/project-post-industry/dto/project-post-industry-response.dto.ts (100%) rename src/api/{metadata => project-showcase-post}/project-post-industry/dto/update-project-post-industry.dto.ts (100%) rename src/api/{metadata => project-showcase-post}/project-post-industry/project-post-industry.controller.ts (95%) rename src/api/{metadata => project-showcase-post}/project-post-industry/project-post-industry.module.ts (100%) rename src/api/{metadata => project-showcase-post}/project-post-industry/project-post-industry.service.ts (97%) diff --git a/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql b/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql index 16c7b96..59fce76 100644 --- a/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql +++ b/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql @@ -1,12 +1,12 @@ -- Create enum for project showcase post status -CREATE TYPE projects."ProjectShowcasePostStatus" AS ENUM ('draft', 'published', 'archived'); +CREATE TYPE projects."ProjectShowcasePostStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED'); -- Create project showcase posts table CREATE TABLE projects."project_showcase_posts" ( "id" BIGSERIAL PRIMARY KEY, "title" VARCHAR(255) NOT NULL, "content" TEXT NOT NULL, - "status" projects."ProjectShowcasePostStatus" NOT NULL DEFAULT 'draft', + "status" projects."ProjectShowcasePostStatus" NOT NULL DEFAULT 'DRAFT', "projectId" BIGINT NOT NULL, "challengeIds" TEXT[] DEFAULT ARRAY[]::TEXT[], "createdById" INTEGER NOT NULL, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e02adff..6345d55 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1054,9 +1054,9 @@ model CustomerPayment { /// Project showcase post lifecycle status. enum ProjectShowcasePostStatus { - draft - published - archived + DRAFT + PUBLISHED + ARCHIVED } /// Showcase content posts linked to a project. @@ -1064,7 +1064,7 @@ model ProjectShowcasePost { id BigInt @id @default(autoincrement()) title String content String @db.Text - status ProjectShowcasePostStatus @default(draft) + status ProjectShowcasePostStatus @default(DRAFT) projectId BigInt challengeIds String[] @default([]) createdById Int diff --git a/src/api/metadata/metadata.module.ts b/src/api/metadata/metadata.module.ts index 53f77ef..c49def7 100644 --- a/src/api/metadata/metadata.module.ts +++ b/src/api/metadata/metadata.module.ts @@ -11,8 +11,6 @@ import { ProductTemplateModule } from './product-template/product-template.modul import { ProjectTemplateModule } from './project-template/project-template.module'; import { ProjectTypeModule } from './project-type/project-type.module'; import { WorkManagementPermissionModule } from './work-management-permission/work-management-permission.module'; -import { ProjectPostIndustryModule } from './project-post-industry/project-post-industry.module'; -import { ProjectPostCategoryModule } from './project-post-category/project-post-category.module'; /** * Aggregates all metadata sub-modules used by the projects API. @@ -46,8 +44,6 @@ import { ProjectPostCategoryModule } from './project-post-category/project-post- PlanConfigModule, PriceConfigModule, WorkManagementPermissionModule, - ProjectPostIndustryModule, - ProjectPostCategoryModule, ], controllers: [MetadataListController], providers: [MetadataListService], diff --git a/src/api/metadata/project-post-category/dto/create-project-post-category.dto.ts b/src/api/project-showcase-post/project-post-category/dto/create-project-post-category.dto.ts similarity index 100% rename from src/api/metadata/project-post-category/dto/create-project-post-category.dto.ts rename to src/api/project-showcase-post/project-post-category/dto/create-project-post-category.dto.ts diff --git a/src/api/metadata/project-post-category/dto/project-post-category-response.dto.ts b/src/api/project-showcase-post/project-post-category/dto/project-post-category-response.dto.ts similarity index 100% rename from src/api/metadata/project-post-category/dto/project-post-category-response.dto.ts rename to src/api/project-showcase-post/project-post-category/dto/project-post-category-response.dto.ts diff --git a/src/api/metadata/project-post-category/dto/update-project-post-category.dto.ts b/src/api/project-showcase-post/project-post-category/dto/update-project-post-category.dto.ts similarity index 100% rename from src/api/metadata/project-post-category/dto/update-project-post-category.dto.ts rename to src/api/project-showcase-post/project-post-category/dto/update-project-post-category.dto.ts diff --git a/src/api/metadata/project-post-category/project-post-category.controller.ts b/src/api/project-showcase-post/project-post-category/project-post-category.controller.ts similarity index 95% rename from src/api/metadata/project-post-category/project-post-category.controller.ts rename to src/api/project-showcase-post/project-post-category/project-post-category.controller.ts index 74c699c..5f46583 100644 --- a/src/api/metadata/project-post-category/project-post-category.controller.ts +++ b/src/api/project-showcase-post/project-post-category/project-post-category.controller.ts @@ -19,16 +19,16 @@ import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; import { AnyAuthenticated } from 'src/shared/guards/tokenRoles.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; -import { getAuditUserIdNumber } from '../utils/metadata-utils'; +import { getAuditUserIdNumber } from 'src/api/metadata/utils/metadata-utils'; import { CreateProjectPostCategoryDto } from './dto/create-project-post-category.dto'; import { ProjectPostCategoryResponseDto } from './dto/project-post-category-response.dto'; import { UpdateProjectPostCategoryDto } from './dto/update-project-post-category.dto'; import { ProjectPostCategoryService } from './project-post-category.service'; -@ApiTags('Metadata - Project Post Categories') +@ApiTags('Project Showcase Post Categories') @ApiBearerAuth() @AnyAuthenticated() -@Controller('/projects/metadata/projectPostCategories') +@Controller('/projects/posts/categories') export class ProjectPostCategoryController { constructor( private readonly service: ProjectPostCategoryService, diff --git a/src/api/metadata/project-post-category/project-post-category.module.ts b/src/api/project-showcase-post/project-post-category/project-post-category.module.ts similarity index 100% rename from src/api/metadata/project-post-category/project-post-category.module.ts rename to src/api/project-showcase-post/project-post-category/project-post-category.module.ts diff --git a/src/api/metadata/project-post-category/project-post-category.service.ts b/src/api/project-showcase-post/project-post-category/project-post-category.service.ts similarity index 97% rename from src/api/metadata/project-post-category/project-post-category.service.ts rename to src/api/project-showcase-post/project-post-category/project-post-category.service.ts index d41dd00..3beea1c 100644 --- a/src/api/metadata/project-post-category/project-post-category.service.ts +++ b/src/api/project-showcase-post/project-post-category/project-post-category.service.ts @@ -11,8 +11,8 @@ import { EventBusService } from 'src/shared/modules/global/eventBus.service'; import { PROJECT_METADATA_RESOURCE, publishMetadataEvent, -} from '../utils/metadata-event.utils'; -import { toSerializable } from '../utils/metadata-utils'; +} from 'src/api/metadata/utils/metadata-event.utils'; +import { toSerializable } from 'src/api/metadata/utils/metadata-utils'; import { CreateProjectPostCategoryDto } from './dto/create-project-post-category.dto'; import { ProjectPostCategoryResponseDto } from './dto/project-post-category-response.dto'; import { UpdateProjectPostCategoryDto } from './dto/update-project-post-category.dto'; diff --git a/src/api/metadata/project-post-industry/dto/create-project-post-industry.dto.ts b/src/api/project-showcase-post/project-post-industry/dto/create-project-post-industry.dto.ts similarity index 100% rename from src/api/metadata/project-post-industry/dto/create-project-post-industry.dto.ts rename to src/api/project-showcase-post/project-post-industry/dto/create-project-post-industry.dto.ts diff --git a/src/api/metadata/project-post-industry/dto/project-post-industry-response.dto.ts b/src/api/project-showcase-post/project-post-industry/dto/project-post-industry-response.dto.ts similarity index 100% rename from src/api/metadata/project-post-industry/dto/project-post-industry-response.dto.ts rename to src/api/project-showcase-post/project-post-industry/dto/project-post-industry-response.dto.ts diff --git a/src/api/metadata/project-post-industry/dto/update-project-post-industry.dto.ts b/src/api/project-showcase-post/project-post-industry/dto/update-project-post-industry.dto.ts similarity index 100% rename from src/api/metadata/project-post-industry/dto/update-project-post-industry.dto.ts rename to src/api/project-showcase-post/project-post-industry/dto/update-project-post-industry.dto.ts diff --git a/src/api/metadata/project-post-industry/project-post-industry.controller.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.ts similarity index 95% rename from src/api/metadata/project-post-industry/project-post-industry.controller.ts rename to src/api/project-showcase-post/project-post-industry/project-post-industry.controller.ts index 814e9ed..925e6cd 100644 --- a/src/api/metadata/project-post-industry/project-post-industry.controller.ts +++ b/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.ts @@ -19,16 +19,16 @@ import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; import { AdminOnly } from 'src/shared/guards/adminOnly.guard'; import { AnyAuthenticated } from 'src/shared/guards/tokenRoles.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; -import { getAuditUserIdNumber } from '../utils/metadata-utils'; +import { getAuditUserIdNumber } from 'src/api/metadata/utils/metadata-utils'; import { CreateProjectPostIndustryDto } from './dto/create-project-post-industry.dto'; import { ProjectPostIndustryResponseDto } from './dto/project-post-industry-response.dto'; import { UpdateProjectPostIndustryDto } from './dto/update-project-post-industry.dto'; import { ProjectPostIndustryService } from './project-post-industry.service'; -@ApiTags('Metadata - Project Post Industries') +@ApiTags('Project Showcase Post Industries') @ApiBearerAuth() @AnyAuthenticated() -@Controller('/projects/metadata/projectPostIndustries') +@Controller('/projects/posts/industries') export class ProjectPostIndustryController { constructor( private readonly service: ProjectPostIndustryService, diff --git a/src/api/metadata/project-post-industry/project-post-industry.module.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.module.ts similarity index 100% rename from src/api/metadata/project-post-industry/project-post-industry.module.ts rename to src/api/project-showcase-post/project-post-industry/project-post-industry.module.ts diff --git a/src/api/metadata/project-post-industry/project-post-industry.service.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.service.ts similarity index 97% rename from src/api/metadata/project-post-industry/project-post-industry.service.ts rename to src/api/project-showcase-post/project-post-industry/project-post-industry.service.ts index 0ee7d8d..ea89754 100644 --- a/src/api/metadata/project-post-industry/project-post-industry.service.ts +++ b/src/api/project-showcase-post/project-post-industry/project-post-industry.service.ts @@ -11,8 +11,8 @@ import { EventBusService } from 'src/shared/modules/global/eventBus.service'; import { PROJECT_METADATA_RESOURCE, publishMetadataEvent, -} from '../utils/metadata-event.utils'; -import { toSerializable } from '../utils/metadata-utils'; +} from 'src/api/metadata/utils/metadata-event.utils'; +import { toSerializable } from 'src/api/metadata/utils/metadata-utils'; import { CreateProjectPostIndustryDto } from './dto/create-project-post-industry.dto'; import { ProjectPostIndustryResponseDto } from './dto/project-post-industry-response.dto'; import { UpdateProjectPostIndustryDto } from './dto/update-project-post-industry.dto'; diff --git a/src/api/project-showcase-post/project-showcase-post.controller.ts b/src/api/project-showcase-post/project-showcase-post.controller.ts index 01b6ea7..60d3971 100644 --- a/src/api/project-showcase-post/project-showcase-post.controller.ts +++ b/src/api/project-showcase-post/project-showcase-post.controller.ts @@ -61,9 +61,8 @@ export class ProjectShowcasePostController { }) async searchPosts( @Query() query: ProjectShowcasePostListQueryDto, - @CurrentUser() user: JwtUser, ): Promise { - return this.service.listPosts(query, user); + return this.service.listPosts(query); } @Get(':projectId/posts') diff --git a/src/api/project-showcase-post/project-showcase-post.module.ts b/src/api/project-showcase-post/project-showcase-post.module.ts index 5e88b28..fbb9885 100644 --- a/src/api/project-showcase-post/project-showcase-post.module.ts +++ b/src/api/project-showcase-post/project-showcase-post.module.ts @@ -2,11 +2,23 @@ import { Module } from '@nestjs/common'; import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; import { ProjectShowcasePostController } from './project-showcase-post.controller'; import { ProjectShowcasePostService } from './project-showcase-post.service'; +import { ProjectPostCategoryController } from './project-post-category/project-post-category.controller'; +import { ProjectPostCategoryService } from './project-post-category/project-post-category.service'; +import { ProjectPostIndustryController } from './project-post-industry/project-post-industry.controller'; +import { ProjectPostIndustryService } from './project-post-industry/project-post-industry.service'; @Module({ imports: [GlobalProvidersModule], - controllers: [ProjectShowcasePostController], - providers: [ProjectShowcasePostService], + controllers: [ + ProjectShowcasePostController, + ProjectPostCategoryController, + ProjectPostIndustryController, + ], + providers: [ + ProjectShowcasePostService, + ProjectPostCategoryService, + ProjectPostIndustryService, + ], exports: [ProjectShowcasePostService], }) export class ProjectShowcasePostModule {} diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index ffe46d6..43cd8ea 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -1,9 +1,9 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { Prisma, ProjectShowcasePost, ProjectShowcasePostStatus } from '@prisma/client'; + Prisma, + ProjectShowcasePost, + ProjectShowcasePostStatus, +} from '@prisma/client'; import { Permission } from 'src/shared/constants/permissions'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { PermissionService } from 'src/shared/services/permission.service'; @@ -12,7 +12,6 @@ import { getAuditUserId, loadProjectPermissionContextBase, parseNumericStringId, - parseOptionalNumericStringId, } from 'src/shared/utils/service.utils'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { CreateProjectShowcasePostDto } from './dto/create-project-showcase-post.dto'; @@ -20,13 +19,6 @@ import { ProjectShowcasePostListQueryDto } from './dto/project-showcase-post-lis import { ProjectShowcasePostResponseDto } from './dto/project-showcase-post-response.dto'; import { UpdateProjectShowcasePostDto } from './dto/update-project-showcase-post.dto'; -interface PaginatedPostResponse { - data: ProjectShowcasePostResponseDto[]; - page: number; - perPage: number; - total: number; -} - @Injectable() export class ProjectShowcasePostService { constructor( @@ -36,7 +28,6 @@ export class ProjectShowcasePostService { async listPosts( criteria: ProjectShowcasePostListQueryDto, - user: JwtUser, ): Promise { const page = criteria.page || 1; const perPage = criteria.perPage || 20; @@ -169,25 +160,19 @@ export class ProjectShowcasePostService { data: { title: dto.title, content: dto.content, - status: dto.status ?? 'draft', + status: dto.status ?? 'DRAFT', projectId: parsedProjectId, challengeIds: dto.challengeIds || [], createdById: auditUserId, updatedById: auditUserId, industries: { create: (dto.industryIds || []).map((industryId) => ({ - industryId: parseNumericStringId( - String(industryId), - 'Industry id', - ), + industryId: parseNumericStringId(String(industryId), 'Industry id'), })), }, categories: { create: (dto.categoryIds || []).map((categoryId) => ({ - categoryId: parseNumericStringId( - String(categoryId), - 'Category id', - ), + categoryId: parseNumericStringId(String(categoryId), 'Category id'), })), }, }, @@ -251,10 +236,7 @@ export class ProjectShowcasePostService { updateData.industries = { deleteMany: {}, create: dto.industryIds.map((industryId) => ({ - industryId: parseNumericStringId( - String(industryId), - 'Industry id', - ), + industryId: parseNumericStringId(String(industryId), 'Industry id'), })), }; } @@ -263,10 +245,7 @@ export class ProjectShowcasePostService { updateData.categories = { deleteMany: {}, create: dto.categoryIds.map((categoryId) => ({ - categoryId: parseNumericStringId( - String(categoryId), - 'Category id', - ), + categoryId: parseNumericStringId(String(categoryId), 'Category id'), })), }; } @@ -345,7 +324,9 @@ export class ProjectShowcasePostService { } } - const status = this.toStringListFilter(criteria.status) as ProjectShowcasePostStatus[]; + const status = this.toStringListFilter( + criteria.status, + ) as ProjectShowcasePostStatus[]; if (status.length === 1) { where.status = status[0]; } else if (status.length > 1) { @@ -391,7 +372,10 @@ export class ProjectShowcasePostService { return [{ updatedAt: 'desc' }]; } - const [field, direction = 'desc'] = sort.trim().split(/\s+/gi).filter(Boolean); + const [field, direction = 'desc'] = sort + .trim() + .split(/\s+/gi) + .filter(Boolean); const normalizedDirection = direction.toLowerCase().includes('asc') ? 'asc' : 'desc'; @@ -446,10 +430,13 @@ export class ProjectShowcasePostService { typeof value === 'string' ? [value] : Array.isArray(value) - ? value.map((entry) => String(entry)) - : value && typeof value === 'object' && 'in' in value && Array.isArray(value.in) - ? value.in.map((entry) => String(entry)) - : []; + ? value.map((entry) => String(entry)) + : value && + typeof value === 'object' && + 'in' in value && + Array.isArray(value.in) + ? value.in.map((entry) => String(entry)) + : []; return values .map((entry) => { @@ -477,7 +464,12 @@ export class ProjectShowcasePostService { return value.map((entry) => String(entry)); } - if (value && typeof value === 'object' && 'in' in value && Array.isArray(value.in)) { + if ( + value && + typeof value === 'object' && + 'in' in value && + Array.isArray(value.in) + ) { return value.in.map((entry) => String(entry)); } From 0d3354c02f37068ea00fa6bd8a1ecbd8545b7c2e Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 26 Jun 2026 10:56:13 +0300 Subject: [PATCH 06/22] lint & pr feedback --- prisma/add_types.sql | 6 ++-- src/api/metadata/metadata.module.ts | 2 -- .../project-showcase-post-list-query.dto.ts | 20 ++++++++--- .../dto/project-showcase-post-response.dto.ts | 5 +-- .../project-post-category.controller.ts | 6 ++-- .../project-post-category.service.ts | 34 ++++++++++--------- .../project-post-industry.controller.ts | 6 ++-- .../project-post-industry.service.ts | 28 ++++++++------- .../project-showcase-post.module.ts | 22 +++++------- .../project-showcase-post.service.ts | 4 +-- 10 files changed, 69 insertions(+), 64 deletions(-) diff --git a/prisma/add_types.sql b/prisma/add_types.sql index 401bcb8..d78e189 100644 --- a/prisma/add_types.sql +++ b/prisma/add_types.sql @@ -221,9 +221,9 @@ BEGIN WHERE n.nspname = 'projects' AND t.typname = 'ProjectShowcasePostStatus' ) THEN CREATE TYPE projects."ProjectShowcasePostStatus" AS ENUM ( - 'draft', - 'published', - 'archived' + 'DRAFT', + 'PUBLISHED', + 'ARCHIVED' ); END IF; diff --git a/src/api/metadata/metadata.module.ts b/src/api/metadata/metadata.module.ts index c49def7..957ee1b 100644 --- a/src/api/metadata/metadata.module.ts +++ b/src/api/metadata/metadata.module.ts @@ -26,8 +26,6 @@ import { WorkManagementPermissionModule } from './work-management-permission/wor * - `PlanConfigModule`: versioned plan configuration definitions. * - `PriceConfigModule`: versioned pricing configuration definitions. * - `WorkManagementPermissionModule`: permission policy records by template. - * - `ProjectPostIndustryModule`: project showcase post industry taxonomy. - * - `ProjectPostCategoryModule`: project showcase post category taxonomy. * * It also registers `MetadataListController` and `MetadataListService` for the * consolidated metadata list endpoint. diff --git a/src/api/project-showcase-post/dto/project-showcase-post-list-query.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-list-query.dto.ts index 0d469e8..20ef277 100644 --- a/src/api/project-showcase-post/dto/project-showcase-post-list-query.dto.ts +++ b/src/api/project-showcase-post/dto/project-showcase-post-list-query.dto.ts @@ -30,27 +30,37 @@ function parseFilterInput( } export class ProjectShowcasePostListQueryDto extends PaginationDto { - @ApiPropertyOptional({ description: 'Sort expression. Example: "updatedAt desc"' }) + @ApiPropertyOptional({ + description: 'Sort expression. Example: "updatedAt desc"', + }) @IsOptional() @IsString() sort?: string; - @ApiPropertyOptional({ description: 'Filter by post status (draft, published, archived)' }) + @ApiPropertyOptional({ + description: 'Filter by post status (DRAFT, PUBLISHED, ARCHIVED)', + }) @IsOptional() @Transform(({ value }) => parseFilterInput(value)) status?: string | string[] | Record; - @ApiPropertyOptional({ description: 'Filter by project id (exact or $in pattern)' }) + @ApiPropertyOptional({ + description: 'Filter by project id (exact or $in pattern)', + }) @IsOptional() @Transform(({ value }) => parseFilterInput(value)) projectId?: string | string[] | Record; - @ApiPropertyOptional({ description: 'Filter by industry id (exact or $in pattern)' }) + @ApiPropertyOptional({ + description: 'Filter by industry id (exact or $in pattern)', + }) @IsOptional() @Transform(({ value }) => parseFilterInput(value)) industryId?: string | string[] | Record; - @ApiPropertyOptional({ description: 'Filter by category id (exact or $in pattern)' }) + @ApiPropertyOptional({ + description: 'Filter by category id (exact or $in pattern)', + }) @IsOptional() @Transform(({ value }) => parseFilterInput(value)) categoryId?: string | string[] | Record; diff --git a/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts index a940bb1..4991c84 100644 --- a/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts +++ b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { ProjectShowcasePostStatus } from '@prisma/client'; export class ProjectShowcasePostResponseDto { @ApiProperty() @@ -10,8 +11,8 @@ export class ProjectShowcasePostResponseDto { @ApiProperty() content: string; - @ApiProperty() - status: string; + @ApiProperty({ enum: ProjectShowcasePostStatus }) + status: ProjectShowcasePostStatus; @ApiProperty() projectId: string; diff --git a/src/api/project-showcase-post/project-post-category/project-post-category.controller.ts b/src/api/project-showcase-post/project-post-category/project-post-category.controller.ts index 5f46583..59148b4 100644 --- a/src/api/project-showcase-post/project-post-category/project-post-category.controller.ts +++ b/src/api/project-showcase-post/project-post-category/project-post-category.controller.ts @@ -30,9 +30,7 @@ import { ProjectPostCategoryService } from './project-post-category.service'; @AnyAuthenticated() @Controller('/projects/posts/categories') export class ProjectPostCategoryController { - constructor( - private readonly service: ProjectPostCategoryService, - ) {} + constructor(private readonly service: ProjectPostCategoryService) {} @Get() @ApiOperation({ summary: 'List project showcase post categories' }) @@ -82,7 +80,7 @@ export class ProjectPostCategoryController { @Delete(':id') @HttpCode(204) @AdminOnly() - @ApiOperation({ summary: 'Delete project showcase post category (soft delete)' }) + @ApiOperation({ summary: 'Delete project showcase post category' }) @ApiParam({ name: 'id', description: 'Category id' }) @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) diff --git a/src/api/project-showcase-post/project-post-category/project-post-category.service.ts b/src/api/project-showcase-post/project-post-category/project-post-category.service.ts index 3beea1c..d41860d 100644 --- a/src/api/project-showcase-post/project-post-category/project-post-category.service.ts +++ b/src/api/project-showcase-post/project-post-category/project-post-category.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { Prisma, ProjectPostCategory } from '@prisma/client'; +import { ProjectPostCategory } from '@prisma/client'; import { PrismaErrorService } from 'src/shared/modules/global/prisma-error.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { EventBusService } from 'src/shared/modules/global/eventBus.service'; @@ -12,7 +12,6 @@ import { PROJECT_METADATA_RESOURCE, publishMetadataEvent, } from 'src/api/metadata/utils/metadata-event.utils'; -import { toSerializable } from 'src/api/metadata/utils/metadata-utils'; import { CreateProjectPostCategoryDto } from './dto/create-project-post-category.dto'; import { ProjectPostCategoryResponseDto } from './dto/project-post-category-response.dto'; import { UpdateProjectPostCategoryDto } from './dto/update-project-post-category.dto'; @@ -34,14 +33,14 @@ export class ProjectPostCategoryService { } async findById(id: string): Promise { - const parsedId = parseInt(id, 10); - if (Number.isNaN(parsedId)) { + const normalizedId = String(id ?? '').trim(); + if (!/^\d+$/.test(normalizedId)) { throw new NotFoundException(`Category not found for id ${id}.`); } const record = await this.prisma.projectPostCategory.findFirst({ where: { - id: BigInt(parsedId), + id: BigInt(normalizedId), }, }); @@ -86,7 +85,10 @@ export class ProjectPostCategoryService { return this.toDto(created); } catch (error) { - this.handleError(error, `create project showcase post category ${dto.name}`); + this.handleError( + error, + `create project showcase post category ${dto.name}`, + ); } } @@ -95,14 +97,14 @@ export class ProjectPostCategoryService { dto: UpdateProjectPostCategoryDto, userId: number, ): Promise { - const parsedId = parseInt(id, 10); - if (Number.isNaN(parsedId)) { + const normalizedId = String(id ?? '').trim(); + if (!/^\d+$/.test(normalizedId)) { throw new NotFoundException(`Category not found for id ${id}.`); } const existing = await this.prisma.projectPostCategory.findFirst({ where: { - id: BigInt(parsedId), + id: BigInt(normalizedId), }, }); @@ -111,7 +113,7 @@ export class ProjectPostCategoryService { } const updated = await this.prisma.projectPostCategory.update({ - where: { id: BigInt(parsedId) }, + where: { id: BigInt(normalizedId) }, data: { ...(typeof dto.name === 'undefined' ? {} : { name: dto.name }), }, @@ -130,14 +132,14 @@ export class ProjectPostCategoryService { } async delete(id: string, userId: number): Promise { - const parsedId = parseInt(id, 10); - if (Number.isNaN(parsedId)) { + const normalizedId = String(id ?? '').trim(); + if (!/^\d+$/.test(normalizedId)) { throw new NotFoundException(`Category not found for id ${id}.`); } const existing = await this.prisma.projectPostCategory.findFirst({ where: { - id: BigInt(parsedId), + id: BigInt(normalizedId), }, select: { id: true }, }); @@ -147,15 +149,15 @@ export class ProjectPostCategoryService { } await this.prisma.projectPostCategory.delete({ - where: { id: BigInt(parsedId) }, + where: { id: BigInt(normalizedId) }, }); await publishMetadataEvent( this.eventBusService, 'PROJECT_METADATA_DELETE', PROJECT_METADATA_RESOURCE.PROJECT_POST_CATEGORY, - String(parsedId), - { id: parsedId }, + String(normalizedId), + { id: normalizedId }, userId, ); } diff --git a/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.ts index 925e6cd..9aa17b0 100644 --- a/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.ts +++ b/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.ts @@ -30,9 +30,7 @@ import { ProjectPostIndustryService } from './project-post-industry.service'; @AnyAuthenticated() @Controller('/projects/posts/industries') export class ProjectPostIndustryController { - constructor( - private readonly service: ProjectPostIndustryService, - ) {} + constructor(private readonly service: ProjectPostIndustryService) {} @Get() @ApiOperation({ summary: 'List project showcase post industries' }) @@ -82,7 +80,7 @@ export class ProjectPostIndustryController { @Delete(':id') @HttpCode(204) @AdminOnly() - @ApiOperation({ summary: 'Delete project showcase post industry (soft delete)' }) + @ApiOperation({ summary: 'Delete project showcase post industry' }) @ApiParam({ name: 'id', description: 'Industry id' }) @ApiResponse({ status: 204, description: 'Deleted' }) @ApiResponse({ status: 403, description: 'Forbidden' }) diff --git a/src/api/project-showcase-post/project-post-industry/project-post-industry.service.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.service.ts index ea89754..f87e5a0 100644 --- a/src/api/project-showcase-post/project-post-industry/project-post-industry.service.ts +++ b/src/api/project-showcase-post/project-post-industry/project-post-industry.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { Prisma, ProjectPostIndustry } from '@prisma/client'; +import { ProjectPostIndustry } from '@prisma/client'; import { PrismaErrorService } from 'src/shared/modules/global/prisma-error.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; import { EventBusService } from 'src/shared/modules/global/eventBus.service'; @@ -12,7 +12,6 @@ import { PROJECT_METADATA_RESOURCE, publishMetadataEvent, } from 'src/api/metadata/utils/metadata-event.utils'; -import { toSerializable } from 'src/api/metadata/utils/metadata-utils'; import { CreateProjectPostIndustryDto } from './dto/create-project-post-industry.dto'; import { ProjectPostIndustryResponseDto } from './dto/project-post-industry-response.dto'; import { UpdateProjectPostIndustryDto } from './dto/update-project-post-industry.dto'; @@ -34,14 +33,14 @@ export class ProjectPostIndustryService { } async findById(id: string): Promise { - const parsedId = parseInt(id, 10); - if (Number.isNaN(parsedId)) { + const normalizedId = String(id ?? '').trim(); + if (!/^\d+$/.test(normalizedId)) { throw new NotFoundException(`Industry not found for id ${id}.`); } const record = await this.prisma.projectPostIndustry.findFirst({ where: { - id: BigInt(parsedId), + id: BigInt(normalizedId), }, }); @@ -86,7 +85,10 @@ export class ProjectPostIndustryService { return this.toDto(created); } catch (error) { - this.handleError(error, `create project showcase post industry ${dto.name}`); + this.handleError( + error, + `create project showcase post industry ${dto.name}`, + ); } } @@ -95,14 +97,15 @@ export class ProjectPostIndustryService { dto: UpdateProjectPostIndustryDto, userId: number, ): Promise { - const parsedId = parseInt(id, 10); - if (Number.isNaN(parsedId)) { + const normalizedId = String(id ?? '').trim(); + if (!/^\d+$/.test(normalizedId)) { throw new NotFoundException(`Industry not found for id ${id}.`); } + const parsedId = BigInt(normalizedId); const existing = await this.prisma.projectPostIndustry.findFirst({ where: { - id: BigInt(parsedId), + id: parsedId, }, }); @@ -111,7 +114,7 @@ export class ProjectPostIndustryService { } const updated = await this.prisma.projectPostIndustry.update({ - where: { id: BigInt(parsedId) }, + where: { id: parsedId }, data: { ...(typeof dto.name === 'undefined' ? {} : { name: dto.name }), }, @@ -130,11 +133,12 @@ export class ProjectPostIndustryService { } async delete(id: string, userId: number): Promise { - const parsedId = parseInt(id, 10); - if (Number.isNaN(parsedId)) { + const normalizedId = String(id ?? '').trim(); + if (!/^\d+$/.test(normalizedId)) { throw new NotFoundException(`Industry not found for id ${id}.`); } + const parsedId = BigInt(normalizedId); const existing = await this.prisma.projectPostIndustry.findFirst({ where: { id: BigInt(parsedId), diff --git a/src/api/project-showcase-post/project-showcase-post.module.ts b/src/api/project-showcase-post/project-showcase-post.module.ts index fbb9885..c674e3d 100644 --- a/src/api/project-showcase-post/project-showcase-post.module.ts +++ b/src/api/project-showcase-post/project-showcase-post.module.ts @@ -2,23 +2,17 @@ import { Module } from '@nestjs/common'; import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; import { ProjectShowcasePostController } from './project-showcase-post.controller'; import { ProjectShowcasePostService } from './project-showcase-post.service'; -import { ProjectPostCategoryController } from './project-post-category/project-post-category.controller'; -import { ProjectPostCategoryService } from './project-post-category/project-post-category.service'; -import { ProjectPostIndustryController } from './project-post-industry/project-post-industry.controller'; -import { ProjectPostIndustryService } from './project-post-industry/project-post-industry.service'; +import { ProjectPostCategoryModule } from './project-post-category/project-post-category.module'; +import { ProjectPostIndustryModule } from './project-post-industry/project-post-industry.module'; @Module({ - imports: [GlobalProvidersModule], - controllers: [ - ProjectShowcasePostController, - ProjectPostCategoryController, - ProjectPostIndustryController, - ], - providers: [ - ProjectShowcasePostService, - ProjectPostCategoryService, - ProjectPostIndustryService, + imports: [ + GlobalProvidersModule, + ProjectPostCategoryModule, + ProjectPostIndustryModule, ], + controllers: [ProjectShowcasePostController], + providers: [ProjectShowcasePostService], exports: [ProjectShowcasePostService], }) export class ProjectShowcasePostModule {} diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 43cd8ea..7984d5c 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -324,8 +324,8 @@ export class ProjectShowcasePostService { } } - const status = this.toStringListFilter( - criteria.status, + const status = this.toStringListFilter(criteria.status).map((entry) => + String(entry).trim().toUpperCase(), ) as ProjectShowcasePostStatus[]; if (status.length === 1) { where.status = status[0]; From a07b8b0620e4dd6bcdb70a52ce5d377c5b79f6be Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 26 Jun 2026 11:02:56 +0300 Subject: [PATCH 07/22] PM-5307 - add tests for showcase posts --- .../project-post-category.controller.spec.ts | 68 +++++ .../project-post-category.service.spec.ts | 151 ++++++++++ .../project-post-industry.controller.spec.ts | 68 +++++ .../project-post-industry.service.spec.ts | 154 ++++++++++ .../project-showcase-post.controller.spec.ts | 95 +++++++ .../project-showcase-post.service.spec.ts | 267 ++++++++++++++++++ 6 files changed, 803 insertions(+) create mode 100644 src/api/project-showcase-post/project-post-category/project-post-category.controller.spec.ts create mode 100644 src/api/project-showcase-post/project-post-category/project-post-category.service.spec.ts create mode 100644 src/api/project-showcase-post/project-post-industry/project-post-industry.controller.spec.ts create mode 100644 src/api/project-showcase-post/project-post-industry/project-post-industry.service.spec.ts create mode 100644 src/api/project-showcase-post/project-showcase-post.controller.spec.ts create mode 100644 src/api/project-showcase-post/project-showcase-post.service.spec.ts diff --git a/src/api/project-showcase-post/project-post-category/project-post-category.controller.spec.ts b/src/api/project-showcase-post/project-post-category/project-post-category.controller.spec.ts new file mode 100644 index 0000000..5a14a62 --- /dev/null +++ b/src/api/project-showcase-post/project-post-category/project-post-category.controller.spec.ts @@ -0,0 +1,68 @@ +import { ProjectPostCategoryController } from './project-post-category.controller'; + +describe('ProjectPostCategoryController', () => { + const serviceMock = { + findAll: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + let controller: ProjectPostCategoryController; + const user = { userId: '42', isMachine: false, tokenPayload: {} } as any; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new ProjectPostCategoryController( + serviceMock as any, + ); + }); + + it('lists categories', async () => { + serviceMock.findAll.mockResolvedValue([{ id: '1', name: 'Design' }]); + + const response = await controller.list(); + + expect(response).toEqual([{ id: '1', name: 'Design' }]); + expect(serviceMock.findAll).toHaveBeenCalled(); + }); + + it('gets a category by id', async () => { + serviceMock.findById.mockResolvedValue({ id: '2', name: 'Product' }); + + const response = await controller.getOne('2'); + + expect(response).toEqual({ id: '2', name: 'Product' }); + expect(serviceMock.findById).toHaveBeenCalledWith('2'); + }); + + it('creates a category', async () => { + serviceMock.create.mockResolvedValue({ id: '3', name: 'Growth' }); + + const response = await controller.create( + { name: 'Growth' }, + user, + ); + + expect(response).toEqual({ id: '3', name: 'Growth' }); + expect(serviceMock.create).toHaveBeenCalled(); + }); + + it('updates a category', async () => { + serviceMock.update.mockResolvedValue({ id: '4', name: 'Brand' }); + + const response = await controller.update('4', { name: 'Brand' }, user); + + expect(response).toEqual({ id: '4', name: 'Brand' }); + expect(serviceMock.update).toHaveBeenCalled(); + }); + + it('deletes a category', async () => { + serviceMock.delete.mockResolvedValue(undefined); + + await controller.delete('5', user); + + expect(serviceMock.delete).toHaveBeenCalledWith('5', expect.any(Number)); + }); +}); diff --git a/src/api/project-showcase-post/project-post-category/project-post-category.service.spec.ts b/src/api/project-showcase-post/project-post-category/project-post-category.service.spec.ts new file mode 100644 index 0000000..06ce506 --- /dev/null +++ b/src/api/project-showcase-post/project-post-category/project-post-category.service.spec.ts @@ -0,0 +1,151 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { ProjectPostCategoryService } from './project-post-category.service'; + +describe('ProjectPostCategoryService', () => { + const prismaMock = { + projectPostCategory: { + findMany: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }; + + const prismaErrorServiceMock = { + handleError: jest.fn(), + }; + + const eventBusServiceMock = {}; + + let service: ProjectPostCategoryService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new ProjectPostCategoryService( + prismaMock as any, + prismaErrorServiceMock as any, + eventBusServiceMock as any, + ); + }); + + it('finds all categories', async () => { + prismaMock.projectPostCategory.findMany.mockResolvedValue([ + { id: BigInt(1), name: 'Design' }, + ]); + + const response = await service.findAll(); + + expect(response).toEqual([{ id: '1', name: 'Design' }]); + }); + + it('finds a category by id', async () => { + prismaMock.projectPostCategory.findFirst.mockResolvedValue({ + id: BigInt(2), + name: 'Product', + }); + + const response = await service.findById('2'); + + expect(response).toEqual({ id: '2', name: 'Product' }); + }); + + it('throws NotFoundException for invalid id when finding a category', async () => { + await expect(service.findById('abc')).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when category does not exist', async () => { + prismaMock.projectPostCategory.findFirst.mockResolvedValue(undefined); + + await expect(service.findById('5')).rejects.toThrow(NotFoundException); + }); + + it('creates a new category', async () => { + prismaMock.projectPostCategory.findFirst.mockResolvedValue(undefined); + prismaMock.projectPostCategory.create.mockResolvedValue({ + id: BigInt(3), + name: 'Growth', + }); + + const response = await service.create({ name: 'Growth' }, 100); + + expect(prismaMock.projectPostCategory.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: { name: 'Growth' }, + }), + ); + expect(response).toEqual({ id: '3', name: 'Growth' }); + }); + + it('throws ConflictException when creating duplicate category', async () => { + prismaMock.projectPostCategory.findFirst.mockResolvedValue({ + id: BigInt(4), + name: 'Design', + }); + + await expect(service.create({ name: 'Design' }, 100)).rejects.toThrow( + ConflictException, + ); + }); + + it('updates an existing category', async () => { + prismaMock.projectPostCategory.findFirst + .mockResolvedValueOnce({ id: BigInt(5), name: 'Design' }) + .mockResolvedValueOnce({ id: BigInt(5), name: 'Brand' }); + prismaMock.projectPostCategory.update.mockResolvedValue({ + id: BigInt(5), + name: 'Brand', + }); + + const response = await service.update('5', { name: 'Brand' }, 100); + + expect(response).toEqual({ id: '5', name: 'Brand' }); + expect(prismaMock.projectPostCategory.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BigInt(5) }, + data: { name: 'Brand' }, + }), + ); + }); + + it('throws NotFoundException when updating invalid id', async () => { + await expect(service.update('abc', { name: 'Any' }, 100)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws NotFoundException when updating missing category', async () => { + prismaMock.projectPostCategory.findFirst.mockResolvedValue(undefined); + + await expect(service.update('7', { name: 'Any' }, 100)).rejects.toThrow( + NotFoundException, + ); + }); + + it('deletes an existing category', async () => { + prismaMock.projectPostCategory.findFirst.mockResolvedValue({ + id: BigInt(9), + }); + prismaMock.projectPostCategory.delete.mockResolvedValue(undefined); + + await service.delete('9', 100); + + expect(prismaMock.projectPostCategory.delete).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: BigInt(9) } }), + ); + }); + + it('throws NotFoundException when deleting invalid id', async () => { + await expect(service.delete('abc', 100)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws NotFoundException when deleting missing category', async () => { + prismaMock.projectPostCategory.findFirst.mockResolvedValue(undefined); + + await expect(service.delete('10', 100)).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.spec.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.spec.ts new file mode 100644 index 0000000..c283877 --- /dev/null +++ b/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.spec.ts @@ -0,0 +1,68 @@ +import { ProjectPostIndustryController } from './project-post-industry.controller'; + +describe('ProjectPostIndustryController', () => { + const serviceMock = { + findAll: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + let controller: ProjectPostIndustryController; + const user = { userId: '42', isMachine: false, tokenPayload: {} } as any; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new ProjectPostIndustryController( + serviceMock as any, + ); + }); + + it('lists industries', async () => { + serviceMock.findAll.mockResolvedValue([{ id: '1', name: 'Finance' }]); + + const response = await controller.list(); + + expect(response).toEqual([{ id: '1', name: 'Finance' }]); + expect(serviceMock.findAll).toHaveBeenCalled(); + }); + + it('gets an industry by id', async () => { + serviceMock.findById.mockResolvedValue({ id: '2', name: 'Design' }); + + const response = await controller.getOne('2'); + + expect(response).toEqual({ id: '2', name: 'Design' }); + expect(serviceMock.findById).toHaveBeenCalledWith('2'); + }); + + it('creates an industry', async () => { + serviceMock.create.mockResolvedValue({ id: '3', name: 'Marketing' }); + + const response = await controller.create( + { name: 'Marketing' }, + user, + ); + + expect(response).toEqual({ id: '3', name: 'Marketing' }); + expect(serviceMock.create).toHaveBeenCalled(); + }); + + it('updates an industry', async () => { + serviceMock.update.mockResolvedValue({ id: '4', name: 'Growth' }); + + const response = await controller.update('4', { name: 'Growth' }, user); + + expect(response).toEqual({ id: '4', name: 'Growth' }); + expect(serviceMock.update).toHaveBeenCalled(); + }); + + it('deletes an industry', async () => { + serviceMock.delete.mockResolvedValue(undefined); + + await controller.delete('5', user); + + expect(serviceMock.delete).toHaveBeenCalledWith('5', expect.any(Number)); + }); +}); diff --git a/src/api/project-showcase-post/project-post-industry/project-post-industry.service.spec.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.service.spec.ts new file mode 100644 index 0000000..def4615 --- /dev/null +++ b/src/api/project-showcase-post/project-post-industry/project-post-industry.service.spec.ts @@ -0,0 +1,154 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { ProjectPostIndustryService } from './project-post-industry.service'; + +describe('ProjectPostIndustryService', () => { + const prismaMock = { + projectPostIndustry: { + findMany: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }; + + const prismaErrorServiceMock = { + handleError: jest.fn(), + }; + + const eventBusServiceMock = {}; + + let service: ProjectPostIndustryService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new ProjectPostIndustryService( + prismaMock as any, + prismaErrorServiceMock as any, + eventBusServiceMock as any, + ); + }); + + it('finds all industries', async () => { + prismaMock.projectPostIndustry.findMany.mockResolvedValue([ + { id: BigInt(1), name: 'Finance' }, + ]); + + const response = await service.findAll(); + + expect(response).toEqual([{ id: '1', name: 'Finance' }]); + }); + + it('finds an industry by id', async () => { + prismaMock.projectPostIndustry.findFirst.mockResolvedValue({ + id: BigInt(2), + name: 'Design', + }); + + const response = await service.findById('2'); + + expect(response).toEqual({ id: '2', name: 'Design' }); + }); + + it('throws NotFoundException for invalid id when finding an industry', async () => { + await expect(service.findById('abc')).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when industry does not exist', async () => { + prismaMock.projectPostIndustry.findFirst.mockResolvedValue(undefined); + + await expect(service.findById('5')).rejects.toThrow(NotFoundException); + }); + + it('creates a new industry', async () => { + prismaMock.projectPostIndustry.findFirst.mockResolvedValue(undefined); + prismaMock.projectPostIndustry.create.mockResolvedValue({ + id: BigInt(3), + name: 'Marketing', + }); + + const response = await service.create( + { name: 'Marketing' }, + 100, + ); + + expect(prismaMock.projectPostIndustry.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: { name: 'Marketing' }, + }), + ); + expect(response).toEqual({ id: '3', name: 'Marketing' }); + }); + + it('throws ConflictException when creating a duplicate industry', async () => { + prismaMock.projectPostIndustry.findFirst.mockResolvedValue({ + id: BigInt(4), + name: 'Finance', + }); + + await expect( + service.create({ name: 'Finance' }, 100), + ).rejects.toThrow(ConflictException); + }); + + it('updates an existing industry', async () => { + prismaMock.projectPostIndustry.findFirst + .mockResolvedValueOnce({ id: BigInt(5), name: 'Finance' }) + .mockResolvedValueOnce({ id: BigInt(5), name: 'Growth' }); + prismaMock.projectPostIndustry.update.mockResolvedValue({ + id: BigInt(5), + name: 'Growth', + }); + + const response = await service.update('5', { name: 'Growth' }, 100); + + expect(response).toEqual({ id: '5', name: 'Growth' }); + expect(prismaMock.projectPostIndustry.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BigInt(5) }, + data: { name: 'Growth' }, + }), + ); + }); + + it('throws NotFoundException when updating invalid id', async () => { + await expect( + service.update('abc', { name: 'Any' }, 100), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when updating missing industry', async () => { + prismaMock.projectPostIndustry.findFirst.mockResolvedValue(undefined); + + await expect( + service.update('7', { name: 'Any' }, 100), + ).rejects.toThrow(NotFoundException); + }); + + it('deletes an existing industry', async () => { + prismaMock.projectPostIndustry.findFirst.mockResolvedValue({ + id: BigInt(9), + }); + prismaMock.projectPostIndustry.delete.mockResolvedValue(undefined); + + await service.delete('9', 100); + + expect(prismaMock.projectPostIndustry.delete).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: BigInt(9) } }), + ); + }); + + it('throws NotFoundException when deleting invalid id', async () => { + await expect(service.delete('abc', 100)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws NotFoundException when deleting missing industry', async () => { + prismaMock.projectPostIndustry.findFirst.mockResolvedValue(undefined); + + await expect(service.delete('10', 100)).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/src/api/project-showcase-post/project-showcase-post.controller.spec.ts b/src/api/project-showcase-post/project-showcase-post.controller.spec.ts new file mode 100644 index 0000000..6f9e7e0 --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.controller.spec.ts @@ -0,0 +1,95 @@ +import { ProjectShowcasePostController } from './project-showcase-post.controller'; + +describe('ProjectShowcasePostController', () => { + const serviceMock = { + listPosts: jest.fn(), + listProjectPosts: jest.fn(), + getPost: jest.fn(), + createPost: jest.fn(), + updatePost: jest.fn(), + deletePost: jest.fn(), + }; + + let controller: ProjectShowcasePostController; + const user = { userId: '42', isMachine: false, tokenPayload: {} } as any; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new ProjectShowcasePostController( + serviceMock as any, + ); + }); + + it('searches posts', async () => { + serviceMock.listPosts.mockResolvedValue([{ id: '1' }]); + + const response = await controller.searchPosts({}); + + expect(response).toEqual([{ id: '1' }]); + expect(serviceMock.listPosts).toHaveBeenCalledWith({}); + }); + + it('lists project-specific posts', async () => { + serviceMock.listProjectPosts.mockResolvedValue([{ id: '2' }]); + + const response = await controller.listProjectPosts( + '1001', + { status: 'DRAFT' }, + user, + ); + + expect(response).toEqual([{ id: '2' }]); + expect(serviceMock.listProjectPosts).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ status: 'DRAFT' }), + user, + ); + }); + + it('gets a project post', async () => { + serviceMock.getPost.mockResolvedValue({ id: '3' }); + + const response = await controller.getProjectPost('1001', '10', user); + + expect(response).toEqual({ id: '3' }); + expect(serviceMock.getPost).toHaveBeenCalledWith('1001', '10', user); + }); + + it('creates a project post', async () => { + const dto = { title: 'New', content: 'Content' }; + serviceMock.createPost.mockResolvedValue({ id: '4' }); + + const response = await controller.createProjectPost('1001', dto, user); + + expect(response).toEqual({ id: '4' }); + expect(serviceMock.createPost).toHaveBeenCalledWith('1001', dto, user); + }); + + it('updates a project post', async () => { + const dto = { title: 'Updated' }; + serviceMock.updatePost.mockResolvedValue({ id: '5' }); + + const response = await controller.updateProjectPost( + '1001', + '10', + dto, + user, + ); + + expect(response).toEqual({ id: '5' }); + expect(serviceMock.updatePost).toHaveBeenCalledWith( + '1001', + '10', + dto, + user, + ); + }); + + it('deletes a project post', async () => { + serviceMock.deletePost.mockResolvedValue(undefined); + + await controller.deleteProjectPost('1001', '10', user); + + expect(serviceMock.deletePost).toHaveBeenCalledWith('1001', '10', user); + }); +}); diff --git a/src/api/project-showcase-post/project-showcase-post.service.spec.ts b/src/api/project-showcase-post/project-showcase-post.service.spec.ts new file mode 100644 index 0000000..ace19b1 --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.service.spec.ts @@ -0,0 +1,267 @@ +import { NotFoundException } from '@nestjs/common'; +import { ProjectShowcasePostService } from './project-showcase-post.service'; + +describe('ProjectShowcasePostService', () => { + const prismaMock = { + projectShowcasePost: { + findMany: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + project: { + findFirst: jest.fn(), + }, + }; + + const permissionServiceMock = { + hasNamedPermission: jest.fn(), + }; + + let service: ProjectShowcasePostService; + const user = { + userId: '42', + isMachine: false, + tokenPayload: {}, + } as any; + + function buildPostRecord(overrides: Partial> = {}) { + return { + id: BigInt(10), + title: 'Showcase post', + content: 'Content', + status: 'DRAFT', + projectId: BigInt(1001), + challengeIds: ['100'], + createdById: 42, + updatedById: 42, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + industries: [ + { + industry: { + id: BigInt(5), + name: 'Finance', + }, + }, + ], + categories: [ + { + category: { + id: BigInt(7), + name: 'Web', + }, + }, + ], + ...overrides, + } as const; + } + + beforeEach(() => { + jest.resetAllMocks(); + + permissionServiceMock.hasNamedPermission.mockReturnValue(true); + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [ + { + userId: BigInt(42), + role: 'manager', + deletedAt: null, + }, + ], + }); + + service = new ProjectShowcasePostService( + prismaMock as any, + permissionServiceMock as any, + ); + }); + + it('lists all showcase posts with default sorting', async () => { + prismaMock.projectShowcasePost.findMany.mockResolvedValue([ + buildPostRecord(), + ]); + + const response = await service.listPosts({}); + + expect(prismaMock.projectShowcasePost.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ updatedAt: 'desc' }], + }), + ); + expect(response).toEqual([ + expect.objectContaining({ + id: '10', + projectId: '1001', + industries: [{ id: '5', name: 'Finance' }], + categories: [{ id: '7', name: 'Web' }], + }), + ]); + }); + + it('lists posts for a project with permission checks', async () => { + prismaMock.projectShowcasePost.findMany.mockResolvedValue([ + buildPostRecord({ status: 'PUBLISHED' }), + ]); + + const response = await service.listProjectPosts( + '1001', + { status: 'published' }, + user, + ); + + expect(prismaMock.project.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BigInt(1001), deletedAt: null }, + }), + ); + expect(prismaMock.projectShowcasePost.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + projectId: BigInt(1001), + status: 'PUBLISHED', + }), + }), + ); + expect(response[0].id).toBe('10'); + }); + + it('gets a post by id and project', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord({ status: 'PUBLISHED' }), + ); + + const response = await service.getPost('1001', '10', user); + + expect(response).toEqual( + expect.objectContaining({ + id: '10', + status: 'PUBLISHED', + }), + ); + expect(prismaMock.projectShowcasePost.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BigInt(10), projectId: BigInt(1001) }, + }), + ); + }); + + it('throws NotFoundException when getting missing post', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue(undefined); + + await expect(service.getPost('1001', '999', user)).rejects.toThrow( + NotFoundException, + ); + }); + + it('creates a new project showcase post with default draft status', async () => { + prismaMock.projectShowcasePost.create.mockResolvedValue( + buildPostRecord({ status: 'DRAFT' }), + ); + + const response = await service.createPost( + '1001', + { + title: 'New post', + content: 'New content', + industryIds: ['5'], + categoryIds: ['7'], + }, + user, + ); + + expect(prismaMock.projectShowcasePost.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + title: 'New post', + content: 'New content', + status: 'DRAFT', + projectId: BigInt(1001), + createdById: 42, + updatedById: 42, + industries: { + create: [{ industryId: BigInt(5) }], + }, + categories: { + create: [{ categoryId: BigInt(7) }], + }, + }), + }), + ); + expect(response.status).toBe('DRAFT'); + }); + + it('throws NotFoundException when updating a missing post', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue(undefined); + + await expect( + service.updatePost( + '1001', + '10', + { title: 'Updated title' }, + user, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('updates a post with provided fields and taxonomy ids', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord(), + ); + prismaMock.projectShowcasePost.update.mockResolvedValue( + buildPostRecord({ title: 'Updated title' }), + ); + + const response = await service.updatePost( + '1001', + '10', + { + title: 'Updated title', + industryIds: ['11'], + categoryIds: ['12'], + }, + user, + ); + + expect(prismaMock.projectShowcasePost.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BigInt(10) }, + data: expect.objectContaining({ + title: 'Updated title', + industries: { + deleteMany: {}, + create: [{ industryId: BigInt(11) }], + }, + categories: { + deleteMany: {}, + create: [{ categoryId: BigInt(12) }], + }, + }), + }), + ); + expect(response.title).toBe('Updated title'); + }); + + it('throws NotFoundException when deleting a missing post', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue(undefined); + + await expect(service.deletePost('1001', '10', user)).rejects.toThrow( + NotFoundException, + ); + }); + + it('deletes an existing post', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord(), + ); + prismaMock.projectShowcasePost.delete.mockResolvedValue(undefined); + + await service.deletePost('1001', '10', user); + + expect(prismaMock.projectShowcasePost.delete).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: BigInt(10) } }), + ); + }); +}); From f4e9c1ed342a551cc09132315314515ca40b8738 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 26 Jun 2026 11:13:03 +0300 Subject: [PATCH 08/22] deploy to dev --- .circleci/config.yml | 2 ++ package.json | 1 + 2 files changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index e12516a..5a03c1d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -69,6 +69,8 @@ workflows: branches: only: - dev + tags: + only: /^dev-.*/ build-prod: jobs: diff --git a/package.json b/package.json index a45fdc6..797178f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json", "test:load": "jest --config ./test/jest-e2e.json --runInBand --testRegex \"test/load/performance.test.ts$\"", "test:deployment": "jest --config ./test/jest-e2e.json --runInBand test/deployment-validation.e2e-spec.ts", + "deploy:dev": "BRANCH=$(git rev-parse --abbrev-ref HEAD) && TAG=\"dev-${BRANCH}\" && git tag -d \"$TAG\" 2>/dev/null; git push origin \":refs/tags/$TAG\" 2>/dev/null; git tag \"$TAG\" && git push origin \"$TAG\"", "postinstall": "npx prisma generate" }, "dependencies": { From 4ec7332b98896f0bb8fabd48dbe64649d1c72a9b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 26 Jun 2026 11:45:30 +0300 Subject: [PATCH 09/22] run prisma migrate deploy on CI/CD --- .circleci/config.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5a03c1d..7923dec 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,7 +30,22 @@ builddeploy_steps: &builddeploy_steps - setup_remote_docker - run: *install_dependency - run: *install_deploysuite - - run: docker buildx build --no-cache=true --build-arg RESET_DB_ARG=<> --build-arg SEED_DATA_ARG=${DEPLOYMENT_ENVIRONMENT} -t ${APPNAME}:latest . + - run: + name: Build Docker Image + command: | + docker buildx build --load --no-cache=true --build-arg RESET_DB_ARG=<> --build-arg SEED_DATA_ARG=${DEPLOYMENT_ENVIRONMENT} -t ${APPNAME}:latest . + - run: + name: Run Prisma migrate deploy + command: | + ./awsconfiguration.sh $DEPLOY_ENV + source awsenvconf + ./psvar-processor.sh -t appenv -p /config/${APPNAME}/deployvar + source deployvar_env + if [ -z "$DATABASE_URL" ]; then + echo "DATABASE_URL is not set. Prisma migration cannot run." + exit 1 + fi + docker run --rm -e DATABASE_URL="$DATABASE_URL" -e NODE_ENV=production ${APPNAME}:latest npx prisma migrate deploy --schema prisma/schema.prisma - run: name: Running MasterScript. command: | From 0d2049ac85ecb7520815db722084bfd5796f30a2 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 26 Jun 2026 11:55:46 +0300 Subject: [PATCH 10/22] Run migrate via startup.sh --- .circleci/config.yml | 17 +---------------- appStartUp.sh | 11 +++++++++++ 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7923dec..5a03c1d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,22 +30,7 @@ builddeploy_steps: &builddeploy_steps - setup_remote_docker - run: *install_dependency - run: *install_deploysuite - - run: - name: Build Docker Image - command: | - docker buildx build --load --no-cache=true --build-arg RESET_DB_ARG=<> --build-arg SEED_DATA_ARG=${DEPLOYMENT_ENVIRONMENT} -t ${APPNAME}:latest . - - run: - name: Run Prisma migrate deploy - command: | - ./awsconfiguration.sh $DEPLOY_ENV - source awsenvconf - ./psvar-processor.sh -t appenv -p /config/${APPNAME}/deployvar - source deployvar_env - if [ -z "$DATABASE_URL" ]; then - echo "DATABASE_URL is not set. Prisma migration cannot run." - exit 1 - fi - docker run --rm -e DATABASE_URL="$DATABASE_URL" -e NODE_ENV=production ${APPNAME}:latest npx prisma migrate deploy --schema prisma/schema.prisma + - run: docker buildx build --no-cache=true --build-arg RESET_DB_ARG=<> --build-arg SEED_DATA_ARG=${DEPLOYMENT_ENVIRONMENT} -t ${APPNAME}:latest . - run: name: Running MasterScript. command: | diff --git a/appStartUp.sh b/appStartUp.sh index d2a491d..950c0d5 100755 --- a/appStartUp.sh +++ b/appStartUp.sh @@ -3,5 +3,16 @@ set -eo pipefail export DATABASE_URL=$(echo -e ${DATABASE_URL}) +# Set default schema to 'public' if not provided +if [ -z "$POSTGRES_SCHEMA" ]; then + echo "POSTGRES_SCHEMA not set, defaulting to 'public'" + export POSTGRES_SCHEMA="public" +else + echo "Using PostgreSQL schema: $POSTGRES_SCHEMA" +fi + +echo "Database - running migrations." +npx prisma migrate deploy + # Start the app exec node dist/src/main From 56a3ce52bf5985513aceffa7f0e80a8a58c84aa0 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 29 Jun 2026 10:26:41 +0300 Subject: [PATCH 11/22] PM-5307 - allow copilot to manage showcase posts --- .../project-showcase-post.controller.ts | 6 +++--- .../project-showcase-post.service.ts | 6 +++--- src/shared/constants/permissions.ts | 2 ++ .../services/permission.service.spec.ts | 21 +++++++++++++++++++ src/shared/services/permission.service.ts | 10 +++++++++ src/shared/utils/permission-docs.utils.ts | 7 +++++++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/api/project-showcase-post/project-showcase-post.controller.ts b/src/api/project-showcase-post/project-showcase-post.controller.ts index 60d3971..c4aca9a 100644 --- a/src/api/project-showcase-post/project-showcase-post.controller.ts +++ b/src/api/project-showcase-post/project-showcase-post.controller.ts @@ -124,7 +124,7 @@ export class ProjectShowcasePostController { @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) + @RequirePermission(Permission.MANAGE_PROJECT_SHOWCASE_POST) @ApiOperation({ summary: 'Create a project showcase post' }) @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) @ApiResponse({ status: 201, type: ProjectShowcasePostResponseDto }) @@ -140,7 +140,7 @@ export class ProjectShowcasePostController { @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) + @RequirePermission(Permission.MANAGE_PROJECT_SHOWCASE_POST) @ApiOperation({ summary: 'Update a project showcase post' }) @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) @ApiParam({ name: 'id', required: true, description: 'Showcase post id' }) @@ -159,7 +159,7 @@ export class ProjectShowcasePostController { @UseGuards(PermissionGuard) @Roles(...Object.values(UserRole)) @Scopes(Scope.PROJECTS_WRITE, Scope.PROJECTS_ALL, Scope.CONNECT_PROJECT_ADMIN) - @RequirePermission(Permission.EDIT_PROJECT) + @RequirePermission(Permission.MANAGE_PROJECT_SHOWCASE_POST) @ApiOperation({ summary: 'Delete a project showcase post' }) @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) @ApiParam({ name: 'id', required: true, description: 'Showcase post id' }) diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 7984d5c..1da77ce 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -149,7 +149,7 @@ export class ProjectShowcasePostService { ); ensureProjectNamedPermission( this.permissionService, - Permission.EDIT_PROJECT, + Permission.MANAGE_PROJECT_SHOWCASE_POST, user, project.members, ); @@ -204,7 +204,7 @@ export class ProjectShowcasePostService { ); ensureProjectNamedPermission( this.permissionService, - Permission.EDIT_PROJECT, + Permission.MANAGE_PROJECT_SHOWCASE_POST, user, project.members, ); @@ -282,7 +282,7 @@ export class ProjectShowcasePostService { ); ensureProjectNamedPermission( this.permissionService, - Permission.EDIT_PROJECT, + Permission.MANAGE_PROJECT_SHOWCASE_POST, user, project.members, ); diff --git a/src/shared/constants/permissions.ts b/src/shared/constants/permissions.ts index 09de334..856f23b 100644 --- a/src/shared/constants/permissions.ts +++ b/src/shared/constants/permissions.ts @@ -17,6 +17,8 @@ export enum Permission { CREATE_PROJECT = 'CREATE_PROJECT', /** Edit project details. */ EDIT_PROJECT = 'EDIT_PROJECT', + /** Create/update/delete project showcase posts. */ + MANAGE_PROJECT_SHOWCASE_POST = 'MANAGE_PROJECT_SHOWCASE_POST', /** Delete a project. */ DELETE_PROJECT = 'DELETE_PROJECT', /** diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index ccd19ee..cd93773 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -748,6 +748,27 @@ describe('PermissionService', () => { ).toBe(false); }); + it('allows project copilots to manage showcase posts', () => { + const user = { + userId: '3001', + roles: [UserRole.TC_COPILOT], + isMachine: false, + }; + const projectMembers = [ + { + userId: '3001', + role: ProjectMemberRole.COPILOT, + }, + ]; + + expect( + service.hasNamedPermission( + Permission.MANAGE_PROJECT_SHOWCASE_POST, + user, + projectMembers, + ), + ).toBe(true); + }); it('allows deleting project for machine token with project write scope', () => { const allowed = service.hasNamedPermission(Permission.DELETE_PROJECT, { scopes: [Scope.PROJECTS_ALL], diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index a10308e..a765d98 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -275,6 +275,15 @@ export class PermissionService { hasMachineProjectWriteScope ); + case NamedPermission.MANAGE_PROJECT_SHOWCASE_POST: + return ( + isAdmin || + isManagementMember || + this.isCopilot(member?.role) || + this.hasProjectUpdateTopcoderRole(user) || + hasMachineProjectWriteScope + ); + case NamedPermission.DELETE_PROJECT: return ( isAdmin || @@ -532,6 +541,7 @@ export class PermissionService { return [ NamedPermission.VIEW_PROJECT, NamedPermission.EDIT_PROJECT, + NamedPermission.MANAGE_PROJECT_SHOWCASE_POST, NamedPermission.DELETE_PROJECT, NamedPermission.READ_PROJECT_MEMBER, NamedPermission.READ_PROJECT_INVITE_NOT_OWN, diff --git a/src/shared/utils/permission-docs.utils.ts b/src/shared/utils/permission-docs.utils.ts index b9ba79e..e2d3056 100644 --- a/src/shared/utils/permission-docs.utils.ts +++ b/src/shared/utils/permission-docs.utils.ts @@ -259,6 +259,13 @@ function getNamedPermissionDocumentation( scopes: PROJECT_WRITE_SCOPES, }); + case NamedPermission.MANAGE_PROJECT_SHOWCASE_POST: + return createSummary({ + userRoles: PROJECT_UPDATE_TOPCODER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + scopes: PROJECT_WRITE_SCOPES, + }); + case NamedPermission.DELETE_PROJECT: return createSummary({ userRoles: ADMIN_AND_MANAGER_ROLES, From c11ba30bc1e385d1fc65b574acec216309c05832 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 29 Jun 2026 13:00:00 +0300 Subject: [PATCH 12/22] PM-5307 - project showcase post pagination --- .../project-showcase-post.controller.spec.ts | 28 ++++++++++++++- .../project-showcase-post.controller.ts | 34 +++++++++++++++++-- .../project-showcase-post.service.ts | 28 +++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/api/project-showcase-post/project-showcase-post.controller.spec.ts b/src/api/project-showcase-post/project-showcase-post.controller.spec.ts index 6f9e7e0..f9fb26f 100644 --- a/src/api/project-showcase-post/project-showcase-post.controller.spec.ts +++ b/src/api/project-showcase-post/project-showcase-post.controller.spec.ts @@ -3,7 +3,9 @@ import { ProjectShowcasePostController } from './project-showcase-post.controlle describe('ProjectShowcasePostController', () => { const serviceMock = { listPosts: jest.fn(), + countPosts: jest.fn(), listProjectPosts: jest.fn(), + countProjectPosts: jest.fn(), getPost: jest.fn(), createPost: jest.fn(), updatePost: jest.fn(), @@ -12,6 +14,16 @@ describe('ProjectShowcasePostController', () => { let controller: ProjectShowcasePostController; const user = { userId: '42', isMachine: false, tokenPayload: {} } as any; + const mockReq = { + protocol: 'http', + get: jest.fn().mockReturnValue('localhost'), + baseUrl: '', + path: '/projects/posts', + } as any; + const mockRes = { + header: jest.fn(), + getHeader: jest.fn().mockReturnValue(undefined), + } as any; beforeEach(() => { jest.clearAllMocks(); @@ -22,17 +34,26 @@ describe('ProjectShowcasePostController', () => { it('searches posts', async () => { serviceMock.listPosts.mockResolvedValue([{ id: '1' }]); + serviceMock.countPosts = jest.fn().mockResolvedValue(1); - const response = await controller.searchPosts({}); + const response = await controller.searchPosts( + mockReq, + mockRes, + {}, + ); expect(response).toEqual([{ id: '1' }]); expect(serviceMock.listPosts).toHaveBeenCalledWith({}); + expect(serviceMock.countPosts).toHaveBeenCalledWith({}); }); it('lists project-specific posts', async () => { serviceMock.listProjectPosts.mockResolvedValue([{ id: '2' }]); + serviceMock.countProjectPosts = jest.fn().mockResolvedValue(1); const response = await controller.listProjectPosts( + mockReq, + mockRes, '1001', { status: 'DRAFT' }, user, @@ -44,6 +65,11 @@ describe('ProjectShowcasePostController', () => { expect.objectContaining({ status: 'DRAFT' }), user, ); + expect(serviceMock.countProjectPosts).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ status: 'DRAFT' }), + user, + ); }); it('gets a project post', async () => { diff --git a/src/api/project-showcase-post/project-showcase-post.controller.ts b/src/api/project-showcase-post/project-showcase-post.controller.ts index c4aca9a..a1d04ac 100644 --- a/src/api/project-showcase-post/project-showcase-post.controller.ts +++ b/src/api/project-showcase-post/project-showcase-post.controller.ts @@ -8,6 +8,8 @@ import { Patch, Post, Query, + Req, + Res, UseGuards, } from '@nestjs/common'; import { @@ -18,6 +20,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; +import { Request, Response } from 'express'; import { Permission } from 'src/shared/constants/permissions'; import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; import { RequirePermission } from 'src/shared/decorators/requirePermission.decorator'; @@ -27,6 +30,7 @@ import { UserRole } from 'src/shared/enums/userRole.enum'; import { PermissionGuard } from 'src/shared/guards/permission.guard'; import { Roles } from 'src/shared/guards/tokenRoles.guard'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { setProjectPaginationHeaders } from 'src/shared/utils/pagination.utils'; import { ProjectShowcasePostResponseDto } from './dto/project-showcase-post-response.dto'; import { ProjectShowcasePostListQueryDto } from './dto/project-showcase-post-list-query.dto'; import { CreateProjectShowcasePostDto } from './dto/create-project-showcase-post.dto'; @@ -60,9 +64,22 @@ export class ProjectShowcasePostController { type: [ProjectShowcasePostResponseDto], }) async searchPosts( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, @Query() query: ProjectShowcasePostListQueryDto, ): Promise { - return this.service.listPosts(query); + const posts = await this.service.listPosts(query) + const total = await this.service.countPosts(query) + + setProjectPaginationHeaders( + req, + res, + query.page || 1, + query.perPage || 20, + total, + ) + + return posts; } @Get(':projectId/posts') @@ -91,11 +108,24 @@ export class ProjectShowcasePostController { type: [ProjectShowcasePostResponseDto], }) async listProjectPosts( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, @Param('projectId') projectId: string, @Query() query: ProjectShowcasePostListQueryDto, @CurrentUser() user: JwtUser, ): Promise { - return this.service.listProjectPosts(projectId, query, user); + const posts = await this.service.listProjectPosts(projectId, query, user) + const total = await this.service.countProjectPosts(projectId, query, user) + + setProjectPaginationHeaders( + req, + res, + query.page || 1, + query.perPage || 20, + total, + ) + + return posts; } @Get(':projectId/posts/:id') diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 1da77ce..31f22d2 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -53,6 +53,13 @@ export class ProjectShowcasePostService { return posts.map((post) => this.toDto(post)); } + async countPosts( + criteria: ProjectShowcasePostListQueryDto, + ): Promise { + const where = this.buildWhere(criteria); + return this.prisma.projectShowcasePost.count({ where }); + } + async listProjectPosts( projectId: string, criteria: ProjectShowcasePostListQueryDto, @@ -94,6 +101,27 @@ export class ProjectShowcasePostService { return posts.map((post) => this.toDto(post)); } + async countProjectPosts( + projectId: string, + criteria: ProjectShowcasePostListQueryDto, + user: JwtUser, + ): Promise { + const parsedProjectId = parseNumericStringId(projectId, 'Project id'); + const project = await loadProjectPermissionContextBase( + this.prisma, + parsedProjectId, + ); + ensureProjectNamedPermission( + this.permissionService, + Permission.VIEW_PROJECT, + user, + project.members, + ); + + const where = this.buildWhere(criteria, parsedProjectId); + return this.prisma.projectShowcasePost.count({ where }); + } + async getPost( projectId: string, id: string, From 718cda00028763db617f682acc7f17d3934532ea Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 29 Jun 2026 16:37:30 +0300 Subject: [PATCH 13/22] PM-5307 - throw error when provided industry or category are invalid --- .../project-showcase-post.service.spec.ts | 112 +++++++++++++ .../project-showcase-post.service.ts | 148 ++++++++++++------ 2 files changed, 210 insertions(+), 50 deletions(-) diff --git a/src/api/project-showcase-post/project-showcase-post.service.spec.ts b/src/api/project-showcase-post/project-showcase-post.service.spec.ts index ace19b1..1c26c60 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.spec.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.spec.ts @@ -1,4 +1,5 @@ import { NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { ProjectShowcasePostService } from './project-showcase-post.service'; describe('ProjectShowcasePostService', () => { @@ -193,6 +194,61 @@ describe('ProjectShowcasePostService', () => { expect(response.status).toBe('DRAFT'); }); + it('throws NotFoundException when create hits an industry foreign key constraint', async () => { + const error = Object.create( + Prisma.PrismaClientKnownRequestError.prototype, + ); + Object.assign(error, { + message: + 'Foreign key constraint violated on the constraint: `project_showcase_post_industries_industry_fkey`', + code: 'P2003', + meta: { constraint: 'project_showcase_post_industries_industry_fkey' }, + }); + + prismaMock.projectShowcasePost.create.mockRejectedValue(error); + + await expect( + service.createPost( + '1001', + { + title: 'New post', + content: 'New content', + industryIds: ['5'], + categoryIds: ['7'], + }, + user, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when create hits a category foreign key constraint', async () => { + const error = Object.create( + Prisma.PrismaClientKnownRequestError.prototype, + ); + Object.assign(error, { + message: + 'Foreign key constraint violated on the constraint: `project_showcase_post_categories_category_fkey`', + code: 'P2003', + meta: { constraint: 'project_showcase_post_categories_category_fkey' }, + }); + + prismaMock.projectShowcasePost.create.mockRejectedValue(error); + + await expect( + service.createPost( + '1001', + { + title: 'New post', + content: 'New content', + industryIds: ['5'], + categoryIds: ['7'], + }, + user, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when updating a missing post', async () => { prismaMock.projectShowcasePost.findFirst.mockResolvedValue(undefined); @@ -244,6 +300,62 @@ describe('ProjectShowcasePostService', () => { expect(response.title).toBe('Updated title'); }); + it('throws NotFoundException when update hits an industry foreign key constraint', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord(), + ); + const error = Object.create( + Prisma.PrismaClientKnownRequestError.prototype, + ); + Object.assign(error, { + message: + 'Foreign key constraint violated on the constraint: `project_showcase_post_industries_industry_fkey`', + code: 'P2003', + meta: { constraint: 'project_showcase_post_industries_industry_fkey' }, + }); + + prismaMock.projectShowcasePost.update.mockRejectedValue(error); + + await expect( + service.updatePost( + '1001', + '10', + { + industryIds: ['11'], + }, + user, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when update hits a category foreign key constraint', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord(), + ); + const error = Object.create( + Prisma.PrismaClientKnownRequestError.prototype, + ); + Object.assign(error, { + message: + 'Foreign key constraint violated on the constraint: `project_showcase_post_categories_category_fkey`', + code: 'P2003', + meta: { constraint: 'project_showcase_post_categories_category_fkey' }, + }); + + prismaMock.projectShowcasePost.update.mockRejectedValue(error); + + await expect( + service.updatePost( + '1001', + '10', + { + categoryIds: ['12'], + }, + user, + ), + ).rejects.toThrow(NotFoundException); + }); + it('throws NotFoundException when deleting a missing post', async () => { prismaMock.projectShowcasePost.findFirst.mockResolvedValue(undefined); diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 31f22d2..0069af3 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -53,9 +53,7 @@ export class ProjectShowcasePostService { return posts.map((post) => this.toDto(post)); } - async countPosts( - criteria: ProjectShowcasePostListQueryDto, - ): Promise { + async countPosts(criteria: ProjectShowcasePostListQueryDto): Promise { const where = this.buildWhere(criteria); return this.prisma.projectShowcasePost.count({ where }); } @@ -183,38 +181,44 @@ export class ProjectShowcasePostService { ); const auditUserId = getAuditUserId(user); + const industryIds = (dto.industryIds || []).map((industryId) => + parseNumericStringId(String(industryId), 'Industry id'), + ); + const categoryIds = (dto.categoryIds || []).map((categoryId) => + parseNumericStringId(String(categoryId), 'Category id'), + ); - const created = await this.prisma.projectShowcasePost.create({ - data: { - title: dto.title, - content: dto.content, - status: dto.status ?? 'DRAFT', - projectId: parsedProjectId, - challengeIds: dto.challengeIds || [], - createdById: auditUserId, - updatedById: auditUserId, - industries: { - create: (dto.industryIds || []).map((industryId) => ({ - industryId: parseNumericStringId(String(industryId), 'Industry id'), - })), + try { + const created = await this.prisma.projectShowcasePost.create({ + data: { + title: dto.title, + content: dto.content, + status: dto.status ?? 'DRAFT', + projectId: parsedProjectId, + challengeIds: dto.challengeIds || [], + createdById: auditUserId, + updatedById: auditUserId, + industries: { + create: industryIds.map((industryId) => ({ industryId })), + }, + categories: { + create: categoryIds.map((categoryId) => ({ categoryId })), + }, }, - categories: { - create: (dto.categoryIds || []).map((categoryId) => ({ - categoryId: parseNumericStringId(String(categoryId), 'Category id'), - })), - }, - }, - include: { - industries: { - include: { industry: true }, + include: { + industries: { + include: { industry: true }, + }, + categories: { + include: { category: true }, + }, }, - categories: { - include: { category: true }, - }, - }, - }); + }); - return this.toDto(created); + return this.toDto(created); + } catch (error) { + this.handlePrismaForeignKeyError(error, 'create project showcase post'); + } } async updatePost( @@ -261,39 +265,47 @@ export class ProjectShowcasePostService { }; if (typeof dto.industryIds !== 'undefined') { + const industryIds = dto.industryIds.map((industryId) => + parseNumericStringId(String(industryId), 'Industry id'), + ); + updateData.industries = { deleteMany: {}, - create: dto.industryIds.map((industryId) => ({ - industryId: parseNumericStringId(String(industryId), 'Industry id'), - })), + create: industryIds.map((industryId) => ({ industryId })), }; } if (typeof dto.categoryIds !== 'undefined') { + const categoryIds = dto.categoryIds.map((categoryId) => + parseNumericStringId(String(categoryId), 'Category id'), + ); + updateData.categories = { deleteMany: {}, - create: dto.categoryIds.map((categoryId) => ({ - categoryId: parseNumericStringId(String(categoryId), 'Category id'), - })), + create: categoryIds.map((categoryId) => ({ categoryId })), }; } - const updated = await this.prisma.projectShowcasePost.update({ - where: { - id: parsedId, - }, - data: updateData, - include: { - industries: { - include: { industry: true }, + try { + const updated = await this.prisma.projectShowcasePost.update({ + where: { + id: parsedId, }, - categories: { - include: { category: true }, + data: updateData, + include: { + industries: { + include: { industry: true }, + }, + categories: { + include: { category: true }, + }, }, - }, - }); + }); - return this.toDto(updated); + return this.toDto(updated); + } catch (error) { + this.handlePrismaForeignKeyError(error, 'update project showcase post'); + } } async deletePost( @@ -447,6 +459,42 @@ export class ProjectShowcasePostService { }; } + private handlePrismaForeignKeyError( + error: unknown, + operation: string, + ): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + const meta = error.meta ?? {}; + let constraint = String(meta.constraint ?? '').trim(); + + if (!constraint) { + const match = /constraint:\s*`([^`]*)`/.exec(error.message); + if (match) { + constraint = match[1]; + } + } + + if ( + constraint.includes('project_showcase_post_industries_industry_fkey') || + /project_showcase_post_industries.*industry/i.test(constraint) + ) { + throw new NotFoundException('Industry not found for provided id.'); + } + + if ( + constraint.includes('project_showcase_post_categories_category_fkey') || + /project_showcase_post_categories.*category/i.test(constraint) + ) { + throw new NotFoundException('Category not found for provided id.'); + } + } + + throw error; + } + private toBigIntFilter( value: string | string[] | Record | undefined, ): bigint[] { From 048dd6a73f41aae0e4307c1c2ed96078dbe64086 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 29 Jun 2026 16:56:26 +0300 Subject: [PATCH 14/22] validate challenge id --- .../dto/create-project-showcase-post.dto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts index ea950a9..afca11a 100644 --- a/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts +++ b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { ProjectShowcasePostStatus } from '@prisma/client'; export class CreateProjectShowcasePostDto { @@ -33,6 +33,6 @@ export class CreateProjectShowcasePostDto { @ApiPropertyOptional({ type: [String] }) @IsOptional() @IsArray() - @IsString({ each: true }) + @IsUUID('4', { each: true }) challengeIds?: string[]; } From dbd0d1957402929250b75ff6492643f57c5bbec8 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 29 Jun 2026 18:42:17 +0300 Subject: [PATCH 15/22] PM-5485 - media assets for project showcase --- .../migration.sql | 11 ++ prisma/schema.prisma | 16 +++ .../dto/create-project-showcase-post.dto.ts | 19 ++- .../project-showcase-post-media-input.dto.ts | 13 ++ .../dto/project-showcase-post-media.dto.ts | 24 ++++ .../dto/project-showcase-post-response.dto.ts | 6 +- .../project-showcase-post.service.spec.ts | 129 ++++++++++++++++++ .../project-showcase-post.service.ts | 31 +++++ 8 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/api/project-showcase-post/dto/project-showcase-post-media-input.dto.ts create mode 100644 src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts diff --git a/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql b/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql index 59fce76..c20e5a1 100644 --- a/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql +++ b/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql @@ -51,6 +51,17 @@ CREATE TABLE projects."project_showcase_post_categories" ( UNIQUE ("projectShowcasePostId", "categoryId") ); +CREATE TABLE projects."project_showcase_post_media" ( + "id" BIGSERIAL PRIMARY KEY, + "projectShowcasePostId" BIGINT NOT NULL, + "type" VARCHAR(255) NOT NULL, + "url" VARCHAR(2048) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT now(), + "createdBy" BIGINT NOT NULL, + CONSTRAINT "project_showcase_post_media_project_showcase_post_fkey" + FOREIGN KEY ("projectShowcasePostId") REFERENCES projects."project_showcase_posts"("id") ON DELETE CASCADE +); + -- Indexes for query performance CREATE INDEX "project_showcase_posts_status_idx" ON projects."project_showcase_posts"("status"); CREATE INDEX "project_showcase_posts_project_id_idx" ON projects."project_showcase_posts"("projectId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6345d55..d803149 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1075,6 +1075,7 @@ model ProjectShowcasePost { project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) industries ProjectShowcasePostIndustry[] categories ProjectShowcasePostCategory[] + media ProjectShowcasePostMedia[] @@index([status]) @@index([projectId]) @@ -1130,3 +1131,18 @@ model ProjectShowcasePostCategory { @@index([categoryId]) @@map("project_showcase_post_categories") } + +/// Media assets attached to project showcase posts. +model ProjectShowcasePostMedia { + id BigInt @id @default(autoincrement()) + projectShowcasePostId BigInt + type String + url String + createdAt DateTime @default(now()) + createdBy BigInt + + projectShowcasePost ProjectShowcasePost @relation(fields: [projectShowcasePostId], references: [id], onDelete: Cascade) + + @@index([projectShowcasePostId]) + @@map("project_showcase_post_media") +} diff --git a/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts index afca11a..816c48a 100644 --- a/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts +++ b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts @@ -1,6 +1,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; import { ProjectShowcasePostStatus } from '@prisma/client'; +import { ProjectShowcasePostMediaInputDto } from './project-showcase-post-media-input.dto'; export class CreateProjectShowcasePostDto { @ApiProperty({ description: 'Post title.' }) @@ -35,4 +45,11 @@ export class CreateProjectShowcasePostDto { @IsArray() @IsUUID('4', { each: true }) challengeIds?: string[]; + + @ApiPropertyOptional({ type: [Object] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProjectShowcasePostMediaInputDto) + media?: ProjectShowcasePostMediaInputDto[]; } diff --git a/src/api/project-showcase-post/dto/project-showcase-post-media-input.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-media-input.dto.ts new file mode 100644 index 0000000..e1762e0 --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-media-input.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUrl } from 'class-validator'; + +export class ProjectShowcasePostMediaInputDto { + @ApiProperty({ description: 'MIME type of the media asset.' }) + @IsString() + @IsNotEmpty() + type: string; + + @ApiProperty({ description: 'URL of the media asset.' }) + @IsUrl() + url: string; +} diff --git a/src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts new file mode 100644 index 0000000..c52dfff --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUrl } from 'class-validator'; + +export class ProjectShowcasePostMediaDto { + @ApiProperty({ description: 'Media asset id.' }) + @IsString() + @IsNotEmpty() + id: string; + + @ApiProperty({ description: 'MIME type of the media asset.' }) + @IsString() + @IsNotEmpty() + type: string; + + @ApiProperty({ description: 'URL of the media asset.' }) + @IsUrl() + url: string; + + @ApiProperty({ description: 'Timestamp when media asset was created.' }) + createdAt: Date; + + @ApiProperty({ description: 'User id who created the media asset.' }) + createdBy: string; +} diff --git a/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts index 4991c84..dcc64bb 100644 --- a/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts +++ b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts @@ -1,5 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ProjectShowcasePostStatus } from '@prisma/client'; +import { ProjectShowcasePostMediaDto } from './project-showcase-post-media.dto'; export class ProjectShowcasePostResponseDto { @ApiProperty() @@ -26,6 +27,9 @@ export class ProjectShowcasePostResponseDto { @ApiProperty({ type: [Object] }) categories: Array<{ id: string; name: string }>; + @ApiPropertyOptional({ type: [ProjectShowcasePostMediaDto] }) + media?: ProjectShowcasePostMediaDto[]; + @ApiProperty() createdById: number; diff --git a/src/api/project-showcase-post/project-showcase-post.service.spec.ts b/src/api/project-showcase-post/project-showcase-post.service.spec.ts index 1c26c60..2eb482a 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.spec.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.spec.ts @@ -55,6 +55,15 @@ describe('ProjectShowcasePostService', () => { }, }, ], + media: [ + { + id: BigInt(101), + type: 'image/png', + url: 'https://example.com/image.png', + createdAt: new Date('2026-01-01T00:00:00Z'), + createdBy: BigInt(42), + }, + ], ...overrides, } as const; } @@ -188,12 +197,72 @@ describe('ProjectShowcasePostService', () => { categories: { create: [{ categoryId: BigInt(7) }], }, + media: { + create: [], + }, }), }), ); expect(response.status).toBe('DRAFT'); }); + it('creates a new project showcase post with media assets', async () => { + prismaMock.projectShowcasePost.create.mockResolvedValue( + buildPostRecord({ + status: 'DRAFT', + media: [ + { + id: BigInt(101), + type: 'image/png', + url: 'https://example.com/image.png', + createdAt: new Date('2026-01-01T00:00:00Z'), + createdBy: BigInt(42), + }, + ], + }), + ); + + const response = await service.createPost( + '1001', + { + title: 'New post', + content: 'New content', + industryIds: ['5'], + categoryIds: ['7'], + media: [ + { + type: 'image/png', + url: 'https://example.com/image.png', + }, + ], + }, + user, + ); + + expect(prismaMock.projectShowcasePost.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + media: { + create: [ + { + type: 'image/png', + url: 'https://example.com/image.png', + createdBy: 42, + }, + ], + }, + }), + }), + ); + expect(response.media).toEqual([ + expect.objectContaining({ + id: '101', + type: 'image/png', + url: 'https://example.com/image.png', + }), + ]); + }); + it('throws NotFoundException when create hits an industry foreign key constraint', async () => { const error = Object.create( Prisma.PrismaClientKnownRequestError.prototype, @@ -300,6 +369,66 @@ describe('ProjectShowcasePostService', () => { expect(response.title).toBe('Updated title'); }); + it('updates a post with media assets', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord(), + ); + prismaMock.projectShowcasePost.update.mockResolvedValue( + buildPostRecord({ + title: 'Updated title', + media: [ + { + id: BigInt(102), + type: 'video/mp4', + url: 'https://example.com/video.mp4', + createdAt: new Date('2026-01-01T00:00:00Z'), + createdBy: BigInt(42), + }, + ], + }), + ); + + const response = await service.updatePost( + '1001', + '10', + { + title: 'Updated title', + media: [ + { + type: 'video/mp4', + url: 'https://example.com/video.mp4', + }, + ], + }, + user, + ); + + expect(prismaMock.projectShowcasePost.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BigInt(10) }, + data: expect.objectContaining({ + media: { + deleteMany: {}, + create: [ + { + type: 'video/mp4', + url: 'https://example.com/video.mp4', + createdBy: 42, + }, + ], + }, + }), + }), + ); + expect(response.media).toEqual([ + expect.objectContaining({ + id: '102', + type: 'video/mp4', + url: 'https://example.com/video.mp4', + }), + ]); + }); + it('throws NotFoundException when update hits an industry foreign key constraint', async () => { prismaMock.projectShowcasePost.findFirst.mockResolvedValue( buildPostRecord(), diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 0069af3..6ead847 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -44,6 +44,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, orderBy, skip, @@ -90,6 +91,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, orderBy, skip, @@ -151,6 +153,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, }); @@ -204,6 +207,13 @@ export class ProjectShowcasePostService { categories: { create: categoryIds.map((categoryId) => ({ categoryId })), }, + media: { + create: (dto.media || []).map((asset) => ({ + type: asset.type, + url: asset.url, + createdBy: auditUserId, + })), + }, }, include: { industries: { @@ -212,6 +222,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, }); @@ -286,6 +297,17 @@ export class ProjectShowcasePostService { }; } + if (typeof dto.media !== 'undefined') { + updateData.media = { + deleteMany: {}, + create: dto.media.map((mediaItem) => ({ + type: mediaItem.type, + url: mediaItem.url, + createdBy: getAuditUserId(user), + })), + }; + } + try { const updated = await this.prisma.projectShowcasePost.update({ where: { @@ -299,6 +321,7 @@ export class ProjectShowcasePostService { categories: { include: { category: true }, }, + media: true, }, }); @@ -435,6 +458,7 @@ export class ProjectShowcasePostService { post: ProjectShowcasePost & { industries: { industry: { id: bigint; name: string } }[]; categories: { category: { id: bigint; name: string } }[]; + media: { id: bigint; type: string; url: string; createdAt: Date; createdBy: bigint }[]; }, ): ProjectShowcasePostResponseDto { return { @@ -456,6 +480,13 @@ export class ProjectShowcasePostService { id: String(entry.category.id), name: entry.category.name, })), + media: post.media.map((entry) => ({ + id: String(entry.id), + type: entry.type, + url: entry.url, + createdAt: entry.createdAt, + createdBy: String(entry.createdBy), + })), }; } From 9ed7b7b21b4f0c8226aca90dd84ebc25a412f3e2 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 30 Jun 2026 07:45:36 +0300 Subject: [PATCH 16/22] pr feedback --- .../dto/create-project-showcase-post.dto.ts | 2 +- .../project-showcase-post.service.ts | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts index 816c48a..88d055e 100644 --- a/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts +++ b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts @@ -46,7 +46,7 @@ export class CreateProjectShowcasePostDto { @IsUUID('4', { each: true }) challengeIds?: string[]; - @ApiPropertyOptional({ type: [Object] }) + @ApiPropertyOptional({ type: [ProjectShowcasePostMediaInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 6ead847..8ba1f8a 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -211,7 +211,7 @@ export class ProjectShowcasePostService { create: (dto.media || []).map((asset) => ({ type: asset.type, url: asset.url, - createdBy: auditUserId, + createdBy: BigInt(auditUserId), })), }, }, @@ -297,13 +297,14 @@ export class ProjectShowcasePostService { }; } - if (typeof dto.media !== 'undefined') { + if (typeof dto.media !== 'undefined' && Array.isArray(dto.media)) { + const auditUserId = BigInt(getAuditUserId(user)); updateData.media = { deleteMany: {}, create: dto.media.map((mediaItem) => ({ type: mediaItem.type, url: mediaItem.url, - createdBy: getAuditUserId(user), + createdBy: auditUserId, })), }; } @@ -458,7 +459,13 @@ export class ProjectShowcasePostService { post: ProjectShowcasePost & { industries: { industry: { id: bigint; name: string } }[]; categories: { category: { id: bigint; name: string } }[]; - media: { id: bigint; type: string; url: string; createdAt: Date; createdBy: bigint }[]; + media: { + id: bigint; + type: string; + url: string; + createdAt: Date; + createdBy: bigint; + }[]; }, ): ProjectShowcasePostResponseDto { return { From 6ed48fe9a2e095eac77dc7559659d678574d143d Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 30 Jun 2026 10:39:44 +0300 Subject: [PATCH 17/22] PM-5307 - fix swagger for project showcase posts --- .../project-post-category.module.ts | 12 ---------- .../project-post-industry.module.ts | 12 ---------- .../project-showcase-post.module.ts | 22 ++++++++++++------- 3 files changed, 14 insertions(+), 32 deletions(-) delete mode 100644 src/api/project-showcase-post/project-post-category/project-post-category.module.ts delete mode 100644 src/api/project-showcase-post/project-post-industry/project-post-industry.module.ts diff --git a/src/api/project-showcase-post/project-post-category/project-post-category.module.ts b/src/api/project-showcase-post/project-post-category/project-post-category.module.ts deleted file mode 100644 index 6ba8f86..0000000 --- a/src/api/project-showcase-post/project-post-category/project-post-category.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; -import { ProjectPostCategoryController } from './project-post-category.controller'; -import { ProjectPostCategoryService } from './project-post-category.service'; - -@Module({ - imports: [GlobalProvidersModule], - controllers: [ProjectPostCategoryController], - providers: [ProjectPostCategoryService], - exports: [ProjectPostCategoryService], -}) -export class ProjectPostCategoryModule {} diff --git a/src/api/project-showcase-post/project-post-industry/project-post-industry.module.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.module.ts deleted file mode 100644 index a44d213..0000000 --- a/src/api/project-showcase-post/project-post-industry/project-post-industry.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; -import { ProjectPostIndustryController } from './project-post-industry.controller'; -import { ProjectPostIndustryService } from './project-post-industry.service'; - -@Module({ - imports: [GlobalProvidersModule], - controllers: [ProjectPostIndustryController], - providers: [ProjectPostIndustryService], - exports: [ProjectPostIndustryService], -}) -export class ProjectPostIndustryModule {} diff --git a/src/api/project-showcase-post/project-showcase-post.module.ts b/src/api/project-showcase-post/project-showcase-post.module.ts index c674e3d..9785121 100644 --- a/src/api/project-showcase-post/project-showcase-post.module.ts +++ b/src/api/project-showcase-post/project-showcase-post.module.ts @@ -2,17 +2,23 @@ import { Module } from '@nestjs/common'; import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; import { ProjectShowcasePostController } from './project-showcase-post.controller'; import { ProjectShowcasePostService } from './project-showcase-post.service'; -import { ProjectPostCategoryModule } from './project-post-category/project-post-category.module'; -import { ProjectPostIndustryModule } from './project-post-industry/project-post-industry.module'; +import { ProjectPostCategoryController } from './project-post-category/project-post-category.controller'; +import { ProjectPostIndustryController } from './project-post-industry/project-post-industry.controller'; +import { ProjectPostCategoryService } from './project-post-category/project-post-category.service'; +import { ProjectPostIndustryService } from './project-post-industry/project-post-industry.service'; @Module({ - imports: [ - GlobalProvidersModule, - ProjectPostCategoryModule, - ProjectPostIndustryModule, + imports: [GlobalProvidersModule], + controllers: [ + ProjectShowcasePostController, + ProjectPostCategoryController, + ProjectPostIndustryController, + ], + providers: [ + ProjectShowcasePostService, + ProjectPostCategoryService, + ProjectPostIndustryService, ], - controllers: [ProjectShowcasePostController], - providers: [ProjectShowcasePostService], exports: [ProjectShowcasePostService], }) export class ProjectShowcasePostModule {} From c217dd26b4d2d6a2c2ad66bb85b1587aa2ce20ec Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 1 Jul 2026 14:36:01 +0300 Subject: [PATCH 18/22] PM-5487 - provide signed urls for the project showcase media --- .env.example | 11 +++ appStartUp.sh | 8 --- package.json | 1 + pnpm-lock.yaml | 49 ++++++++++++- .../project-showcase-post.service.spec.ts | 23 ++++--- .../project-showcase-post.service.ts | 4 +- src/shared/config/app.config.ts | 27 ++++++++ src/shared/utils/cloudfront.utils.spec.ts | 33 +++++++++ src/shared/utils/cloudfront.utils.ts | 68 +++++++++++++++++++ 9 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 src/shared/utils/cloudfront.utils.spec.ts create mode 100644 src/shared/utils/cloudfront.utils.ts diff --git a/.env.example b/.env.example index e9c0006..9cb8008 100644 --- a/.env.example +++ b/.env.example @@ -84,3 +84,14 @@ CORS_ALLOWED_ORIGIN="" # Logging NODE_ENV=development + +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY="" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1hmfSFeNdnmVVe+4hcz0 +Wirsiu83FjezbiEnLsmZcTwGknTeK26O9JOlC7R1I2sc9TvMjxutqjSRyoLDv6tg +2f29RMeHPsR88tW+aYZYqYgU8PMl5y+EctPfb798eRDyGctpPKBxOczgGSUgAF0y +Y/qCzFYpxXwnpNn775XGR1QIKTgCIEZISAIntcZcgN0q0oGxTJF963exDKWoGxJt +ZvSf1TGI4JbCB6MB3jnl6rr99mWx1GN0NKaylOVBq0ToS7IBaSENjZSaKzTO7hmL +1smAsYB2G4fOcPaQurkqE2gryzj5ZyMCL30k0l/Ifh/td7Fqu/6K73Gta/B3yd/N +UwIDAQAB +-----END PUBLIC KEY-----" diff --git a/appStartUp.sh b/appStartUp.sh index 950c0d5..2644dd0 100755 --- a/appStartUp.sh +++ b/appStartUp.sh @@ -3,14 +3,6 @@ set -eo pipefail export DATABASE_URL=$(echo -e ${DATABASE_URL}) -# Set default schema to 'public' if not provided -if [ -z "$POSTGRES_SCHEMA" ]; then - echo "POSTGRES_SCHEMA not set, defaulting to 'public'" - export POSTGRES_SCHEMA="public" -else - echo "Using PostgreSQL schema: $POSTGRES_SCHEMA" -fi - echo "Database - running migrations." npx prisma migrate deploy diff --git a/package.json b/package.json index 797178f..a72fbb2 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.926.0", + "@aws-sdk/cloudfront-signer": "^3.1077.0", "@aws-sdk/s3-request-presigner": "^3.926.0", "@nestjs/axios": "^4.0.0", "@nestjs/common": "^11.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 483cfb1..18ca78a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.926.0 version: 3.985.0 + '@aws-sdk/cloudfront-signer': + specifier: ^3.1077.0 + version: 3.1077.0 '@aws-sdk/s3-request-presigner': specifier: ^3.926.0 version: 3.985.0 @@ -263,6 +266,10 @@ packages: resolution: {integrity: sha512-81J8iE8MuXhdbMfIz4sWFj64Pe41bFi/uqqmqOC5SlGv+kwoyLsyKS/rH2tW2t5buih4vTUxskRjxlqikTD4oQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/cloudfront-signer@3.1077.0': + resolution: {integrity: sha512-KjOQEbesPbpVd3NjF2fGCDbigunTY/x5fERn1RmFZ836VUT2Sh4BJpaHZ1rOLE+wsEuYS4DFHkXzbVbmJ5H5lw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.7': resolution: {integrity: sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg==} engines: {node: '>=20.0.0'} @@ -1285,6 +1292,10 @@ packages: resolution: {integrity: sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==} engines: {node: '>=18.0.0'} + '@smithy/core@3.28.0': + resolution: {integrity: sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.8': resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} engines: {node: '>=18.0.0'} @@ -1405,6 +1416,10 @@ packages: resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} engines: {node: '>=18.0.0'} + '@smithy/types@4.15.0': + resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.8': resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} engines: {node: '>=18.0.0'} @@ -4189,6 +4204,11 @@ packages: version: 3.0.1 engines: {node: '>= 14'} + tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062: + resolution: {tarball: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062} + version: 3.0.1 + engines: {node: '>= 14'} + terser-webpack-plugin@5.3.16: resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} engines: {node: '>= 10.13.0'} @@ -4740,6 +4760,11 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/cloudfront-signer@3.1077.0': + dependencies: + '@smithy/core': 3.28.0 + tslib: 2.8.1 + '@aws-sdk/core@3.973.7': dependencies: '@aws-sdk/types': 3.973.1 @@ -6106,6 +6131,11 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/core@3.28.0': + dependencies: + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.8': dependencies: '@smithy/node-config-provider': 4.3.8 @@ -6301,6 +6331,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.15.0': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.2.8': dependencies: '@smithy/querystring-parser': 4.2.8 @@ -6512,7 +6546,7 @@ snapshots: joi: 18.0.2 lodash: 4.17.23 superagent: 10.3.0 - tc-core-library-js: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/1075136355e1e1c4779f2138a30f3ffbd718bfa4 + tc-core-library-js: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062 transitivePeerDependencies: - debug - supports-color @@ -9428,6 +9462,19 @@ snapshots: - debug - supports-color + tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062: + dependencies: + axios: 1.13.5 + bunyan: 1.8.15 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 + lodash: 4.17.23 + millisecond: 0.1.2 + r7insight_node: 2.1.1 + transitivePeerDependencies: + - debug + - supports-color + terser-webpack-plugin@5.3.16(@swc/core@1.15.11)(webpack@5.104.1(@swc/core@1.15.11)): dependencies: '@jridgewell/trace-mapping': 0.3.31 diff --git a/src/api/project-showcase-post/project-showcase-post.service.spec.ts b/src/api/project-showcase-post/project-showcase-post.service.spec.ts index 2eb482a..babbe12 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.spec.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.spec.ts @@ -1,6 +1,13 @@ import { NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { ProjectShowcasePostService } from './project-showcase-post.service'; +import type { ProjectShowcasePostService } from './project-showcase-post.service'; + +jest.mock('src/shared/utils/cloudfront.utils', () => ({ + signCloudFrontUrl: jest.fn((url: string) => `${url}?signed=1`), +})); + +const { ProjectShowcasePostService: ProjectShowcasePostServiceClass } = + require('./project-showcase-post.service'); describe('ProjectShowcasePostService', () => { const prismaMock = { @@ -20,7 +27,7 @@ describe('ProjectShowcasePostService', () => { hasNamedPermission: jest.fn(), }; - let service: ProjectShowcasePostService; + let service: InstanceType; const user = { userId: '42', isMachine: false, @@ -69,7 +76,7 @@ describe('ProjectShowcasePostService', () => { } beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); permissionServiceMock.hasNamedPermission.mockReturnValue(true); prismaMock.project.findFirst.mockResolvedValue({ @@ -83,7 +90,7 @@ describe('ProjectShowcasePostService', () => { ], }); - service = new ProjectShowcasePostService( + service = new ProjectShowcasePostServiceClass( prismaMock as any, permissionServiceMock as any, ); @@ -247,7 +254,7 @@ describe('ProjectShowcasePostService', () => { { type: 'image/png', url: 'https://example.com/image.png', - createdBy: 42, + createdBy: BigInt(42), }, ], }, @@ -258,7 +265,7 @@ describe('ProjectShowcasePostService', () => { expect.objectContaining({ id: '101', type: 'image/png', - url: 'https://example.com/image.png', + url: 'https://example.com/image.png?signed=1', }), ]); }); @@ -413,7 +420,7 @@ describe('ProjectShowcasePostService', () => { { type: 'video/mp4', url: 'https://example.com/video.mp4', - createdBy: 42, + createdBy: BigInt(42), }, ], }, @@ -424,7 +431,7 @@ describe('ProjectShowcasePostService', () => { expect.objectContaining({ id: '102', type: 'video/mp4', - url: 'https://example.com/video.mp4', + url: 'https://example.com/video.mp4?signed=1', }), ]); }); diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 8ba1f8a..0404fb7 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -18,6 +18,7 @@ import { CreateProjectShowcasePostDto } from './dto/create-project-showcase-post import { ProjectShowcasePostListQueryDto } from './dto/project-showcase-post-list-query.dto'; import { ProjectShowcasePostResponseDto } from './dto/project-showcase-post-response.dto'; import { UpdateProjectShowcasePostDto } from './dto/update-project-showcase-post.dto'; +import { signCloudFrontUrl } from 'src/shared/utils/cloudfront.utils'; @Injectable() export class ProjectShowcasePostService { @@ -490,7 +491,8 @@ export class ProjectShowcasePostService { media: post.media.map((entry) => ({ id: String(entry.id), type: entry.type, - url: entry.url, + url_: entry.url, + url: signCloudFrontUrl(entry.url), createdAt: entry.createdAt, createdBy: String(entry.createdBy), })), diff --git a/src/shared/config/app.config.ts b/src/shared/config/app.config.ts index 3578c9f..7efdbd5 100644 --- a/src/shared/config/app.config.ts +++ b/src/shared/config/app.config.ts @@ -71,6 +71,33 @@ export const APP_CONFIG = { process.env.PRESIGNED_URL_EXPIRATION, 3600, ), + /** + * CloudFront key pair id used to sign project showcase media URLs. + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID`. + */ + cloudFrontProjectShowcaseMediaKeyPairId: + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID || + process.env.CLOUDFRONT_KEY_PAIR_ID, + /** + * CloudFront private key used to sign project showcase media URLs. + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY`. + */ + cloudFrontProjectShowcaseMediaPrivateKey: + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY, + /** + * CloudFront public key used to validate signed URLs (not used by service). + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY`. + */ + cloudFrontProjectShowcaseMediaPublicKey: + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY, + /** + * CloudFront signed URL expiry in seconds. + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_URL_EXPIRATION`, default: `600`. + */ + cloudFrontProjectShowcaseMediaUrlExpiration: parseNumberEnv( + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_URL_EXPIRATION, + 600, + ), /** * Maximum number of phase products per phase. * Env: `MAX_PHASE_PRODUCT_COUNT`, default: `20`. diff --git a/src/shared/utils/cloudfront.utils.spec.ts b/src/shared/utils/cloudfront.utils.spec.ts new file mode 100644 index 0000000..33a85f4 --- /dev/null +++ b/src/shared/utils/cloudfront.utils.spec.ts @@ -0,0 +1,33 @@ +describe('signCloudFrontUrl', () => { + const url = 'https://cdn.example.com/private/report.pdf'; + const mockSignedUrl = `${url}?signature=abc`; + + beforeEach(() => { + jest.resetModules(); + delete process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID; + delete process.env.CLOUDFRONT_KEY_PAIR_ID; + delete process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY; + }); + + it('returns original URL when signing keys are missing', () => { + jest.isolateModules(() => { + const { signCloudFrontUrl } = require('./cloudfront.utils'); + expect(signCloudFrontUrl(url)).toBe(url); + }); + }); + + it('signs the URL when CloudFront key pair id and private key are configured', () => { + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID = 'KP_ID'; + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY = + '-----BEGIN PRIVATE KEY-----\nMIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAuR1qVAvV+7Z2qmeJ\n-----END PRIVATE KEY-----'; + + jest.mock('@aws-sdk/cloudfront-signer', () => ({ + getSignedUrl: jest.fn(() => mockSignedUrl), + })); + + jest.isolateModules(() => { + const { signCloudFrontUrl } = require('./cloudfront.utils'); + expect(signCloudFrontUrl(url)).toBe(mockSignedUrl); + }); + }); +}); diff --git a/src/shared/utils/cloudfront.utils.ts b/src/shared/utils/cloudfront.utils.ts new file mode 100644 index 0000000..c65bb17 --- /dev/null +++ b/src/shared/utils/cloudfront.utils.ts @@ -0,0 +1,68 @@ +import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; +import { APP_CONFIG } from 'src/shared/config/app.config'; + +function normalizePemKey(rawKey: string | undefined): string | undefined { + if (!rawKey) { + return undefined; + } + + let normalized = rawKey.trim(); + + while (normalized.includes('\\n')) { + normalized = normalized.replace(/\\n/g, '\n'); + } + + const pemMatch = normalized.match( + /-----BEGIN ([A-Z0-9 ]+)-----\s*([A-Za-z0-9+/=\s]+)\s*-----END \1-----/s, + ); + if (pemMatch) { + const [, keyType, keyBody] = pemMatch; + const compactBody = keyBody.replace(/\s+/g, ''); + + if (compactBody.length > 0) { + const wrappedBody = compactBody.match(/.{1,64}/g)?.join('\n') || ''; + normalized = [ + `-----BEGIN ${keyType}-----`, + wrappedBody, + `-----END ${keyType}-----`, + ].join('\n'); + } + } + + if (!normalized.includes('-----BEGIN')) { + try { + const decoded = Buffer.from(normalized, 'base64').toString('utf8'); + if (decoded.includes('-----BEGIN')) { + normalized = decoded; + } + } catch { + // Keep the original value if decoding fails. + } + } + + return normalized.trim(); +} + +export function signCloudFrontUrl(url: string): string { + const keyPairId = APP_CONFIG.cloudFrontProjectShowcaseMediaKeyPairId; + const privateKey = normalizePemKey( + APP_CONFIG.cloudFrontProjectShowcaseMediaPrivateKey, + ); + + if (!keyPairId || !privateKey) { + return url; + } + + try { + return getSignedUrl({ + url, + keyPairId, + privateKey, + dateLessThan: new Date( + Date.now() + APP_CONFIG.cloudFrontProjectShowcaseMediaUrlExpiration * 1000, + ).toISOString(), + }); + } catch { + return url; + } +} From ac16b71ff26d3c3f84f46c6fc4719eba9ae0faad Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 1 Jul 2026 14:46:03 +0300 Subject: [PATCH 19/22] cleanup --- src/api/project-showcase-post/project-showcase-post.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 0404fb7..d3ab149 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -491,7 +491,6 @@ export class ProjectShowcasePostService { media: post.media.map((entry) => ({ id: String(entry.id), type: entry.type, - url_: entry.url, url: signCloudFrontUrl(entry.url), createdAt: entry.createdAt, createdBy: String(entry.createdBy), From 280d0b121092e6234bf99cbe4fc4bf90f3b25d5b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 1 Jul 2026 15:44:08 +0300 Subject: [PATCH 20/22] docs --- docs/projects-showcase-media.md | 273 ++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/projects-showcase-media.md diff --git a/docs/projects-showcase-media.md b/docs/projects-showcase-media.md new file mode 100644 index 0000000..547bc8d --- /dev/null +++ b/docs/projects-showcase-media.md @@ -0,0 +1,273 @@ +# CloudFront Signed URLs + Private S3 Setup + +## Purpose + +This document describes how to configure a private S3 bucket that is +only accessible through CloudFront using CloudFront Signed URLs. + +The application authenticates users with JWTs. CloudFront **does not** +validate JWTs directly. Instead: + +1. The backend validates the JWT. +2. The backend generates a short-lived CloudFront signed URL. +3. CloudFront validates the signature. +4. CloudFront retrieves the object from the private S3 bucket using an + Origin Access Control (OAC). + +------------------------------------------------------------------------ + +# Architecture + +``` text +Browser + | + | JWT + v +Projects API + | + | Verify JWT + | Generate CloudFront Signed URL + v +Browser + | + | GET https://cdn.example.com/path/file.ext?...Signature... + v +CloudFront + | + | Validate signature + | (automatic) + v +Origin Access Control (OAC) + | + | SigV4 request + v +Private S3 Bucket +``` + +------------------------------------------------------------------------ + +# S3 Configuration + +## Bucket + +- Enable **Block all public access**. +- Do not use public ACLs. +- Do not add bucket policies granting `Principal: "*"` read access. + +## Bucket Policy + +Replace the placeholders before applying. + +``` json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCloudFrontServicePrincipal", + "Effect": "Allow", + "Principal": { + "Service": "cloudfront.amazonaws.com" + }, + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::YOUR_BUCKET/*", + "Condition": { + "StringEquals": { + "AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID" + } + } + } + ] +} +``` + +------------------------------------------------------------------------ + +# CloudFront Configuration + +## 1. Origin Access Control + +Create: + + CloudFront + └── Origin access + └── Origin access controls + +Configuration: + +- Origin type: S3 +- Signing behavior: Sign requests +- Signing protocol: SigV4 + +Attach the OAC to the S3 origin. + +------------------------------------------------------------------------ + +## 2. RSA Key Pair + +Generate locally: + +``` bash +openssl genrsa -out private_key.pem 2048 + +openssl rsa \ + -pubout \ + -in private_key.pem \ + -out public_key.pem +``` + +Never commit `private_key.pem`. + +------------------------------------------------------------------------ + +## 3. Public Key + + CloudFront + └── Security + └── Public Keys + +Create a Public Key and paste the contents of `public_key.pem`. + +Record the generated **Key Pair ID**. + +------------------------------------------------------------------------ + +## 4. Key Group + + CloudFront + └── Security + └── Key Groups + +Create a Key Group containing the Public Key. + +------------------------------------------------------------------------ + +## 5. Distribution + +Edit the protected behavior. + +Configure: + +- Viewer protocol policy: Redirect HTTP to HTTPS +- Trusted Key Groups: Select the Key Group created above + +Once configured, CloudFront automatically: + +- rejects unsigned requests +- rejects expired requests +- rejects requests signed with an unknown key + +No Lambda or custom verification logic is required. + +------------------------------------------------------------------------ + +# Parameter Store + +Store the following values in AWS Systems Manager Parameter Store. + + /config/projects-api-v6/appvar/CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY + /config/projects-api-v6/appvar/CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY + /config/projects-api-v6/appvar/CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID + +Recommended types: + +| Parameter | Type | Notes | +|---|---|---| +| CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY | SecureString | PEM contents of private key | +| CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY | String | PEM contents of public key | +| CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID | String | CloudFront Key Pair ID | + +The application should read these values at startup and never hardcode +or commit them. + +------------------------------------------------------------------------ + +# Backend + +Install: + +``` bash +npm install @aws-sdk/cloudfront-signer +``` + +Example: + +``` ts +import { getSignedUrl } from "@aws-sdk/cloudfront-signer"; + +const signedUrl = getSignedUrl({ + url, + keyPairId: process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID!, + privateKey: process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY!, + dateLessThan: new Date(Date.now() + 5 * 60 * 1000).toISOString() +}); +``` + +`getSignedUrl()` performs local cryptographic signing only. It does not +make any AWS API calls. + +------------------------------------------------------------------------ + +# Request Flow + +``` text +Client + │ + ├── JWT + ▼ +Projects API + │ + ├── Verify JWT + ├── getSignedUrl() + ▼ +Signed URL + │ + ▼ +CloudFront + │ + ├── Validate signature + ├── Validate expiration + ▼ +S3 (via OAC) +``` + +------------------------------------------------------------------------ + +# Validation + +Expected results: + + Request Expected + --------------------------------------- ------------------- + Direct S3 URL 403 Access Denied + CloudFront URL without signature 403 Forbidden + CloudFront URL with invalid signature 403 Forbidden + CloudFront URL with expired signature 403 Forbidden + CloudFront URL with valid signature 200 OK + +------------------------------------------------------------------------ + +# Key Rotation + +1. Generate a new RSA key pair. +2. Create a new CloudFront Public Key. +3. Add the new Public Key to the existing Key Group. +4. Update Parameter Store with the new private key and key pair ID. +5. Deploy the backend. +6. Wait until all previously issued signed URLs have expired. +7. Remove the old Public Key from the Key Group. +8. Delete the old key material if no longer required. + +This sequence avoids downtime during key rotation. + +------------------------------------------------------------------------ + +# Security Checklist + +- Block Public Access enabled. +- No public bucket policy. +- OAC attached to the S3 origin. +- Bucket policy only permits the CloudFront distribution. +- Private key stored as SecureString in Parameter Store. +- Private key never committed to source control. +- Signed URLs expire after a short period (recommended: 5--15 + minutes). From a7d5dbaec96755f782714374bb9def50252d7782 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 1 Jul 2026 21:51:51 +0300 Subject: [PATCH 21/22] PM-5487 - env example --- .env.example | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 9cb8008..2497aaa 100644 --- a/.env.example +++ b/.env.example @@ -86,12 +86,6 @@ CORS_ALLOWED_ORIGIN="" NODE_ENV=development CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY="" -CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1hmfSFeNdnmVVe+4hcz0 -Wirsiu83FjezbiEnLsmZcTwGknTeK26O9JOlC7R1I2sc9TvMjxutqjSRyoLDv6tg -2f29RMeHPsR88tW+aYZYqYgU8PMl5y+EctPfb798eRDyGctpPKBxOczgGSUgAF0y -Y/qCzFYpxXwnpNn775XGR1QIKTgCIEZISAIntcZcgN0q0oGxTJF963exDKWoGxJt -ZvSf1TGI4JbCB6MB3jnl6rr99mWx1GN0NKaylOVBq0ToS7IBaSENjZSaKzTO7hmL -1smAsYB2G4fOcPaQurkqE2gryzj5ZyMCL30k0l/Ifh/td7Fqu/6K73Gta/B3yd/N -UwIDAQAB ------END PUBLIC KEY-----" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY="" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID="" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_URL_EXPIRATION="" From 5264258e9bef3f1e595dedc96f3348f56435373a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 1 Jul 2026 21:55:06 +0300 Subject: [PATCH 22/22] undo pnpm-lock changes related to tc-core --- pnpm-lock.yaml | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18ca78a..346d661 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4204,11 +4204,6 @@ packages: version: 3.0.1 engines: {node: '>= 14'} - tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062: - resolution: {tarball: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062} - version: 3.0.1 - engines: {node: '>= 14'} - terser-webpack-plugin@5.3.16: resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} engines: {node: '>= 10.13.0'} @@ -6546,7 +6541,7 @@ snapshots: joi: 18.0.2 lodash: 4.17.23 superagent: 10.3.0 - tc-core-library-js: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062 + tc-core-library-js: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/1075136355e1e1c4779f2138a30f3ffbd718bfa4 transitivePeerDependencies: - debug - supports-color @@ -9462,19 +9457,6 @@ snapshots: - debug - supports-color - tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062: - dependencies: - axios: 1.13.5 - bunyan: 1.8.15 - jsonwebtoken: 9.0.3 - jwks-rsa: 3.2.2 - lodash: 4.17.23 - millisecond: 0.1.2 - r7insight_node: 2.1.1 - transitivePeerDependencies: - - debug - - supports-color - terser-webpack-plugin@5.3.16(@swc/core@1.15.11)(webpack@5.104.1(@swc/core@1.15.11)): dependencies: '@jridgewell/trace-mapping': 0.3.31