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": { diff --git a/prisma/add_types.sql b/prisma/add_types.sql index 75f7b91..d78e189 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/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; + 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..59fce76 --- /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..6345d55 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") +} 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/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..20ef277 --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-list-query.dto.ts @@ -0,0 +1,77 @@ +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..4991c84 --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ProjectShowcasePostStatus } from '@prisma/client'; + +export class ProjectShowcasePostResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + title: string; + + @ApiProperty() + content: string; + + @ApiProperty({ enum: ProjectShowcasePostStatus }) + status: ProjectShowcasePostStatus; + + @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-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 new file mode 100644 index 0000000..5ee08d3 --- /dev/null +++ b/src/api/project-showcase-post/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/project-showcase-post/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 new file mode 100644 index 0000000..99d14d0 --- /dev/null +++ b/src/api/project-showcase-post/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/project-showcase-post/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 new file mode 100644 index 0000000..2d5aaf5 --- /dev/null +++ b/src/api/project-showcase-post/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/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.controller.ts b/src/api/project-showcase-post/project-post-category/project-post-category.controller.ts new file mode 100644 index 0000000..59148b4 --- /dev/null +++ b/src/api/project-showcase-post/project-post-category/project-post-category.controller.ts @@ -0,0 +1,94 @@ +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 '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('Project Showcase Post Categories') +@ApiBearerAuth() +@AnyAuthenticated() +@Controller('/projects/posts/categories') +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' }) + @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/project-showcase-post/project-post-category/project-post-category.module.ts b/src/api/project-showcase-post/project-post-category/project-post-category.module.ts new file mode 100644 index 0000000..6ba8f86 --- /dev/null +++ b/src/api/project-showcase-post/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/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-category/project-post-category.service.ts b/src/api/project-showcase-post/project-post-category/project-post-category.service.ts new file mode 100644 index 0000000..d41860d --- /dev/null +++ b/src/api/project-showcase-post/project-post-category/project-post-category.service.ts @@ -0,0 +1,179 @@ +import { + ConflictException, + HttpException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +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'; +import { + PROJECT_METADATA_RESOURCE, + publishMetadataEvent, +} from 'src/api/metadata/utils/metadata-event.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 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(normalizedId), + }, + }); + + 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 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(normalizedId), + }, + }); + + if (!existing) { + throw new NotFoundException(`Category not found for id ${id}.`); + } + + const updated = await this.prisma.projectPostCategory.update({ + where: { id: BigInt(normalizedId) }, + 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 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(normalizedId), + }, + select: { id: true }, + }); + + if (!existing) { + throw new NotFoundException(`Category not found for id ${id}.`); + } + + await this.prisma.projectPostCategory.delete({ + where: { id: BigInt(normalizedId) }, + }); + + await publishMetadataEvent( + this.eventBusService, + 'PROJECT_METADATA_DELETE', + PROJECT_METADATA_RESOURCE.PROJECT_POST_CATEGORY, + String(normalizedId), + { id: normalizedId }, + 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/project-showcase-post/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 new file mode 100644 index 0000000..232c21c --- /dev/null +++ b/src/api/project-showcase-post/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/project-showcase-post/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 new file mode 100644 index 0000000..0fa30c6 --- /dev/null +++ b/src/api/project-showcase-post/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/project-showcase-post/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 new file mode 100644 index 0000000..58a720a --- /dev/null +++ b/src/api/project-showcase-post/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/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.controller.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.ts new file mode 100644 index 0000000..9aa17b0 --- /dev/null +++ b/src/api/project-showcase-post/project-post-industry/project-post-industry.controller.ts @@ -0,0 +1,94 @@ +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 '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('Project Showcase Post Industries') +@ApiBearerAuth() +@AnyAuthenticated() +@Controller('/projects/posts/industries') +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' }) + @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/project-showcase-post/project-post-industry/project-post-industry.module.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.module.ts new file mode 100644 index 0000000..a44d213 --- /dev/null +++ b/src/api/project-showcase-post/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/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-post-industry/project-post-industry.service.ts b/src/api/project-showcase-post/project-post-industry/project-post-industry.service.ts new file mode 100644 index 0000000..f87e5a0 --- /dev/null +++ b/src/api/project-showcase-post/project-post-industry/project-post-industry.service.ts @@ -0,0 +1,181 @@ +import { + ConflictException, + HttpException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +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'; +import { + PROJECT_METADATA_RESOURCE, + publishMetadataEvent, +} from 'src/api/metadata/utils/metadata-event.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 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(normalizedId), + }, + }); + + 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 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: parsedId, + }, + }); + + if (!existing) { + throw new NotFoundException(`Industry not found for id ${id}.`); + } + + const updated = await this.prisma.projectPostIndustry.update({ + where: { id: 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 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), + }, + 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/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.controller.ts b/src/api/project-showcase-post/project-showcase-post.controller.ts new file mode 100644 index 0000000..60d3971 --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.controller.ts @@ -0,0 +1,174 @@ +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, + ): Promise { + return this.service.listPosts(query); + } + + @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..c674e3d --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; +import { ProjectShowcasePostController } from './project-showcase-post.controller'; +import { ProjectShowcasePostService } from './project-showcase-post.service'; +import { ProjectPostCategoryModule } from './project-post-category/project-post-category.module'; +import { ProjectPostIndustryModule } from './project-post-industry/project-post-industry.module'; + +@Module({ + 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.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) } }), + ); + }); +}); 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..7984d5c --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -0,0 +1,478 @@ +import { 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, +} 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'; + +@Injectable() +export class ProjectShowcasePostService { + constructor( + private readonly prisma: PrismaService, + private readonly permissionService: PermissionService, + ) {} + + async listPosts( + criteria: ProjectShowcasePostListQueryDto, + ): 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).map((entry) => + String(entry).trim().toUpperCase(), + ) 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, }, });