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/.env.example b/.env.example index e9c0006..2497aaa 100644 --- a/.env.example +++ b/.env.example @@ -84,3 +84,8 @@ CORS_ALLOWED_ORIGIN="" # Logging NODE_ENV=development + +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY="" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY="" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID="" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_URL_EXPIRATION="" diff --git a/appStartUp.sh b/appStartUp.sh index d2a491d..2644dd0 100755 --- a/appStartUp.sh +++ b/appStartUp.sh @@ -3,5 +3,8 @@ set -eo pipefail export DATABASE_URL=$(echo -e ${DATABASE_URL}) +echo "Database - running migrations." +npx prisma migrate deploy + # Start the app exec node dist/src/main diff --git a/docs/projects-showcase-media.md b/docs/projects-showcase-media.md new file mode 100644 index 0000000..547bc8d --- /dev/null +++ b/docs/projects-showcase-media.md @@ -0,0 +1,273 @@ +# CloudFront Signed URLs + Private S3 Setup + +## Purpose + +This document describes how to configure a private S3 bucket that is +only accessible through CloudFront using CloudFront Signed URLs. + +The application authenticates users with JWTs. CloudFront **does not** +validate JWTs directly. Instead: + +1. The backend validates the JWT. +2. The backend generates a short-lived CloudFront signed URL. +3. CloudFront validates the signature. +4. CloudFront retrieves the object from the private S3 bucket using an + Origin Access Control (OAC). + +------------------------------------------------------------------------ + +# Architecture + +``` text +Browser + | + | JWT + v +Projects API + | + | Verify JWT + | Generate CloudFront Signed URL + v +Browser + | + | GET https://cdn.example.com/path/file.ext?...Signature... + v +CloudFront + | + | Validate signature + | (automatic) + v +Origin Access Control (OAC) + | + | SigV4 request + v +Private S3 Bucket +``` + +------------------------------------------------------------------------ + +# S3 Configuration + +## Bucket + +- Enable **Block all public access**. +- Do not use public ACLs. +- Do not add bucket policies granting `Principal: "*"` read access. + +## Bucket Policy + +Replace the placeholders before applying. + +``` json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCloudFrontServicePrincipal", + "Effect": "Allow", + "Principal": { + "Service": "cloudfront.amazonaws.com" + }, + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::YOUR_BUCKET/*", + "Condition": { + "StringEquals": { + "AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID" + } + } + } + ] +} +``` + +------------------------------------------------------------------------ + +# CloudFront Configuration + +## 1. Origin Access Control + +Create: + + CloudFront + └── Origin access + └── Origin access controls + +Configuration: + +- Origin type: S3 +- Signing behavior: Sign requests +- Signing protocol: SigV4 + +Attach the OAC to the S3 origin. + +------------------------------------------------------------------------ + +## 2. RSA Key Pair + +Generate locally: + +``` bash +openssl genrsa -out private_key.pem 2048 + +openssl rsa \ + -pubout \ + -in private_key.pem \ + -out public_key.pem +``` + +Never commit `private_key.pem`. + +------------------------------------------------------------------------ + +## 3. Public Key + + CloudFront + └── Security + └── Public Keys + +Create a Public Key and paste the contents of `public_key.pem`. + +Record the generated **Key Pair ID**. + +------------------------------------------------------------------------ + +## 4. Key Group + + CloudFront + └── Security + └── Key Groups + +Create a Key Group containing the Public Key. + +------------------------------------------------------------------------ + +## 5. Distribution + +Edit the protected behavior. + +Configure: + +- Viewer protocol policy: Redirect HTTP to HTTPS +- Trusted Key Groups: Select the Key Group created above + +Once configured, CloudFront automatically: + +- rejects unsigned requests +- rejects expired requests +- rejects requests signed with an unknown key + +No Lambda or custom verification logic is required. + +------------------------------------------------------------------------ + +# Parameter Store + +Store the following values in AWS Systems Manager Parameter Store. + + /config/projects-api-v6/appvar/CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY + /config/projects-api-v6/appvar/CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY + /config/projects-api-v6/appvar/CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID + +Recommended types: + +| Parameter | Type | Notes | +|---|---|---| +| CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY | SecureString | PEM contents of private key | +| CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY | String | PEM contents of public key | +| CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID | String | CloudFront Key Pair ID | + +The application should read these values at startup and never hardcode +or commit them. + +------------------------------------------------------------------------ + +# Backend + +Install: + +``` bash +npm install @aws-sdk/cloudfront-signer +``` + +Example: + +``` ts +import { getSignedUrl } from "@aws-sdk/cloudfront-signer"; + +const signedUrl = getSignedUrl({ + url, + keyPairId: process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID!, + privateKey: process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY!, + dateLessThan: new Date(Date.now() + 5 * 60 * 1000).toISOString() +}); +``` + +`getSignedUrl()` performs local cryptographic signing only. It does not +make any AWS API calls. + +------------------------------------------------------------------------ + +# Request Flow + +``` text +Client + │ + ├── JWT + ▼ +Projects API + │ + ├── Verify JWT + ├── getSignedUrl() + ▼ +Signed URL + │ + ▼ +CloudFront + │ + ├── Validate signature + ├── Validate expiration + ▼ +S3 (via OAC) +``` + +------------------------------------------------------------------------ + +# Validation + +Expected results: + + Request Expected + --------------------------------------- ------------------- + Direct S3 URL 403 Access Denied + CloudFront URL without signature 403 Forbidden + CloudFront URL with invalid signature 403 Forbidden + CloudFront URL with expired signature 403 Forbidden + CloudFront URL with valid signature 200 OK + +------------------------------------------------------------------------ + +# Key Rotation + +1. Generate a new RSA key pair. +2. Create a new CloudFront Public Key. +3. Add the new Public Key to the existing Key Group. +4. Update Parameter Store with the new private key and key pair ID. +5. Deploy the backend. +6. Wait until all previously issued signed URLs have expired. +7. Remove the old Public Key from the Key Group. +8. Delete the old key material if no longer required. + +This sequence avoids downtime during key rotation. + +------------------------------------------------------------------------ + +# Security Checklist + +- Block Public Access enabled. +- No public bucket policy. +- OAC attached to the S3 origin. +- Bucket policy only permits the CloudFront distribution. +- Private key stored as SecureString in Parameter Store. +- Private key never committed to source control. +- Signed URLs expire after a short period (recommended: 5--15 + minutes). diff --git a/package.json b/package.json index a45fdc6..a72fbb2 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,12 @@ "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": { "@aws-sdk/client-s3": "^3.926.0", + "@aws-sdk/cloudfront-signer": "^3.1077.0", "@aws-sdk/s3-request-presigner": "^3.926.0", "@nestjs/axios": "^4.0.0", "@nestjs/common": "^11.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 483cfb1..346d661 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.926.0 version: 3.985.0 + '@aws-sdk/cloudfront-signer': + specifier: ^3.1077.0 + version: 3.1077.0 '@aws-sdk/s3-request-presigner': specifier: ^3.926.0 version: 3.985.0 @@ -263,6 +266,10 @@ packages: resolution: {integrity: sha512-81J8iE8MuXhdbMfIz4sWFj64Pe41bFi/uqqmqOC5SlGv+kwoyLsyKS/rH2tW2t5buih4vTUxskRjxlqikTD4oQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/cloudfront-signer@3.1077.0': + resolution: {integrity: sha512-KjOQEbesPbpVd3NjF2fGCDbigunTY/x5fERn1RmFZ836VUT2Sh4BJpaHZ1rOLE+wsEuYS4DFHkXzbVbmJ5H5lw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.7': resolution: {integrity: sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg==} engines: {node: '>=20.0.0'} @@ -1285,6 +1292,10 @@ packages: resolution: {integrity: sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==} engines: {node: '>=18.0.0'} + '@smithy/core@3.28.0': + resolution: {integrity: sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.8': resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} engines: {node: '>=18.0.0'} @@ -1405,6 +1416,10 @@ packages: resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} engines: {node: '>=18.0.0'} + '@smithy/types@4.15.0': + resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.8': resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} engines: {node: '>=18.0.0'} @@ -4740,6 +4755,11 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/cloudfront-signer@3.1077.0': + dependencies: + '@smithy/core': 3.28.0 + tslib: 2.8.1 + '@aws-sdk/core@3.973.7': dependencies: '@aws-sdk/types': 3.973.1 @@ -6106,6 +6126,11 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/core@3.28.0': + dependencies: + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.8': dependencies: '@smithy/node-config-provider': 4.3.8 @@ -6301,6 +6326,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.15.0': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.2.8': dependencies: '@smithy/querystring-parser': 4.2.8 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..c20e5a1 --- /dev/null +++ b/prisma/migrations/20260624000000_add_project_showcase_cms/migration.sql @@ -0,0 +1,71 @@ +-- 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") +); + +CREATE TABLE projects."project_showcase_post_media" ( + "id" BIGSERIAL PRIMARY KEY, + "projectShowcasePostId" BIGINT NOT NULL, + "type" VARCHAR(255) NOT NULL, + "url" VARCHAR(2048) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT now(), + "createdBy" BIGINT NOT NULL, + CONSTRAINT "project_showcase_post_media_project_showcase_post_fkey" + FOREIGN KEY ("projectShowcasePostId") REFERENCES projects."project_showcase_posts"("id") ON DELETE CASCADE +); + +-- Indexes for query performance +CREATE INDEX "project_showcase_posts_status_idx" ON projects."project_showcase_posts"("status"); +CREATE INDEX "project_showcase_posts_project_id_idx" ON projects."project_showcase_posts"("projectId"); +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..d803149 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,98 @@ 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[] + media ProjectShowcasePostMedia[] + + @@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") +} + +/// Media assets attached to project showcase posts. +model ProjectShowcasePostMedia { + id BigInt @id @default(autoincrement()) + projectShowcasePostId BigInt + type String + url String + createdAt DateTime @default(now()) + createdBy BigInt + + projectShowcasePost ProjectShowcasePost @relation(fields: [projectShowcasePostId], references: [id], onDelete: Cascade) + + @@index([projectShowcasePostId]) + @@map("project_showcase_post_media") +} diff --git a/src/api/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..88d055e --- /dev/null +++ b/src/api/project-showcase-post/dto/create-project-showcase-post.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; +import { ProjectShowcasePostStatus } from '@prisma/client'; +import { ProjectShowcasePostMediaInputDto } from './project-showcase-post-media-input.dto'; + +export class CreateProjectShowcasePostDto { + @ApiProperty({ description: 'Post title.' }) + @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() + @IsUUID('4', { each: true }) + challengeIds?: string[]; + + @ApiPropertyOptional({ type: [ProjectShowcasePostMediaInputDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProjectShowcasePostMediaInputDto) + media?: ProjectShowcasePostMediaInputDto[]; +} 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-media-input.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-media-input.dto.ts new file mode 100644 index 0000000..e1762e0 --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-media-input.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUrl } from 'class-validator'; + +export class ProjectShowcasePostMediaInputDto { + @ApiProperty({ description: 'MIME type of the media asset.' }) + @IsString() + @IsNotEmpty() + type: string; + + @ApiProperty({ description: 'URL of the media asset.' }) + @IsUrl() + url: string; +} diff --git a/src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts new file mode 100644 index 0000000..c52dfff --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-media.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUrl } from 'class-validator'; + +export class ProjectShowcasePostMediaDto { + @ApiProperty({ description: 'Media asset id.' }) + @IsString() + @IsNotEmpty() + id: string; + + @ApiProperty({ description: 'MIME type of the media asset.' }) + @IsString() + @IsNotEmpty() + type: string; + + @ApiProperty({ description: 'URL of the media asset.' }) + @IsUrl() + url: string; + + @ApiProperty({ description: 'Timestamp when media asset was created.' }) + createdAt: Date; + + @ApiProperty({ description: 'User id who created the media asset.' }) + createdBy: string; +} diff --git a/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts new file mode 100644 index 0000000..dcc64bb --- /dev/null +++ b/src/api/project-showcase-post/dto/project-showcase-post-response.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ProjectShowcasePostStatus } from '@prisma/client'; +import { ProjectShowcasePostMediaDto } from './project-showcase-post-media.dto'; + +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 }>; + + @ApiPropertyOptional({ type: [ProjectShowcasePostMediaDto] }) + media?: ProjectShowcasePostMediaDto[]; + + @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.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.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..f9fb26f --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.controller.spec.ts @@ -0,0 +1,121 @@ +import { ProjectShowcasePostController } from './project-showcase-post.controller'; + +describe('ProjectShowcasePostController', () => { + const serviceMock = { + listPosts: jest.fn(), + countPosts: jest.fn(), + listProjectPosts: jest.fn(), + countProjectPosts: jest.fn(), + getPost: jest.fn(), + createPost: jest.fn(), + updatePost: jest.fn(), + deletePost: jest.fn(), + }; + + let controller: ProjectShowcasePostController; + const user = { userId: '42', isMachine: false, tokenPayload: {} } as any; + const mockReq = { + protocol: 'http', + get: jest.fn().mockReturnValue('localhost'), + baseUrl: '', + path: '/projects/posts', + } as any; + const mockRes = { + header: jest.fn(), + getHeader: jest.fn().mockReturnValue(undefined), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new ProjectShowcasePostController( + serviceMock as any, + ); + }); + + it('searches posts', async () => { + serviceMock.listPosts.mockResolvedValue([{ id: '1' }]); + serviceMock.countPosts = jest.fn().mockResolvedValue(1); + + const response = await controller.searchPosts( + mockReq, + mockRes, + {}, + ); + + expect(response).toEqual([{ id: '1' }]); + expect(serviceMock.listPosts).toHaveBeenCalledWith({}); + expect(serviceMock.countPosts).toHaveBeenCalledWith({}); + }); + + it('lists project-specific posts', async () => { + serviceMock.listProjectPosts.mockResolvedValue([{ id: '2' }]); + serviceMock.countProjectPosts = jest.fn().mockResolvedValue(1); + + const response = await controller.listProjectPosts( + mockReq, + mockRes, + '1001', + { status: 'DRAFT' }, + user, + ); + + expect(response).toEqual([{ id: '2' }]); + expect(serviceMock.listProjectPosts).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ status: 'DRAFT' }), + user, + ); + expect(serviceMock.countProjectPosts).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..a1d04ac --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.controller.ts @@ -0,0 +1,204 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { Permission } from 'src/shared/constants/permissions'; +import { CurrentUser } from 'src/shared/decorators/currentUser.decorator'; +import { RequirePermission } from 'src/shared/decorators/requirePermission.decorator'; +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 { setProjectPaginationHeaders } from 'src/shared/utils/pagination.utils'; +import { ProjectShowcasePostResponseDto } from './dto/project-showcase-post-response.dto'; +import { ProjectShowcasePostListQueryDto } from './dto/project-showcase-post-list-query.dto'; +import { CreateProjectShowcasePostDto } from './dto/create-project-showcase-post.dto'; +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( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + @Query() query: ProjectShowcasePostListQueryDto, + ): Promise { + const posts = await this.service.listPosts(query) + const total = await this.service.countPosts(query) + + setProjectPaginationHeaders( + req, + res, + query.page || 1, + query.perPage || 20, + total, + ) + + return posts; + } + + @Get(':projectId/posts') + @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( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + @Param('projectId') projectId: string, + @Query() query: ProjectShowcasePostListQueryDto, + @CurrentUser() user: JwtUser, + ): Promise { + const posts = await this.service.listProjectPosts(projectId, query, user) + const total = await this.service.countProjectPosts(projectId, query, user) + + setProjectPaginationHeaders( + req, + res, + query.page || 1, + query.perPage || 20, + total, + ) + + return posts; + } + + @Get(':projectId/posts/:id') + @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.MANAGE_PROJECT_SHOWCASE_POST) + @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.MANAGE_PROJECT_SHOWCASE_POST) + @ApiOperation({ summary: 'Update a project showcase post' }) + @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) + @ApiParam({ name: 'id', required: true, description: 'Showcase post id' }) + @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.MANAGE_PROJECT_SHOWCASE_POST) + @ApiOperation({ summary: 'Delete a project showcase post' }) + @ApiParam({ name: 'projectId', required: true, description: 'Project id' }) + @ApiParam({ name: 'id', required: true, description: 'Showcase post id' }) + @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..9785121 --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; +import { ProjectShowcasePostController } from './project-showcase-post.controller'; +import { ProjectShowcasePostService } from './project-showcase-post.service'; +import { ProjectPostCategoryController } from './project-post-category/project-post-category.controller'; +import { ProjectPostIndustryController } from './project-post-industry/project-post-industry.controller'; +import { ProjectPostCategoryService } from './project-post-category/project-post-category.service'; +import { ProjectPostIndustryService } from './project-post-industry/project-post-industry.service'; + +@Module({ + imports: [GlobalProvidersModule], + controllers: [ + ProjectShowcasePostController, + ProjectPostCategoryController, + ProjectPostIndustryController, + ], + providers: [ + ProjectShowcasePostService, + ProjectPostCategoryService, + ProjectPostIndustryService, + ], + exports: [ProjectShowcasePostService], +}) +export class ProjectShowcasePostModule {} diff --git a/src/api/project-showcase-post/project-showcase-post.service.spec.ts b/src/api/project-showcase-post/project-showcase-post.service.spec.ts new file mode 100644 index 0000000..babbe12 --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.service.spec.ts @@ -0,0 +1,515 @@ +import { NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { ProjectShowcasePostService } from './project-showcase-post.service'; + +jest.mock('src/shared/utils/cloudfront.utils', () => ({ + signCloudFrontUrl: jest.fn((url: string) => `${url}?signed=1`), +})); + +const { ProjectShowcasePostService: ProjectShowcasePostServiceClass } = + require('./project-showcase-post.service'); + +describe('ProjectShowcasePostService', () => { + const prismaMock = { + 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: InstanceType; + 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', + }, + }, + ], + media: [ + { + id: BigInt(101), + type: 'image/png', + url: 'https://example.com/image.png', + createdAt: new Date('2026-01-01T00:00:00Z'), + createdBy: BigInt(42), + }, + ], + ...overrides, + } as const; + } + + beforeEach(() => { + jest.clearAllMocks(); + + permissionServiceMock.hasNamedPermission.mockReturnValue(true); + prismaMock.project.findFirst.mockResolvedValue({ + id: BigInt(1001), + members: [ + { + userId: BigInt(42), + role: 'manager', + deletedAt: null, + }, + ], + }); + + service = new ProjectShowcasePostServiceClass( + 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) }], + }, + media: { + create: [], + }, + }), + }), + ); + expect(response.status).toBe('DRAFT'); + }); + + it('creates a new project showcase post with media assets', async () => { + prismaMock.projectShowcasePost.create.mockResolvedValue( + buildPostRecord({ + status: 'DRAFT', + media: [ + { + id: BigInt(101), + type: 'image/png', + url: 'https://example.com/image.png', + createdAt: new Date('2026-01-01T00:00:00Z'), + createdBy: BigInt(42), + }, + ], + }), + ); + + const response = await service.createPost( + '1001', + { + title: 'New post', + content: 'New content', + industryIds: ['5'], + categoryIds: ['7'], + media: [ + { + type: 'image/png', + url: 'https://example.com/image.png', + }, + ], + }, + user, + ); + + expect(prismaMock.projectShowcasePost.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + media: { + create: [ + { + type: 'image/png', + url: 'https://example.com/image.png', + createdBy: BigInt(42), + }, + ], + }, + }), + }), + ); + expect(response.media).toEqual([ + expect.objectContaining({ + id: '101', + type: 'image/png', + url: 'https://example.com/image.png?signed=1', + }), + ]); + }); + + it('throws NotFoundException when create hits an industry foreign key constraint', async () => { + const error = Object.create( + Prisma.PrismaClientKnownRequestError.prototype, + ); + Object.assign(error, { + message: + 'Foreign key constraint violated on the constraint: `project_showcase_post_industries_industry_fkey`', + code: 'P2003', + meta: { constraint: 'project_showcase_post_industries_industry_fkey' }, + }); + + prismaMock.projectShowcasePost.create.mockRejectedValue(error); + + await expect( + service.createPost( + '1001', + { + title: 'New post', + content: 'New content', + industryIds: ['5'], + categoryIds: ['7'], + }, + user, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when create hits a category foreign key constraint', async () => { + const error = Object.create( + Prisma.PrismaClientKnownRequestError.prototype, + ); + Object.assign(error, { + message: + 'Foreign key constraint violated on the constraint: `project_showcase_post_categories_category_fkey`', + code: 'P2003', + meta: { constraint: 'project_showcase_post_categories_category_fkey' }, + }); + + prismaMock.projectShowcasePost.create.mockRejectedValue(error); + + await expect( + service.createPost( + '1001', + { + title: 'New post', + content: 'New content', + industryIds: ['5'], + categoryIds: ['7'], + }, + user, + ), + ).rejects.toThrow(NotFoundException); + }); + + + it('throws NotFoundException when updating a missing post', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue(undefined); + + 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('updates a post with media assets', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord(), + ); + prismaMock.projectShowcasePost.update.mockResolvedValue( + buildPostRecord({ + title: 'Updated title', + media: [ + { + id: BigInt(102), + type: 'video/mp4', + url: 'https://example.com/video.mp4', + createdAt: new Date('2026-01-01T00:00:00Z'), + createdBy: BigInt(42), + }, + ], + }), + ); + + const response = await service.updatePost( + '1001', + '10', + { + title: 'Updated title', + media: [ + { + type: 'video/mp4', + url: 'https://example.com/video.mp4', + }, + ], + }, + user, + ); + + expect(prismaMock.projectShowcasePost.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BigInt(10) }, + data: expect.objectContaining({ + media: { + deleteMany: {}, + create: [ + { + type: 'video/mp4', + url: 'https://example.com/video.mp4', + createdBy: BigInt(42), + }, + ], + }, + }), + }), + ); + expect(response.media).toEqual([ + expect.objectContaining({ + id: '102', + type: 'video/mp4', + url: 'https://example.com/video.mp4?signed=1', + }), + ]); + }); + + it('throws NotFoundException when update hits an industry foreign key constraint', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord(), + ); + const error = Object.create( + Prisma.PrismaClientKnownRequestError.prototype, + ); + Object.assign(error, { + message: + 'Foreign key constraint violated on the constraint: `project_showcase_post_industries_industry_fkey`', + code: 'P2003', + meta: { constraint: 'project_showcase_post_industries_industry_fkey' }, + }); + + prismaMock.projectShowcasePost.update.mockRejectedValue(error); + + await expect( + service.updatePost( + '1001', + '10', + { + industryIds: ['11'], + }, + user, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when update hits a category foreign key constraint', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue( + buildPostRecord(), + ); + const error = Object.create( + Prisma.PrismaClientKnownRequestError.prototype, + ); + Object.assign(error, { + message: + 'Foreign key constraint violated on the constraint: `project_showcase_post_categories_category_fkey`', + code: 'P2003', + meta: { constraint: 'project_showcase_post_categories_category_fkey' }, + }); + + prismaMock.projectShowcasePost.update.mockRejectedValue(error); + + await expect( + service.updatePost( + '1001', + '10', + { + categoryIds: ['12'], + }, + user, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when deleting a missing post', async () => { + prismaMock.projectShowcasePost.findFirst.mockResolvedValue(undefined); + + 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..d3ab149 --- /dev/null +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -0,0 +1,593 @@ +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'; +import { signCloudFrontUrl } from 'src/shared/utils/cloudfront.utils'; + +@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 }, + }, + media: true, + }, + orderBy, + skip, + take: perPage, + }); + + return posts.map((post) => this.toDto(post)); + } + + async countPosts(criteria: ProjectShowcasePostListQueryDto): Promise { + const where = this.buildWhere(criteria); + return this.prisma.projectShowcasePost.count({ where }); + } + + async listProjectPosts( + projectId: string, + criteria: ProjectShowcasePostListQueryDto, + 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 }, + }, + media: true, + }, + orderBy, + skip, + take: perPage, + }); + + return posts.map((post) => this.toDto(post)); + } + + async countProjectPosts( + projectId: string, + criteria: ProjectShowcasePostListQueryDto, + user: JwtUser, + ): Promise { + const parsedProjectId = parseNumericStringId(projectId, 'Project id'); + const project = await loadProjectPermissionContextBase( + this.prisma, + parsedProjectId, + ); + ensureProjectNamedPermission( + this.permissionService, + Permission.VIEW_PROJECT, + user, + project.members, + ); + + const where = this.buildWhere(criteria, parsedProjectId); + return this.prisma.projectShowcasePost.count({ where }); + } + + async getPost( + projectId: string, + id: string, + 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 }, + }, + media: 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.MANAGE_PROJECT_SHOWCASE_POST, + user, + project.members, + ); + + const auditUserId = getAuditUserId(user); + const industryIds = (dto.industryIds || []).map((industryId) => + parseNumericStringId(String(industryId), 'Industry id'), + ); + const categoryIds = (dto.categoryIds || []).map((categoryId) => + parseNumericStringId(String(categoryId), 'Category id'), + ); + + try { + const created = await this.prisma.projectShowcasePost.create({ + data: { + title: dto.title, + content: dto.content, + status: dto.status ?? 'DRAFT', + projectId: parsedProjectId, + challengeIds: dto.challengeIds || [], + createdById: auditUserId, + updatedById: auditUserId, + industries: { + create: industryIds.map((industryId) => ({ industryId })), + }, + categories: { + create: categoryIds.map((categoryId) => ({ categoryId })), + }, + media: { + create: (dto.media || []).map((asset) => ({ + type: asset.type, + url: asset.url, + createdBy: BigInt(auditUserId), + })), + }, + }, + include: { + industries: { + include: { industry: true }, + }, + categories: { + include: { category: true }, + }, + media: true, + }, + }); + + return this.toDto(created); + } catch (error) { + this.handlePrismaForeignKeyError(error, 'create project showcase post'); + } + } + + 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.MANAGE_PROJECT_SHOWCASE_POST, + 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') { + const industryIds = dto.industryIds.map((industryId) => + parseNumericStringId(String(industryId), 'Industry id'), + ); + + updateData.industries = { + deleteMany: {}, + create: industryIds.map((industryId) => ({ industryId })), + }; + } + + if (typeof dto.categoryIds !== 'undefined') { + const categoryIds = dto.categoryIds.map((categoryId) => + parseNumericStringId(String(categoryId), 'Category id'), + ); + + updateData.categories = { + deleteMany: {}, + create: categoryIds.map((categoryId) => ({ categoryId })), + }; + } + + if (typeof dto.media !== 'undefined' && Array.isArray(dto.media)) { + const auditUserId = BigInt(getAuditUserId(user)); + updateData.media = { + deleteMany: {}, + create: dto.media.map((mediaItem) => ({ + type: mediaItem.type, + url: mediaItem.url, + createdBy: auditUserId, + })), + }; + } + + try { + const updated = await this.prisma.projectShowcasePost.update({ + where: { + id: parsedId, + }, + data: updateData, + include: { + industries: { + include: { industry: true }, + }, + categories: { + include: { category: true }, + }, + media: true, + }, + }); + + return this.toDto(updated); + } catch (error) { + this.handlePrismaForeignKeyError(error, 'update project showcase post'); + } + } + + 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.MANAGE_PROJECT_SHOWCASE_POST, + 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 } }[]; + media: { + id: bigint; + type: string; + url: string; + createdAt: Date; + createdBy: bigint; + }[]; + }, + ): 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, + })), + media: post.media.map((entry) => ({ + id: String(entry.id), + type: entry.type, + url: signCloudFrontUrl(entry.url), + createdAt: entry.createdAt, + createdBy: String(entry.createdBy), + })), + }; + } + + private handlePrismaForeignKeyError( + error: unknown, + operation: string, + ): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + const meta = error.meta ?? {}; + let constraint = String(meta.constraint ?? '').trim(); + + if (!constraint) { + const match = /constraint:\s*`([^`]*)`/.exec(error.message); + if (match) { + constraint = match[1]; + } + } + + if ( + constraint.includes('project_showcase_post_industries_industry_fkey') || + /project_showcase_post_industries.*industry/i.test(constraint) + ) { + throw new NotFoundException('Industry not found for provided id.'); + } + + if ( + constraint.includes('project_showcase_post_categories_category_fkey') || + /project_showcase_post_categories.*category/i.test(constraint) + ) { + throw new NotFoundException('Category not found for provided id.'); + } + } + + throw error; + } + + private toBigIntFilter( + value: string | string[] | Record | undefined, + ): bigint[] { + 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/config/app.config.ts b/src/shared/config/app.config.ts index 3578c9f..7efdbd5 100644 --- a/src/shared/config/app.config.ts +++ b/src/shared/config/app.config.ts @@ -71,6 +71,33 @@ export const APP_CONFIG = { process.env.PRESIGNED_URL_EXPIRATION, 3600, ), + /** + * CloudFront key pair id used to sign project showcase media URLs. + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID`. + */ + cloudFrontProjectShowcaseMediaKeyPairId: + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID || + process.env.CLOUDFRONT_KEY_PAIR_ID, + /** + * CloudFront private key used to sign project showcase media URLs. + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY`. + */ + cloudFrontProjectShowcaseMediaPrivateKey: + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY, + /** + * CloudFront public key used to validate signed URLs (not used by service). + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY`. + */ + cloudFrontProjectShowcaseMediaPublicKey: + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY, + /** + * CloudFront signed URL expiry in seconds. + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_URL_EXPIRATION`, default: `600`. + */ + cloudFrontProjectShowcaseMediaUrlExpiration: parseNumberEnv( + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_URL_EXPIRATION, + 600, + ), /** * Maximum number of phase products per phase. * Env: `MAX_PHASE_PRODUCT_COUNT`, default: `20`. diff --git a/src/shared/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/src/shared/constants/permissions.ts b/src/shared/constants/permissions.ts index 09de334..856f23b 100644 --- a/src/shared/constants/permissions.ts +++ b/src/shared/constants/permissions.ts @@ -17,6 +17,8 @@ export enum Permission { CREATE_PROJECT = 'CREATE_PROJECT', /** Edit project details. */ EDIT_PROJECT = 'EDIT_PROJECT', + /** Create/update/delete project showcase posts. */ + MANAGE_PROJECT_SHOWCASE_POST = 'MANAGE_PROJECT_SHOWCASE_POST', /** Delete a project. */ DELETE_PROJECT = 'DELETE_PROJECT', /** diff --git a/src/shared/services/member.service.spec.ts b/src/shared/services/member.service.spec.ts index a032f7b..d09644c 100644 --- a/src/shared/services/member.service.spec.ts +++ b/src/shared/services/member.service.spec.ts @@ -88,6 +88,65 @@ describe('MemberService', () => { ); }); + it('returns subjects from legacy wrapped Identity role responses', async () => { + httpServiceMock.get.mockImplementation( + (url: string, options: { params?: { filter?: string } }) => { + if ( + url === 'https://identity.test/roles' && + options.params?.filter === 'roleName=Project Manager' + ) { + return of({ + data: { + result: { + content: [{ id: '201', roleName: 'Project Manager' }], + }, + }, + }); + } + + if (url === 'https://identity.test/roles/201') { + return of({ + data: { + result: { + content: { + subjects: [ + { + subjectId: 5001, + handle: 'pm-one', + email: 'PM.One@Topcoder.com', + }, + ], + }, + }, + }, + }); + } + + return of({ data: {} }); + }, + ); + + const result = await service.getRoleSubjects('Project Manager'); + + expect(result).toEqual([ + { + userId: 5001, + handle: 'pm-one', + email: 'pm.one@topcoder.com', + }, + ]); + expect(httpServiceMock.get).toHaveBeenCalledWith( + 'https://identity.test/roles/201', + expect.objectContaining({ + params: expect.objectContaining({ + fields: 'subjects', + selector: 'subjects', + perPage: 200, + }), + }), + ); + }); + it('returns empty list when role list lookup fails', async () => { httpServiceMock.get.mockImplementation((url: string) => { if (url === 'https://identity.test/roles') { diff --git a/src/shared/services/member.service.ts b/src/shared/services/member.service.ts index b8f68e0..8c82139 100644 --- a/src/shared/services/member.service.ts +++ b/src/shared/services/member.service.ts @@ -13,15 +13,12 @@ export interface MemberRoleRecord { type RoleSubjectRecord = { subjectID?: string | number; + subjectId?: string | number; userId?: string | number; handle?: string; email?: string; }; -type RoleDetailResponse = { - subjects?: RoleSubjectRecord[]; -}; - /** * Member and identity enrichment service. * @@ -213,9 +210,9 @@ export class MemberService { * Looks up role members from Identity API by role name. * * Resolves `/roles?filter=roleName=` and then - * `/roles/:id?selector=subjects&perPage=200`, returning the merged - * `subjects` list - * normalized to `MemberDetail` shape. + * `/roles/:id` with subject expansion, returning the merged `subjects` list + * normalized to `MemberDetail` shape. Supports both direct array responses + * and the legacy `{ result: { content } }` Identity response wrapper. * * Role-detail lookups are tolerant to partial failures; one failing role id * does not prevent subjects from other matching roles from being returned. @@ -250,9 +247,9 @@ export class MemberService { }), ); - const roles = Array.isArray(rolesResponse.data) - ? (rolesResponse.data as MemberRoleRecord[]) - : []; + const roles = this.extractArrayPayload( + rolesResponse.data, + ); const roleIds = roles .filter( @@ -276,14 +273,14 @@ export class MemberService { 'Content-Type': 'application/json', }, params: { + fields: 'subjects', selector: 'subjects', perPage: 200, }, }), ); - const payload = roleResponse.data as RoleDetailResponse; - return Array.isArray(payload?.subjects) ? payload.subjects : []; + return this.extractRoleSubjects(roleResponse.data); }), ); @@ -310,7 +307,7 @@ export class MemberService { .trim() .toLowerCase(); const subjectId = String( - subject.subjectID || subject.userId || '', + subject.subjectID || subject.subjectId || subject.userId || '', ).trim(); const key = email || subjectId; @@ -319,7 +316,8 @@ export class MemberService { } uniqueSubjects.set(key, { - userId: subject.subjectID || subject.userId || null, + userId: + subject.subjectID || subject.subjectId || subject.userId || null, handle: subject.handle || null, email, }); @@ -337,4 +335,90 @@ export class MemberService { async getRoleSubjectsByRoleName(roleName: string): Promise { return this.getRoleSubjects(roleName); } + + /** + * Extracts an array payload from Identity responses. + * + * @param payload Identity API response body in direct or legacy wrapped form. + * @returns Extracted array or an empty array when the shape is unsupported. + */ + private extractArrayPayload(payload: unknown): T[] { + if (Array.isArray(payload)) { + return payload as T[]; + } + + if (!payload || typeof payload !== 'object') { + return []; + } + + const response = payload as { + content?: unknown; + result?: { + content?: unknown; + }; + }; + + if (Array.isArray(response.result?.content)) { + return response.result.content as T[]; + } + + if (Array.isArray(response.content)) { + return response.content as T[]; + } + + return []; + } + + /** + * Extracts role subjects from Identity role-detail responses. + * + * @param payload Identity role-detail body in direct, legacy wrapped, or list form. + * @returns Role subject records or an empty array when no subjects are present. + */ + private extractRoleSubjects(payload: unknown): RoleSubjectRecord[] { + const directSubjects = this.extractSubjectsFromObject(payload); + if (directSubjects.length > 0) { + return directSubjects; + } + + const listPayload = this.extractArrayPayload(payload); + if (listPayload.length > 0) { + return listPayload; + } + + if (!payload || typeof payload !== 'object') { + return []; + } + + const response = payload as { + content?: unknown; + result?: { + content?: unknown; + }; + }; + + const resultSubjects = this.extractSubjectsFromObject( + response.result?.content, + ); + if (resultSubjects.length > 0) { + return resultSubjects; + } + + return this.extractSubjectsFromObject(response.content); + } + + /** + * Extracts a `subjects` array from an object-like response fragment. + * + * @param payload Response fragment that may contain a `subjects` property. + * @returns Subjects array or an empty array when absent. + */ + private extractSubjectsFromObject(payload: unknown): RoleSubjectRecord[] { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return []; + } + + const subjects = (payload as { subjects?: unknown }).subjects; + return Array.isArray(subjects) ? (subjects as RoleSubjectRecord[]) : []; + } } diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index ccd19ee..cd93773 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -748,6 +748,27 @@ describe('PermissionService', () => { ).toBe(false); }); + it('allows project copilots to manage showcase posts', () => { + const user = { + userId: '3001', + roles: [UserRole.TC_COPILOT], + isMachine: false, + }; + const projectMembers = [ + { + userId: '3001', + role: ProjectMemberRole.COPILOT, + }, + ]; + + expect( + service.hasNamedPermission( + Permission.MANAGE_PROJECT_SHOWCASE_POST, + user, + projectMembers, + ), + ).toBe(true); + }); it('allows deleting project for machine token with project write scope', () => { const allowed = service.hasNamedPermission(Permission.DELETE_PROJECT, { scopes: [Scope.PROJECTS_ALL], diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index a10308e..a765d98 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -275,6 +275,15 @@ export class PermissionService { hasMachineProjectWriteScope ); + case NamedPermission.MANAGE_PROJECT_SHOWCASE_POST: + return ( + isAdmin || + isManagementMember || + this.isCopilot(member?.role) || + this.hasProjectUpdateTopcoderRole(user) || + hasMachineProjectWriteScope + ); + case NamedPermission.DELETE_PROJECT: return ( isAdmin || @@ -532,6 +541,7 @@ export class PermissionService { return [ NamedPermission.VIEW_PROJECT, NamedPermission.EDIT_PROJECT, + NamedPermission.MANAGE_PROJECT_SHOWCASE_POST, NamedPermission.DELETE_PROJECT, NamedPermission.READ_PROJECT_MEMBER, NamedPermission.READ_PROJECT_INVITE_NOT_OWN, diff --git a/src/shared/utils/cloudfront.utils.spec.ts b/src/shared/utils/cloudfront.utils.spec.ts new file mode 100644 index 0000000..33a85f4 --- /dev/null +++ b/src/shared/utils/cloudfront.utils.spec.ts @@ -0,0 +1,33 @@ +describe('signCloudFrontUrl', () => { + const url = 'https://cdn.example.com/private/report.pdf'; + const mockSignedUrl = `${url}?signature=abc`; + + beforeEach(() => { + jest.resetModules(); + delete process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID; + delete process.env.CLOUDFRONT_KEY_PAIR_ID; + delete process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY; + }); + + it('returns original URL when signing keys are missing', () => { + jest.isolateModules(() => { + const { signCloudFrontUrl } = require('./cloudfront.utils'); + expect(signCloudFrontUrl(url)).toBe(url); + }); + }); + + it('signs the URL when CloudFront key pair id and private key are configured', () => { + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID = 'KP_ID'; + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY = + '-----BEGIN PRIVATE KEY-----\nMIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAuR1qVAvV+7Z2qmeJ\n-----END PRIVATE KEY-----'; + + jest.mock('@aws-sdk/cloudfront-signer', () => ({ + getSignedUrl: jest.fn(() => mockSignedUrl), + })); + + jest.isolateModules(() => { + const { signCloudFrontUrl } = require('./cloudfront.utils'); + expect(signCloudFrontUrl(url)).toBe(mockSignedUrl); + }); + }); +}); diff --git a/src/shared/utils/cloudfront.utils.ts b/src/shared/utils/cloudfront.utils.ts new file mode 100644 index 0000000..c65bb17 --- /dev/null +++ b/src/shared/utils/cloudfront.utils.ts @@ -0,0 +1,68 @@ +import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; +import { APP_CONFIG } from 'src/shared/config/app.config'; + +function normalizePemKey(rawKey: string | undefined): string | undefined { + if (!rawKey) { + return undefined; + } + + let normalized = rawKey.trim(); + + while (normalized.includes('\\n')) { + normalized = normalized.replace(/\\n/g, '\n'); + } + + const pemMatch = normalized.match( + /-----BEGIN ([A-Z0-9 ]+)-----\s*([A-Za-z0-9+/=\s]+)\s*-----END \1-----/s, + ); + if (pemMatch) { + const [, keyType, keyBody] = pemMatch; + const compactBody = keyBody.replace(/\s+/g, ''); + + if (compactBody.length > 0) { + const wrappedBody = compactBody.match(/.{1,64}/g)?.join('\n') || ''; + normalized = [ + `-----BEGIN ${keyType}-----`, + wrappedBody, + `-----END ${keyType}-----`, + ].join('\n'); + } + } + + if (!normalized.includes('-----BEGIN')) { + try { + const decoded = Buffer.from(normalized, 'base64').toString('utf8'); + if (decoded.includes('-----BEGIN')) { + normalized = decoded; + } + } catch { + // Keep the original value if decoding fails. + } + } + + return normalized.trim(); +} + +export function signCloudFrontUrl(url: string): string { + const keyPairId = APP_CONFIG.cloudFrontProjectShowcaseMediaKeyPairId; + const privateKey = normalizePemKey( + APP_CONFIG.cloudFrontProjectShowcaseMediaPrivateKey, + ); + + if (!keyPairId || !privateKey) { + return url; + } + + try { + return getSignedUrl({ + url, + keyPairId, + privateKey, + dateLessThan: new Date( + Date.now() + APP_CONFIG.cloudFrontProjectShowcaseMediaUrlExpiration * 1000, + ).toISOString(), + }); + } catch { + return url; + } +} diff --git a/src/shared/utils/permission-docs.utils.ts b/src/shared/utils/permission-docs.utils.ts index b9ba79e..e2d3056 100644 --- a/src/shared/utils/permission-docs.utils.ts +++ b/src/shared/utils/permission-docs.utils.ts @@ -259,6 +259,13 @@ function getNamedPermissionDocumentation( scopes: PROJECT_WRITE_SCOPES, }); + case NamedPermission.MANAGE_PROJECT_SHOWCASE_POST: + return createSummary({ + userRoles: PROJECT_UPDATE_TOPCODER_ROLES, + projectRoles: PROJECT_MEMBER_MANAGEMENT_AND_COPILOT_ROLES, + scopes: PROJECT_WRITE_SCOPES, + }); + case NamedPermission.DELETE_PROJECT: return createSummary({ userRoles: ADMIN_AND_MANAGER_ROLES, 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, }, });