From 9010b5f0c312a8979223a8ea77ddf2a14104de5d Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 24 Jun 2026 16:13:25 +0300 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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": {