diff --git a/.claude/shared-rules.md b/.claude/shared-rules.md new file mode 100644 index 00000000..d4bbdf01 --- /dev/null +++ b/.claude/shared-rules.md @@ -0,0 +1,44 @@ +# Shared project rules + +These rules apply to **all three repos** (`be`, `fe`, `sdk`). This file is synced from the +org `.github` repo — do not edit it inside a downstream repo; change it at the source and let +the sync open a PR. Repo-specific rules live below the import in each repo's own `CLAUDE.md`. + +## API changes go through the SDK contract first + +The `sdk` is the single source of truth for the API contract. Any change to the API surface +(endpoints, request/response shapes, types, error codes) **MUST** follow this order: + +1. Define or update the contract in the `sdk` repo first. +2. Publish the new SDK version to npm. +3. Only then update `be` and `fe` to consume the change. + +**IMPORTANT:** `be` and `fe` MUST depend on the SDK as published on **npmjs.com** — never on the +local `sdk` checkout or a sibling folder. Do not use `file:../sdk`, `npm link`, workspace or +`link:` references, or any other local linking for the SDK dependency. Pin to a version that is +actually published. If the version you need isn't on npmjs.com yet, the work is **not ready** to +land in `be`/`fe` — finish and publish the SDK first. + +Do not implement or "stub" an API change in `be` or `fe` ahead of the contract. If the contract is +missing, wrong, or incomplete, fix it in `sdk` and publish — never work around it locally. + +Allways make sure all API related issues are based on the latest dependency "need4deed-sdk". + +## Reuse before you create + +Before adding any new util, helper, hook, component, or shared function, **first search** the +codebase (and the SDK) for something that already does the job. Prefer reusing or extending +existing code over writing a near-duplicate. If nothing fits and you must add a new one, state +briefly why the existing options didn't work. + +## Be conservative with database schema changes + +Before altering the database schema: + +- Confirm the change is **absolutely necessary** and cannot reasonably be solved without touching + the schema. +- Confirm it is **semantically correct** — names and structure must match existing modeling + conventions and clearly express what the data represents. + +Surface the proposed change and your reasoning *before* writing a migration. Schema changes are +deliberate decisions, not incidental side effects of a feature. diff --git a/.env.example b/.env.example index 0c69eafb..26d5c200 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,36 @@ -# Transactional email via Brevo. EMAIL_FROM is the from address and must be a -# verified sender/domain in Brevo. +# Verification emails (email-verification, password-reset) — Infomaniak SMTP account #1. EMAIL_FROM=no-reply@need4deed.org -BREVO_API_KEY=fake-string +SMTP_HOST=mail.infomaniak.com +SMTP_PORT=587 +SMTP_USER=no-reply@need4deed.org +SMTP_PASS=fake-string + +# Notification emails (cron scans, status-change triggers) — Infomaniak SMTP account #2. +# Infomaniak does not allow multiple senders on one SMTP account, hence the split. +EMAIL_FROM_NOTIFY=coordinators@need4deed.org +SMTP_NOTIFY_HOST=mail.infomaniak.com +SMTP_NOTIFY_PORT=587 +SMTP_NOTIFY_USER=coordinators@need4deed.org +SMTP_NOTIFY_PASS=fake-string + # Verification email content is read from a per-locale CDN manifest # (${CDN_BASE_URL}emails/verification.json), cached in-memory; falls back to # built-in copy. Optional tuning: EMAIL_TEMPLATE_TTL_MS=600000 EMAIL_TEMPLATE_FETCH_TIMEOUT_MS=5000 +# Sender addresses for outbound notification emails (fall back to these defaults if unset). +# EMAIL_FROM_NOTIFY is the SMTP account used as `from` on all 8 notification triggers. +EMAIL_FROM_NOTIFY=coordinators@need4deed.org +EMAIL_FROM_VOLUNTEER=volunteer@need4deed.org +EMAIL_FROM_CONTACT=contact@need4deed.org +EMAIL_FROM_ACCOMPANYING=accompanying@need4deed.org + +# Notification controls (default: dry-run on in non-prod, cron active) +# NOTIFY_DRY_RUN=true — redirect all emails to test@need4deed.org with [TO: ] subject prefix +# NOTIFY_EMAIL_DRY_RUN=true — same, email-only override +# NOTIFY_CRON_MUTED=true — suppress cron-triggered email scans entirely + CORS_ORIGINS=http://localhost:3000,http://localhost:3100,http://localhost:3200 CORS_CREDENTIALS=true diff --git a/.github/workflows/build-bootstrap.yaml b/.github/workflows/build-bootstrap.yaml new file mode 100644 index 00000000..3fcda13f --- /dev/null +++ b/.github/workflows/build-bootstrap.yaml @@ -0,0 +1,32 @@ +name: build-bootstrap + +on: + push: + branches: [develop] + paths: + - data-bootstrap/** + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push bootstrap image + uses: docker/build-push-action@v6 + with: + context: . + file: data-bootstrap/Dockerfile + push: true + tags: ghcr.io/need4deed-org/bootstrap:latest diff --git a/.github/workflows/deploy-aits.yaml b/.github/workflows/deploy-aits.yaml new file mode 100644 index 00000000..ad39f355 --- /dev/null +++ b/.github/workflows/deploy-aits.yaml @@ -0,0 +1,37 @@ +name: deploy-aits + +on: + push: + branches: [develop] + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/need4deed-org/be:develop + + - name: Roll out new image on AITS + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.AITS_HOST }} + username: ubuntu + key: ${{ secrets.AITS_SSH_KEY }} + script: kubectl rollout restart deployment/be -n n4d-dev diff --git a/.gitignore b/.gitignore index be32ccb4..7822e301 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# raw database dump — contains real PII, never commit +data-bootstrap/dev.dump.sql + .idea/ .vscode/ node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 90458ff8..77f01579 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,5 @@ +@.claude/shared-rules.md + # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. @@ -163,7 +165,8 @@ Copy `.env.example` to `.env` and fill in real values. Key variables: - `JWT_SECRET` — required; server refuses to start without it - `NODE_ENV` — `development` | `test` | `production` - `RUN_MIGRATIONS` — when truthy, auto-run pending migrations on server startup (always on in prod regardless of this flag); see Commands section -- `EMAIL_FROM`, `BREVO_API_KEY` — transactional email via Brevo (verified sender + API key) +- `EMAIL_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` — Infomaniak SMTP account for verification emails (email-verification, password-reset) +- `EMAIL_FROM_NOTIFY`, `SMTP_NOTIFY_HOST`, `SMTP_NOTIFY_PORT`, `SMTP_NOTIFY_USER`, `SMTP_NOTIFY_PASS` — Infomaniak SMTP account for notification emails (cron scans, status-change triggers); separate account because Infomaniak does not allow multiple senders on one SMTP account - `EMAIL_TEMPLATE_TTL_MS`, `EMAIL_TEMPLATE_FETCH_TIMEOUT_MS` — optional; cache TTL + fetch timeout for the verification-email CDN manifest (`${CDN_BASE_URL}emails/verification.json`); falls back to built-in copy - `CORS_ORIGINS` — comma-separated list of allowed origins diff --git a/Dockerfile b/Dockerfile index 03661a6d..c4fd5452 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,12 +13,18 @@ ARG NODE_ENV=${NODE_ENV:-production} ARG CDN_BASE_URL ARG NIDS_TOKEN ARG RUN_MIGRATIONS=${RUN_MIGRATIONS:-true} +ARG GIT_COMMIT_SHA=${GIT_COMMIT_SHA:-unknown} +ARG NOTIFY_EMAIL_DRY_RUN=false +ARG NOTIFY_SLACK_DRY_RUN=false ENV NODE_ENV=${NODE_ENV} ENV PORT=8000 ENV CDN_BASE_URL=${CDN_BASE_URL} ENV NIDS_TOKEN=${NIDS_TOKEN} ENV RUN_MIGRATIONS=${RUN_MIGRATIONS} +ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} +ENV NOTIFY_EMAIL_DRY_RUN=${NOTIFY_EMAIL_DRY_RUN} +ENV NOTIFY_SLACK_DRY_RUN=${NOTIFY_SLACK_DRY_RUN} WORKDIR /app RUN apk update && apk add --no-cache curl dumb-init diff --git a/data-bootstrap/Dockerfile b/data-bootstrap/Dockerfile index 5f10688a..f2e811b3 100644 --- a/data-bootstrap/Dockerfile +++ b/data-bootstrap/Dockerfile @@ -1,22 +1,15 @@ -FROM postgres:17.2 - -# Install postgresql-client for pg_restore/psql commands - -# Set environment variables for external database connection -ENV PGHOST=localhost -ENV PGPORT=5432 -ENV PGDATABASE=mydb -ENV PGUSER=postgres -ENV PGPASSWORD=password - -# Copy the SQL files -COPY *.sql /app/ - -# Create script to restore dump to external database -COPY bootstrap.sh /app/ -RUN chmod +x /app/bootstrap.sh - +FROM node:22-alpine AS builder WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --production=false +COPY . . +RUN yarn build -# Run the restore script -CMD ["/app/bootstrap.sh"] \ No newline at end of file +FROM node:22-alpine +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --production=true && yarn cache clean +COPY --from=builder /app/build ./build +COPY public ./build/public +COPY src/data/seeds/fixtures ./build/src/data/seeds/fixtures +CMD ["node", "build/src/data/seeds/run.js"] diff --git a/data-bootstrap/bootstrap.sh b/data-bootstrap/bootstrap.sh deleted file mode 100644 index 04f7c4c1..00000000 --- a/data-bootstrap/bootstrap.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -set -e - -echo "Importing database dump..." -PGPASSWORD="${DB_PASSWORD}" psql -q -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_USER}" -d "${DB_NAME}" -f /app/dev.dump.sql - -if [ $? -eq 0 ]; then - echo "Database import completed successfully" - -else - echo "Import failed" - exit 1 -fi -# Clean up -unset PGPASSWORD - -echo "" -echo "Connection details for verification:" -echo " psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME" -exit 0 \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 8f46ac37..fb8e1f70 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,7 +14,9 @@ services: environment: - NODE_ENV=${NODE_ENV} - RUN_MIGRATIONS=${RUN_MIGRATIONS:-true} - - PORT=5000 + - PORT=8000 + - NOTIFY_EMAIL_DRY_RUN=true + - NOTIFY_SLACK_DRY_RUN=true - DB_HOST=db - DB_PORT=5432 - DB_USERNAME=postgres @@ -22,7 +24,9 @@ services: - DB_NAME=postgres - DB_SCHEMA=public ports: - - 5000:5000 + - ${BE_PORT:-5000}:${PORT:-8000} + networks: + - n4d-net volumes: - ./:/app command: ["yarn", "dev"] @@ -33,8 +37,13 @@ services: db: condition: service_healthy build: - context: ./data-bootstrap + context: . + dockerfile: data-bootstrap/Dockerfile + networks: + - n4d-net environment: + - NODE_ENV=development + - RUN_MIGRATIONS=true - DB_HOST=db - DB_PORT=5432 - DB_USER=postgres @@ -44,7 +53,7 @@ services: db: container_name: db - image: postgres:17.2 + image: postgres:17.9 ports: - 5432:5432 environment: @@ -56,18 +65,14 @@ services: POSTGRES_DB: postgres volumes: - be_database:/var/lib/postgresql + networks: + - n4d-net healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 30s timeout: 10s retries: 3 start_period: 40s - localstack: - image: localstack/localstack - ports: - - "4566:4566" - environment: - - SERVICES=ses volumes: be_database: diff --git a/package.json b/package.json index 027cfd6c..51190a61 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "@swc/core": "^1.15.10", "@swc/helpers": "^0.5.18", "@types/node": "^24.9.1", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", "@typescript-eslint/utils": "^8.46.2", @@ -41,7 +43,9 @@ "class-validator": "^0.14.2", "fastify": "^5.3.3", "fastify-plugin": "^5.0.1", - "need4deed-sdk": "0.0.110", + "need4deed-sdk": "0.0.125", + "node-cron": "^4.5.0", + "nodemailer": "^9.0.3", "pg": "^8.14.1", "pino": "^10.3.1", "pino-pretty": "^13.0.0", @@ -51,6 +55,7 @@ }, "scripts": { "start": "ts-node src/index.ts", + "seed": "ts-node src/data/seeds/run.ts", "dev": "nodemon", "typeorm": "typeorm-ts-node-commonjs -d src/data/data-source.ts", "migration:generate": "yarn typeorm migration:generate", @@ -68,6 +73,7 @@ "test:watch": "yarn test --watch", "test:coverage": "yarn test --coverage", "test:ui": "yarn test --ui", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "prepare": "husky", "what": "./get-json-field-value.sh package.json" }, diff --git a/src/config/constants.ts b/src/config/constants.ts index e4e6071d..60fce8d7 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -2,7 +2,15 @@ import * as path from "path"; const publicFixturesFromHere = ["..", "..", "public", "data"]; -const positives = ["1", "YES", "Yes", "yes", "TRUE", "True", "true"]; +export const TRUTHY = new Set([ + "1", + "YES", + "Yes", + "yes", + "TRUE", + "True", + "true", +]); export const CDNBaseUrl = process.env.CDN_BASE_URL || "https://d2nwrdddg8skub.cloudfront.net"; @@ -58,33 +66,63 @@ export const seedLeadFromFile = path.join( export const seedAgentsFile = path.join( __dirname, - ...["..", "..", "dev", "files", "notion"], + ...["..", "data", "seeds", "fixtures"], "nid-agents.json", ); export const seedOpportunitiesFile = path.join( __dirname, - ...["..", "..", "dev", "files", "notion"], + ...["..", "data", "seeds", "fixtures"], "nid-opportunities.json", ); export const seedVolunteersFile = path.join( __dirname, - ...["..", "..", "dev", "files", "notion"], + ...["..", "data", "seeds", "fixtures"], "nid-volunteers.json", ); -// export const seedAgentsFile = CDNBaseUrl + "/dev/agents.json"; -// export const seedVolunteersFile = CDNBaseUrl + "/dev/volunteers.json"; -// export const seedOpportunitiesFile = CDNBaseUrl + "/dev/opportunities.json"; - export const urlEmailVerification = process.env.URL_EMAIL_VERIFICATION || "https://app.need4deed.org/verify-email"; +export const urlPasswordReset = + process.env.URL_PASSWORD_RESET || "https://app.need4deed.org/reset-password"; + // CDN manifest (per-locale subject + html/text) for the verification email. export const emailVerificationManifestUrl = CDNBaseUrl + "/emails/verification.json"; + +// CDN manifest (per-locale subject + html/text) for the password reset email. +export const emailPasswordResetManifestUrl = + CDNBaseUrl + "/emails/password-reset.json"; + +// CDN manifests for outbound volunteer/opportunity emails. +export const emailSuggestionManifestUrl = + CDNBaseUrl + "/emails/suggestion.json"; +export const emailStaleManifestUrl = CDNBaseUrl + "/emails/stale.json"; +export const emailIntroductionManifestUrl = + CDNBaseUrl + "/emails/introduction.json"; +export const emailPostMatchCheckupManifestUrl = + CDNBaseUrl + "/emails/checkup.json"; +export const emailAccompanyNotFoundManifestUrl = + CDNBaseUrl + "/emails/accompanynotfound.json"; +export const emailAccompanyMatchManifestUrl = + CDNBaseUrl + "/emails/accompanymatch.json"; +export const emailRegularUpdateManifestUrl = CDNBaseUrl + "/emails/update.json"; +export const emailNewRegularManifestUrl = + CDNBaseUrl + "/emails/confirmation.json"; +export const emailNewAccompanyingManifestUrl = + CDNBaseUrl + "/emails/confirmationaccompanying.json"; + +export const emailFromVolunteer = + process.env.EMAIL_FROM_VOLUNTEER || "volunteer@need4deed.org"; +export const emailFromContact = + process.env.EMAIL_FROM_CONTACT || "contact@need4deed.org"; +export const emailFromAccompanying = + process.env.EMAIL_FROM_ACCOMPANYING || "accompanying@need4deed.org"; +export const emailFromNotify = + process.env.EMAIL_FROM_NOTIFY || "coordinators@need4deed.org"; // How long a fetched email manifest is cached in-memory (default 10 min). export const emailTemplateTtlMs = Number(process.env.EMAIL_TEMPLATE_TTL_MS) || 10 * 60 * 1000; @@ -95,6 +133,7 @@ export const emailTemplateFetchTimeoutMs = export const REFRESH_LIFESPAN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds export const ACCESS_LIFESPAN_MS = 15 * 60 * 1000; // 15 minutes in milliseconds export const VERIFY_LIFESPAN_MS = 24 * 60 * 60 * 1000; // 24h in milliseconds +export const RESET_LIFESPAN_MS = 60 * 60 * 1000; // 60 minutes in milliseconds export const pluginTimeout = 300 * 1000; // 30s in milliseconds export const accessCookieName = "access"; @@ -116,12 +155,8 @@ export const isDev = process.env.NODE_ENV === "development"; export const isTest = process.env.NODE_ENV === "test"; export const isProd = process.env.NODE_ENV === "production"; export const isStaging = process.env.NODE_ENV === "staging"; -export const shouldTruncateAll = positives.includes( - process.env.TRUNCATE_ALL || "", -); -export const shouldRunMigrations = positives.includes( - process.env.RUN_MIGRATIONS || "", -); +export const shouldTruncateAll = TRUTHY.has(process.env.TRUNCATE_ALL || ""); +export const shouldRunMigrations = TRUTHY.has(process.env.RUN_MIGRATIONS || ""); export const nidsToken = process.env.NIDS_TOKEN || ""; export const authAccessTokenCookieName = "access"; diff --git a/src/data/data-source.ts b/src/data/data-source.ts index 05b02656..7d8e1f18 100644 --- a/src/data/data-source.ts +++ b/src/data/data-source.ts @@ -14,6 +14,7 @@ import LeadFrom from "./entity/lead.entity"; import Address from "./entity/location/address.entity"; import District from "./entity/location/district.entity"; import Postcode from "./entity/location/postcode.entity"; +import ActivityLog from "./entity/m2m/activity-log.entity"; import AgentLanguage from "./entity/m2m/agent-language"; import AgentPerson from "./entity/m2m/agent-person"; import AgentPostcode from "./entity/m2m/agent-postcode"; @@ -32,6 +33,7 @@ import Opportunity from "./entity/opportunity/opportunity.entity"; import Option from "./entity/option.entity"; import Organization from "./entity/organization.entity"; import Person from "./entity/person.entity"; +import Post from "./entity/post.entity"; import Activity from "./entity/profile/activity.entity"; import Category from "./entity/profile/category.entity"; import Language from "./entity/profile/language.entity"; @@ -59,6 +61,7 @@ export const dataSource = new DataSource({ entities: [ Accompanying, Activity, + ActivityLog, Address, Agent, AgentLanguage, @@ -89,6 +92,7 @@ export const dataSource = new DataSource({ OpportunityVolunteer, Organization, Person, + Post, Postcode, Option, Skill, diff --git a/src/data/entity/communication.entity.ts b/src/data/entity/communication.entity.ts index 79855c57..5856db82 100644 --- a/src/data/entity/communication.entity.ts +++ b/src/data/entity/communication.entity.ts @@ -6,19 +6,22 @@ import { import { Check, Column, + CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, } from "typeorm"; import Agent from "./opportunity/agent.entity"; +import Opportunity from "./opportunity/opportunity.entity"; import User from "./user.entity"; import Volunteer from "./volunteer/volunteer.entity"; @Entity() @Check(` (CASE WHEN "volunteer_id" IS NOT NULL THEN 1 ELSE 0 END + - CASE WHEN "agent_id" IS NOT NULL THEN 1 ELSE 0 END) = 1 + CASE WHEN "agent_id" IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN "opportunity_id" IS NOT NULL THEN 1 ELSE 0 END) >= 1 `) export default class Communication { constructor(communication?: Partial) { @@ -26,6 +29,7 @@ export default class Communication { Object.assign(this, communication); } } + @PrimaryGeneratedColumn() id: number; @@ -38,36 +42,46 @@ export default class Communication { @Column({ type: "enum", enum: CommunicationType, nullable: true }) communicationType?: CommunicationType; - @Column({ type: "timestamp" }) + @CreateDateColumn() date: Date; @ManyToOne(() => Volunteer, { - nullable: false, + nullable: true, onDelete: "CASCADE", }) @JoinColumn({ name: "volunteer_id" }) - volunteer: Volunteer; + volunteer?: Volunteer; @Column({ nullable: true }) - volunteerId: number; + volunteerId?: number; @ManyToOne(() => Agent, { - nullable: false, + nullable: true, onDelete: "CASCADE", }) @JoinColumn({ name: "agent_id" }) - agent: Agent; + agent?: Agent; + + @Column({ nullable: true }) + agentId?: number; + + @ManyToOne(() => Opportunity, { + nullable: true, + onDelete: "CASCADE", + }) + @JoinColumn({ name: "opportunity_id" }) + opportunity?: Opportunity; @Column({ nullable: true }) - agentId: number; + opportunityId?: number; @ManyToOne(() => User, { - nullable: false, + nullable: true, onDelete: "CASCADE", }) @JoinColumn({ name: "user_id" }) - user: User; + user?: User; @Column({ nullable: true }) - userId: number; + userId?: number; } diff --git a/src/data/entity/m2m/activity-log.entity.ts b/src/data/entity/m2m/activity-log.entity.ts new file mode 100644 index 00000000..5093335c --- /dev/null +++ b/src/data/entity/m2m/activity-log.entity.ts @@ -0,0 +1,50 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm"; +import OpportunityVolunteer from "./opportunity-volunteer"; + +@Entity() +export default class ActivityLog { + constructor(partial?: Partial) { + if (partial) { + Object.assign(this, partial); + } + } + + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: "date" }) + date: Date; + + // Stored as decimal; transformer coerces the pg driver's string return to number. + @Column({ + type: "decimal", + precision: 5, + scale: 2, + transformer: { to: (v: number) => v, from: (v: string) => Number(v) }, + }) + hours: number; + + @ManyToOne(() => OpportunityVolunteer, (ov) => ov.activityLogs, { + nullable: false, + onDelete: "CASCADE", + }) + @JoinColumn({ name: "opportunity_volunteer_id" }) + opportunityVolunteer: OpportunityVolunteer; + + @Column() + opportunityVolunteerId: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/data/entity/m2m/comment-person.ts b/src/data/entity/m2m/comment-person.ts index 820351db..39422d57 100644 --- a/src/data/entity/m2m/comment-person.ts +++ b/src/data/entity/m2m/comment-person.ts @@ -41,4 +41,7 @@ export default class CommentPerson { @Column() personId: number; + + @Column({ type: "timestamp", nullable: true, default: null }) + readAt: Date | null; } diff --git a/src/data/entity/m2m/opportunity-volunteer.ts b/src/data/entity/m2m/opportunity-volunteer.ts index ac22a48a..4e46846f 100644 --- a/src/data/entity/m2m/opportunity-volunteer.ts +++ b/src/data/entity/m2m/opportunity-volunteer.ts @@ -9,12 +9,14 @@ import { Entity, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, Unique, UpdateDateColumn, } from "typeorm"; import Opportunity from "../opportunity/opportunity.entity"; import Volunteer from "../volunteer/volunteer.entity"; +import ActivityLog from "./activity-log.entity"; @Entity() @Unique(["opportunityId", "volunteerId"]) @@ -63,23 +65,32 @@ export default class OpportunityVolunteer { @Column() volunteerId: number; + @OneToMany(() => ActivityLog, (log) => log.opportunityVolunteer) + activityLogs: ActivityLog[]; + @AfterInsert() async afterInsertHook() { - const { updateVolunteerMatching, updateOpportunityMatching } = await import("../../utils"); + const { updateVolunteerMatching, updateOpportunityMatching } = await import( + "../../utils" + ); updateVolunteerMatching(this.volunteerId); updateOpportunityMatching(this.opportunityId); } @AfterUpdate() async afterUpdateHook() { - const { updateVolunteerMatching, updateOpportunityMatching } = await import("../../utils"); + const { updateVolunteerMatching, updateOpportunityMatching } = await import( + "../../utils" + ); updateVolunteerMatching(this.volunteerId); updateOpportunityMatching(this.opportunityId); } @AfterRemove() async afterRemoveHook() { - const { updateVolunteerMatching, updateOpportunityMatching } = await import("../../utils"); + const { updateVolunteerMatching, updateOpportunityMatching } = await import( + "../../utils" + ); updateVolunteerMatching(this.volunteerId); updateOpportunityMatching(this.opportunityId); } diff --git a/src/data/entity/opportunity/opportunity.entity.ts b/src/data/entity/opportunity/opportunity.entity.ts index ce0f4116..6b096254 100644 --- a/src/data/entity/opportunity/opportunity.entity.ts +++ b/src/data/entity/opportunity/opportunity.entity.ts @@ -15,6 +15,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; +import Communication from "../communication.entity"; import Deal from "../deal.entity"; import District from "../location/district.entity"; import OpportunityVolunteer from "../m2m/opportunity-volunteer"; @@ -131,6 +132,9 @@ export default class Opportunity { @OneToMany(() => Appreciation, (appreciation) => appreciation.opportunity) appreciations: Appreciation[]; + @OneToMany(() => Communication, (communication) => communication.opportunity) + communications: Communication[]; + @ManyToOne(() => Person, { nullable: true }) @JoinColumn({ name: "contact_person_id" }) contactPerson?: Person; diff --git a/src/data/entity/post.entity.ts b/src/data/entity/post.entity.ts new file mode 100644 index 00000000..e7c0794f --- /dev/null +++ b/src/data/entity/post.entity.ts @@ -0,0 +1,65 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm"; +import Agent from "./opportunity/agent.entity"; +import Opportunity from "./opportunity/opportunity.entity"; +import Person from "./person.entity"; + +@Entity() +export default class Post { + constructor(post?: Partial) { + if (post) { + Object.assign(this, post); + } + } + + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: "text" }) + text: string; + + @ManyToOne(() => Person, { nullable: false, onDelete: "CASCADE" }) + @JoinColumn({ name: "author_id" }) + author: Person; + + @Column() + authorId: number; + + @ManyToOne(() => Agent, { nullable: true, onDelete: "SET NULL" }) + @JoinColumn({ name: "agent_id" }) + agent: Agent | null; + + @Column({ nullable: true }) + agentId: number | null; + + @ManyToMany(() => Person) + @JoinTable({ + name: "post_person", + joinColumn: { name: "post_id" }, + inverseJoinColumn: { name: "person_id" }, + }) + taggedPersons: Person[]; + + @ManyToMany(() => Opportunity) + @JoinTable({ + name: "post_opportunity", + joinColumn: { name: "post_id" }, + inverseJoinColumn: { name: "opportunity_id" }, + }) + linkedOpportunities: Opportunity[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/data/entity/volunteer/volunteer.entity.ts b/src/data/entity/volunteer/volunteer.entity.ts index 3ca2cad8..4dcd99d6 100644 --- a/src/data/entity/volunteer/volunteer.entity.ts +++ b/src/data/entity/volunteer/volunteer.entity.ts @@ -173,6 +173,9 @@ export default class Volunteer { @OneToMany(() => Appreciation, (appreciation) => appreciation.volunteer) appreciations: Appreciation[]; + @Column({ default: true }) + shareContact: boolean; + @Column({ nullable: true }) @IsOptional() @IsString() diff --git a/src/data/index.ts b/src/data/index.ts index e5b3ed0e..b5c9ce32 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -1,6 +1,7 @@ import { isProd, shouldRunMigrations } from "../config"; import logger from "../logger"; import { dataSource } from "./data-source"; +import { GenesisBaseSchema1763036250000 } from "./migrations/1763036250000-genesis-base-schema"; const lockNumber = 0x639b4e2a1c8d79a9n; // random BIGINT (for PostgreSQL) @@ -16,6 +17,48 @@ export const check = { }, }; +// On a fresh database the migrations table is empty, so TypeORM would try to +// run all 74 migrations in sequence — but migrations 1-73 conflict with the +// schema that genesis already created (duplicate enums/tables). +// This function runs genesis directly and marks all 74 migrations as done +// BEFORE TypeORM reads the migrations table, so TypeORM sees 0 pending and +// skips everything. On an existing database (cnt > 0) it is a no-op. +async function bootstrapFreshDb(): Promise { + const qr = dataSource.createQueryRunner(); + try { + await qr.query(` + CREATE TABLE IF NOT EXISTS public.be_migrations ( + id SERIAL NOT NULL, + "timestamp" bigint NOT NULL, + name character varying NOT NULL, + PRIMARY KEY (id) + ) + `); + const [{ cnt }] = await qr.query( + `SELECT COUNT(*)::int AS cnt FROM public.be_migrations`, + ); + if (cnt > 0) {return;} + + logger.info("Fresh database detected — running genesis bootstrap"); + await qr.startTransaction(); + try { + const genesis = new GenesisBaseSchema1763036250000(); + await genesis.up(qr); + await qr.query( + `INSERT INTO public.be_migrations (timestamp, name) + VALUES (1763036250000, 'GenesisBaseSchema1763036250000')`, + ); + await qr.commitTransaction(); + } catch (e) { + await qr.rollbackTransaction(); + throw e; + } + logger.info("Genesis bootstrap completed — all migrations pre-marked"); + } finally { + await qr.release(); + } +} + export async function initDatabase() { await dataSource.initialize(); if (dataSource.isInitialized) { @@ -25,6 +68,7 @@ export async function initDatabase() { try { if (isProd || shouldRunMigrations) { logger.info("Attempting to run migrations"); + await bootstrapFreshDb(); await dataSource.runMigrations(); logger.info("Migrations completed"); } diff --git a/src/data/migrations/1763036250000-genesis-base-schema.ts b/src/data/migrations/1763036250000-genesis-base-schema.ts new file mode 100644 index 00000000..e7453585 --- /dev/null +++ b/src/data/migrations/1763036250000-genesis-base-schema.ts @@ -0,0 +1,1148 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class GenesisBaseSchema1763036250000 implements MigrationInterface { + name = "GenesisBaseSchema1763036250000"; + + public async up(queryRunner: QueryRunner): Promise { + // ─── ENUMS (idempotent via DO blocks) ──────────────────────────────────── + + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.accompanying_language_to_translate_enum AS ENUM ('deutsche','englishOk','noTranslation'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.agent_engagement_status_enum AS ENUM ('agent-new','agent-active','agent-unresponsive','agent-inactive'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.agent_person_role_enum AS ENUM ('social-worker','volunteer-coordinator','manager','project-coordinator','psychologist','project-staff','childcare-worker','other'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.agent_person_status_enum AS ENUM ('active','pending'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.agent_search_status_enum AS ENUM ('agent-searching','agent-not-needed','agent-volunteers-found'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.agent_trust_level_enum AS ENUM ('agent-high','agent-low','agent-unknown'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.agent_type_enum AS ENUM ('AE','GU1','GU2','GU2+','GU3','NU','ASOG','counseling-center','tandem','multiple-social-support'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.appreciation_title_enum AS ENUM ('t-shirt','benefit-card','tote-bag'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.comment_entity_type_enum AS ENUM ('none','activity','agent','comment','category','district','language','lead_from','opportunity','skill','volunteer'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.communication_communication_type_enum AS ENUM ('briefed','first-inquiry-sent','opportunity-list-sent','status-update','post-match-followup','matched','accompanying-not-found','accompanying-matched','opportunity-updated','opportunity-confirmation'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.communication_contact_method_enum AS ENUM ('email','phone-number','telegram','whatsapp','signal','sms','voicenote','video-call'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.communication_contact_type_enum AS ENUM ('called','tried-to-call','texted-or-emailed','other'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.config_config_key_enum AS ENUM ('schema','reference_data','master_data','truncate-all'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.deal_language_proficiency_enum AS ENUM ('intermediate','fluent','native','advanced','beginner'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.deal_language_purpose_enum AS ENUM ('general','translation','recipient'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.deal_type_enum AS ENUM ('volunteer','opportunity'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.document_type_enum AS ENUM ('measles-vacc-cert','good-conduct-cert','CGC-application','passport-copy'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.event_n4d_type_enum AS ENUM ('party','workshop'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.field_translation_entity_type_enum AS ENUM ('none','activity','agent','comment','category','district','language','lead_from','opportunity','skill','volunteer'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.notion_relation_host_type_enum AS ENUM ('none','activity','agent','comment','category','district','language','lead_from','opportunity','skill','volunteer'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.notion_relation_tenant_type_enum AS ENUM ('none','activity','agent','comment','category','district','language','lead_from','opportunity','skill','volunteer'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.opportunity_status_enum AS ENUM ('opp-new','opp-searching','opp-active','opp-inactive','opp-past'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.opportunity_status_match_enum AS ENUM ('opp-vol-pending-match','opp-vol-no-matches','opp-vol-matched','opp-vol-needs-rematch','opp-vol-unmatched','opp-vol-past'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.opportunity_translation_type_enum AS ENUM ('deutsche','englishOk','noTranslation'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.opportunity_type_enum AS ENUM ('accompanying','regular','events'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.opportunity_volunteer_status_enum AS ENUM ('opp-pending','opp-matched','opp-active','opp-past'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.option_item_type_enum AS ENUM ('none','activity','agent','comment','category','district','language','lead_from','opportunity','skill','volunteer'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.timeline_content_entity_type_enum AS ENUM ('none','activity','agent','comment','category','district','language','lead_from','opportunity','skill','volunteer'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.timeline_content_type_enum AS ENUM ('create','update','remove','comment','status','matching'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.timeline_source_entity_type_enum AS ENUM ('none','activity','agent','comment','category','district','language','lead_from','opportunity','skill','volunteer'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.timeline_target_entity_type_enum AS ENUM ('none','activity','agent','comment','category','district','language','lead_from','opportunity','skill','volunteer'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.timeslot_occasional_enum AS ENUM ('weekends','weekdays'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.user_role_enum AS ENUM ('user','coordinator','agent','volunteer','admin'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.volunteer_status_appreciation_enum AS ENUM ('t-shirt','benefit-card','tote-bag'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.volunteer_status_cgc_enum AS ENUM ('undefined','yes','no','asked_to_apply','applied_self','applied_n4d'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.volunteer_status_cgc_process_enum AS ENUM ('uploaded','missing'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.volunteer_status_communication_enum AS ENUM ('called','email-sent','briefed','tried-call','not-responding'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.volunteer_status_engagement_enum AS ENUM ('vol-new','vol-active','vol-available','vol-temp-unavailable','vol-inactive','vol-unresponsive'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.volunteer_status_match_enum AS ENUM ('vol-no-matches','vol-pending-match','vol-matched','vol-needs-rematch','vol-past'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.volunteer_status_type_enum AS ENUM ('accompanying','regular','events','regular-accompanying'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE public.volunteer_status_vaccination_enum AS ENUM ('undefined','yes','no','asked_to_apply','applied_self','applied_n4d'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + + // ─── SEQUENCES ─────────────────────────────────────────────────────────── + + for (const seq of [ + "accompanying_id_seq", + "activity_id_seq", + "activity_log_id_seq", + "address_id_seq", + "agent_id_seq", + "agent_language_id_seq", + "agent_person_id_seq", + "agent_postcode_id_seq", + "appreciation_id_seq", + "category_id_seq", + "comment_id_seq", + "comment_person_id_seq", + "communication_id_seq", + "config_id_seq", + "deal_id_seq", + "deal_activity_id_seq", + "deal_district_id_seq", + "deal_language_id_seq", + "deal_skill_id_seq", + "deal_timeslot_id_seq", + "district_id_seq", + "district_postcode_id_seq", + "document_id_seq", + "event_n4d_id_seq", + "event_translation_id_seq", + "field_translation_id_seq", + "language_id_seq", + "lead_from_id_seq", + "notion_relation_id_seq", + "opportunity_id_seq", + "opportunity_volunteer_id_seq", + "option_id_seq", + "organization_id_seq", + "person_id_seq", + "post_id_seq", + "postcode_id_seq", + "skill_id_seq", + "testimonial_id_seq", + "timeline_id_seq", + "timeslot_id_seq", + "trusted_domain_id_seq", + "user_id_seq", + "volunteer_id_seq", + ]) { + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS public.${seq} AS integer START 1 INCREMENT 1 NO MINVALUE NO MAXVALUE CACHE 1`, + ); + } + + // ─── TABLES ────────────────────────────────────────────────────────────── + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.language ( + id integer NOT NULL, + iso_code character varying NOT NULL, + title character varying NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.postcode ( + id integer NOT NULL, + longitude numeric(10,7), + latitude numeric(9,7), + value character varying NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.district ( + id integer NOT NULL, + title character varying NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.category ( + id integer NOT NULL, + title character varying NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.skill ( + id integer NOT NULL, + title character varying NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.lead_from ( + id integer NOT NULL, + count integer DEFAULT 0 NOT NULL, + title character varying NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.address ( + id integer NOT NULL, + title character varying, + street character varying, + postcode_id integer NOT NULL, + city character varying + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.person ( + id integer NOT NULL, + first_name character varying NOT NULL, + middle_name character varying, + last_name character varying, + email character varying, + phone character varying, + avatar_url character varying, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + address_id integer, + preferred_communication_type text[] DEFAULT '{mobilePhone}'::text[] NOT NULL, + landline character varying + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public."user" ( + id integer NOT NULL, + email character varying NOT NULL, + password character varying NOT NULL, + is_active boolean DEFAULT false NOT NULL, + role public.user_role_enum DEFAULT 'user'::public.user_role_enum NOT NULL, + language character varying DEFAULT 'en'::character varying NOT NULL, + timezone character varying DEFAULT 'CET'::character varying NOT NULL, + person_id integer, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.organization ( + id integer NOT NULL, + title character varying NOT NULL, + email character varying, + phone character varying, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + address_id integer NOT NULL, + person_id integer NOT NULL, + website character varying + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.district_postcode ( + id integer NOT NULL, + district_id integer NOT NULL, + postcode_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.agent ( + id integer NOT NULL, + title character varying NOT NULL, + type public.agent_type_enum, + website character varying, + trust_level public.agent_trust_level_enum DEFAULT 'agent-unknown'::public.agent_trust_level_enum NOT NULL, + search_status public.agent_search_status_enum DEFAULT 'agent-not-needed'::public.agent_search_status_enum NOT NULL, + services text[], + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + address_id integer, + district_id integer, + engagement_status public.agent_engagement_status_enum DEFAULT 'agent-new'::public.agent_engagement_status_enum NOT NULL, + info character varying, + organization_id integer, + nid character varying + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.agent_postcode ( + id integer NOT NULL, + agent_id integer NOT NULL, + postcode_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.timeslot ( + id integer NOT NULL, + info character varying, + rrule character varying, + start timestamp without time zone, + "end" timestamp without time zone, + occasional public.timeslot_occasional_enum + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.deal ( + id integer NOT NULL, + type public.deal_type_enum NOT NULL, + postcode_id integer NOT NULL, + info character varying, + category_id integer + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.activity ( + id integer NOT NULL, + title character varying NOT NULL, + category_id integer + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.volunteer ( + id integer NOT NULL, + info_about character varying, + info_experience character varying, + status_engagement public.volunteer_status_engagement_enum DEFAULT 'vol-new'::public.volunteer_status_engagement_enum NOT NULL, + status_communication public.volunteer_status_communication_enum, + status_appreciation public.volunteer_status_appreciation_enum, + status_type public.volunteer_status_type_enum, + status_match public.volunteer_status_match_enum DEFAULT 'vol-no-matches'::public.volunteer_status_match_enum NOT NULL, + status_cgc_process public.volunteer_status_cgc_process_enum, + status_vaccination public.volunteer_status_vaccination_enum DEFAULT 'undefined'::public.volunteer_status_vaccination_enum NOT NULL, + status_cgc public.volunteer_status_cgc_enum DEFAULT 'undefined'::public.volunteer_status_cgc_enum NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + deal_id integer, + person_id integer, + preferred_communication_type text[] DEFAULT '{mobilePhone}'::text[] NOT NULL, + date_return timestamp without time zone, + nid character varying, + status_vaccination_date timestamp without time zone, + status_cgc_application_date timestamp without time zone, + status_cgc_date timestamp without time zone, + share_contact boolean DEFAULT true NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.accompanying ( + id integer NOT NULL, + address character varying NOT NULL, + name character varying NOT NULL, + phone character varying, + email character varying, + date timestamp with time zone NOT NULL, + language_to_translate public.accompanying_language_to_translate_enum, + postcode_id integer + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.opportunity ( + id integer NOT NULL, + title character varying NOT NULL, + type public.opportunity_type_enum NOT NULL, + info character varying, + translation_type public.opportunity_translation_type_enum, + info_confidential character varying, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + deal_id integer, + agent_id integer, + status public.opportunity_status_enum DEFAULT 'opp-new'::public.opportunity_status_enum NOT NULL, + number_volunteers integer DEFAULT 1 NOT NULL, + accompanying_id integer, + nid character varying, + status_match public.opportunity_status_match_enum DEFAULT 'opp-vol-no-matches'::public.opportunity_status_match_enum NOT NULL, + district_id integer, + contact_person_id integer, + submitted_by_person_id integer + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.opportunity_volunteer ( + id integer NOT NULL, + status public.opportunity_volunteer_status_enum NOT NULL, + opportunity_id integer NOT NULL, + volunteer_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.option ( + id integer NOT NULL, + item_type public.option_item_type_enum NOT NULL, + item_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.field_translation ( + id integer NOT NULL, + field_name character varying DEFAULT 'title'::character varying NOT NULL, + language_id integer, + entity_type public.field_translation_entity_type_enum DEFAULT 'none'::public.field_translation_entity_type_enum NOT NULL, + entity_id integer NOT NULL, + translation text NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.timeline ( + id integer NOT NULL, + source_entity_type public.timeline_source_entity_type_enum, + source_entity_id integer, + target_entity_type public.timeline_target_entity_type_enum NOT NULL, + target_entity_id integer NOT NULL, + content_entity_type public.timeline_content_entity_type_enum NOT NULL, + content_entity_id integer NOT NULL, + content_type public.timeline_content_type_enum NOT NULL, + "timestamp" timestamp without time zone DEFAULT now() NOT NULL, + content text NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.event_n4d ( + id integer NOT NULL, + is_active boolean DEFAULT false NOT NULL, + date timestamp with time zone NOT NULL, + date_end timestamp with time zone, + type public.event_n4d_type_enum DEFAULT 'party'::public.event_n4d_type_enum NOT NULL, + pic character varying(256), + location_link character varying(256), + rsvp_link character varying(256) NOT NULL, + followup_link character varying(256), + address character varying(256) NOT NULL, + host_name character varying(256), + language_id integer + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.event_translation ( + id integer NOT NULL, + title character varying(256), + subtitle character varying(256), + menu_title character varying(256), + time_str character varying(256), + location_comment character varying(256), + description text NOT NULL, + short_description character varying(512) NOT NULL, + additional_title character varying(256), + additional_info jsonb, + outro text, + followup_text character varying(256), + eventn4d_id integer, + language_id integer + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.testimonial ( + id integer NOT NULL, + is_active boolean DEFAULT false NOT NULL, + name character varying(256), + pic character varying(256), + person_id integer, + language_id integer + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.agent_language ( + id integer NOT NULL, + agent_id integer NOT NULL, + language_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.agent_person ( + id integer NOT NULL, + role public.agent_person_role_enum DEFAULT 'other'::public.agent_person_role_enum NOT NULL, + agent_id integer NOT NULL, + person_id integer NOT NULL, + status public.agent_person_status_enum DEFAULT 'active'::public.agent_person_status_enum NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.appreciation ( + id integer NOT NULL, + title public.appreciation_title_enum NOT NULL, + date_due timestamp without time zone, + date_delivery timestamp without time zone, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + opportunity_id integer, + volunteer_id integer NOT NULL, + user_id integer + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.comment ( + id integer NOT NULL, + text text NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + language_id integer, + user_id integer NOT NULL, + entity_type public.comment_entity_type_enum DEFAULT 'none'::public.comment_entity_type_enum NOT NULL, + entity_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.comment_person ( + id integer NOT NULL, + comment_id integer NOT NULL, + person_id integer NOT NULL, + read_at timestamp without time zone + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.communication ( + id integer NOT NULL, + contact_type public.communication_contact_type_enum NOT NULL, + contact_method public.communication_contact_method_enum NOT NULL, + communication_type public.communication_communication_type_enum, + date timestamp without time zone DEFAULT now() NOT NULL, + volunteer_id integer, + user_id integer, + agent_id integer, + opportunity_id integer, + CONSTRAINT "CHK_0ea4b79edeac5eaa19c54735c7" CHECK (((CASE WHEN (volunteer_id IS NOT NULL) THEN 1 ELSE 0 END + CASE WHEN (agent_id IS NOT NULL) THEN 1 ELSE 0 END) + CASE WHEN (opportunity_id IS NOT NULL) THEN 1 ELSE 0 END) >= 1) + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.config ( + id integer NOT NULL, + config_key public.config_config_key_enum NOT NULL, + config_value boolean + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.deal_activity ( + id integer NOT NULL, + deal_id integer NOT NULL, + activity_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.deal_district ( + id integer NOT NULL, + deal_id integer NOT NULL, + district_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.deal_language ( + id integer NOT NULL, + proficiency public.deal_language_proficiency_enum DEFAULT 'advanced'::public.deal_language_proficiency_enum, + purpose public.deal_language_purpose_enum DEFAULT 'general'::public.deal_language_purpose_enum, + deal_id integer NOT NULL, + language_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.deal_skill ( + id integer NOT NULL, + deal_id integer NOT NULL, + skill_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.deal_timeslot ( + id integer NOT NULL, + deal_id integer NOT NULL, + timeslot_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.document ( + id integer NOT NULL, + type public.document_type_enum NOT NULL, + s3_key character varying NOT NULL, + original_name character varying NOT NULL, + mime_type character varying NOT NULL, + volunteer_id integer, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.notion_relation ( + id integer NOT NULL, + payroll character varying, + host_nid character varying NOT NULL, + host_type public.notion_relation_host_type_enum NOT NULL, + host_id integer NOT NULL, + tenant_nid character varying NOT NULL, + tenant_type public.notion_relation_tenant_type_enum NOT NULL, + tenant_id integer + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.post ( + id integer NOT NULL, + text text NOT NULL, + author_id integer NOT NULL, + agent_id integer, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.post_opportunity ( + post_id integer NOT NULL, + opportunity_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.post_person ( + post_id integer NOT NULL, + person_id integer NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.activity_log ( + id integer NOT NULL, + date date NOT NULL, + hours numeric(5,2) NOT NULL, + opportunity_volunteer_id integer NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL + )`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.trusted_domain ( + id integer NOT NULL, + domain character varying NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL + )`); + + // ─── SEQUENCE OWNERSHIP & COLUMN DEFAULTS ───────────────────────────────── + // Both are idempotent (SET DEFAULT overwrites, OWNED BY reassigns). + + const seqOwners: [string, string, string][] = [ + ["accompanying_id_seq", "accompanying", "id"], + ["activity_id_seq", "activity", "id"], + ["activity_log_id_seq", "activity_log", "id"], + ["address_id_seq", "address", "id"], + ["agent_id_seq", "agent", "id"], + ["agent_language_id_seq", "agent_language", "id"], + ["agent_person_id_seq", "agent_person", "id"], + ["agent_postcode_id_seq", "agent_postcode", "id"], + ["appreciation_id_seq", "appreciation", "id"], + ["category_id_seq", "category", "id"], + ["comment_id_seq", "comment", "id"], + ["comment_person_id_seq", "comment_person", "id"], + ["communication_id_seq", "communication", "id"], + ["config_id_seq", "config", "id"], + ["deal_id_seq", "deal", "id"], + ["deal_activity_id_seq", "deal_activity", "id"], + ["deal_district_id_seq", "deal_district", "id"], + ["deal_language_id_seq", "deal_language", "id"], + ["deal_skill_id_seq", "deal_skill", "id"], + ["deal_timeslot_id_seq", "deal_timeslot", "id"], + ["district_id_seq", "district", "id"], + ["district_postcode_id_seq", "district_postcode", "id"], + ["document_id_seq", "document", "id"], + ["event_n4d_id_seq", "event_n4d", "id"], + ["event_translation_id_seq", "event_translation", "id"], + ["field_translation_id_seq", "field_translation", "id"], + ["language_id_seq", "language", "id"], + ["lead_from_id_seq", "lead_from", "id"], + ["notion_relation_id_seq", "notion_relation", "id"], + ["opportunity_id_seq", "opportunity", "id"], + ["opportunity_volunteer_id_seq", "opportunity_volunteer", "id"], + ["option_id_seq", "option", "id"], + ["organization_id_seq", "organization", "id"], + ["person_id_seq", "person", "id"], + ["post_id_seq", "post", "id"], + ["postcode_id_seq", "postcode", "id"], + ["skill_id_seq", "skill", "id"], + ["testimonial_id_seq", "testimonial", "id"], + ["timeline_id_seq", "timeline", "id"], + ["timeslot_id_seq", "timeslot", "id"], + ["trusted_domain_id_seq", "trusted_domain", "id"], + ["user_id_seq", '"user"', "id"], + ["volunteer_id_seq", "volunteer", "id"], + ]; + + for (const [seq, tbl, col] of seqOwners) { + await queryRunner.query( + `ALTER SEQUENCE public.${seq} OWNED BY public.${tbl}.${col}`, + ); + await queryRunner.query( + `ALTER TABLE ONLY public.${tbl} ALTER COLUMN ${col} SET DEFAULT nextval('public.${seq}'::regclass)`, + ); + } + + // ─── PRIMARY KEYS ──────────────────────────────────────────────────────── + + const pks: [string, string, string][] = [ + ["accompanying", "PK_d0fd931d21e719a937ba4ca36ac", "id"], + ["activity", "PK_24625a1d6b1b089c8ae206fe467", "id"], + ["activity_log", "PK_067d761e2956b77b14e534fd6f1", "id"], + ["address", "PK_d92de1f82754668b5f5f5dd4fd5", "id"], + ["agent", "PK_1000e989398c5d4ed585cf9a46f", "id"], + ["agent_language", "PK_c4b59dce9dddd19cb7ab607b1ad", "id"], + ["agent_person", "PK_d78c87230af992a1bf93c5b93ae", "id"], + ["agent_postcode", "PK_790c4a8ac360683061758eea4fb", "id"], + ["appreciation", "PK_d9824c8e198e82f7394c805eddf", "id"], + ["category", "PK_9c4e4a89e3674fc9f382d733f03", "id"], + ["comment", "PK_0b0e4bbc8415ec426f87f3a88e2", "id"], + ["comment_person", "PK_f3a5d50a9812f4f4a03921497f7", "id"], + ["communication", "PK_392407b9e9100bee1a64e26cd5d", "id"], + ["config", "PK_d0ee79a681413d50b0a4f98cf7b", "id"], + ["deal", "PK_9ce1c24acace60f6d7dc7a7189e", "id"], + ["deal_activity", "PK_2dfd3d9d6571f3b52b4f2a2f7b6", "id"], + ["deal_district", "PK_f166ddb3884b15af861573d036b", "id"], + ["deal_language", "PK_f41aff0608b977c9b2a70e484ff", "id"], + ["deal_skill", "PK_791b2c5c3ae4db034fef8999c35", "id"], + ["deal_timeslot", "PK_4259966a4f5f9cc66b7179d94e2", "id"], + ["district", "PK_ee5cb6fd5223164bb87ea693f1e", "id"], + ["district_postcode", "PK_0e1774a1cd62d250b9564ab0904", "id"], + ["document", "PK_e57d3357f83f3cdc0acffc3d777", "id"], + ["event_n4d", "PK_e0df3ada625ad10e0b3fbeaec47", "id"], + ["event_translation", "PK_d5739128be79554fecf75dca107", "id"], + ["field_translation", "PK_9159080b83585be7c50d6a9883e", "id"], + ["language", "PK_cc0a99e710eb3733f6fb42b1d4c", "id"], + ["lead_from", "PK_62c40760ad8725f93fa01345855", "id"], + ["notion_relation", "PK_a9db9fcae0b0476e5142622c0c0", "id"], + ["opportunity", "PK_085fd6d6f4765325e6c16163568", "id"], + ["opportunity_volunteer", "PK_501d2e5de509fa64503be23ae18", "id"], + ["option", "PK_e6090c1c6ad8962eea97abdbe63", "id"], + ["organization", "PK_472c1f99a32def1b0abb219cd67", "id"], + ["person", "PK_5fdaf670315c4b7e70cce85daa3", "id"], + ["post", "PK_be5fda3aac270b134ff9c21cdee", "id"], + ["postcode", "PK_c19bc9f774c1cf019766a35ca4d", "id"], + ["skill", "PK_a0d33334424e64fb78dc3ce7196", "id"], + ["testimonial", "PK_e1aee1c726db2d336480c69f7cb", "id"], + ["timeline", "PK_f841188896cefd9277904ec40b9", "id"], + ["timeslot", "PK_cd8bca557ee1eb5b090b9e63009", "id"], + ["trusted_domain", "PK_b8c4fcc9a45771d9f59e1dd6b1b", "id"], + ['"user"', "PK_cace4a159ff9f2512dd42373760", "id"], + ["volunteer", "PK_76924da1998b3e07025e04c4d3c", "id"], + ]; + + for (const [tbl, name, cols] of pks) { + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.${tbl} ADD CONSTRAINT "${name}" PRIMARY KEY (${cols}); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + } + + // Composite PKs + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.post_person ADD CONSTRAINT "PK_2752a498efcea4ce067c3ced8b6" PRIMARY KEY (post_id, person_id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.post_opportunity ADD CONSTRAINT "PK_83c02a05c6699152a7619ca8de2" PRIMARY KEY (post_id, opportunity_id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + + // ─── UNIQUE CONSTRAINTS ─────────────────────────────────────────────────── + + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.activity ADD CONSTRAINT "UQ_a28a1682ea80f10d1ecc7babaa0" UNIQUE (title); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.address ADD CONSTRAINT "UQ_dc72f107eef6108d4163fae4cd2" UNIQUE (title); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent ADD CONSTRAINT "UQ_c13f74bf3e3d5e4fedf63231881" UNIQUE (title); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.category ADD CONSTRAINT "UQ_9f16dbbf263b0af0f03637fa7b5" UNIQUE (title); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_activity ADD CONSTRAINT "UQ_916f710fb2164dee243ab81e42d" UNIQUE (deal_id, activity_id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_district ADD CONSTRAINT "UQ_bb2d0e17996f1cc2e8c36b8097c" UNIQUE (deal_id, district_id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_skill ADD CONSTRAINT "UQ_cf0a989e58c03a76911ea044fa5" UNIQUE (deal_id, skill_id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_timeslot ADD CONSTRAINT "UQ_dc6a725ff9fddeff3866465fc95" UNIQUE (deal_id, timeslot_id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.document ADD CONSTRAINT "UQ_761a987245fa09ddf48e5aafcf4" UNIQUE (type, volunteer_id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.opportunity_volunteer ADD CONSTRAINT "UQ_ef61e09ab1a57be86168bd83378" UNIQUE (opportunity_id, volunteer_id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.organization ADD CONSTRAINT "UQ_a7c11b94f5aaa12289f67de3f8f" UNIQUE (title); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.skill ADD CONSTRAINT "UQ_5b1131c92af934e7c2a1322ec87" UNIQUE (title); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public."user" ADD CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE (email); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + + // ─── FOREIGN KEYS ──────────────────────────────────────────────────────── + + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.event_n4d ADD CONSTRAINT "FK_019ed6de3369ed99b82ebf1b85c" FOREIGN KEY (language_id) REFERENCES public.language(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent_postcode ADD CONSTRAINT "FK_0c0882d8ac7a24eec11d7bff144" FOREIGN KEY (agent_id) REFERENCES public.agent(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.organization ADD CONSTRAINT "FK_0f31fe3925535afb5462326d7d6" FOREIGN KEY (address_id) REFERENCES public.address(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.event_translation ADD CONSTRAINT "FK_10fabc95d13968a570404f5c516" FOREIGN KEY (language_id) REFERENCES public.language(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent_person ADD CONSTRAINT "FK_1fc545aa66757a901d425e88f0b" FOREIGN KEY (person_id) REFERENCES public.person(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.opportunity ADD CONSTRAINT "FK_20c491a989b6d30143c9487fa3c" FOREIGN KEY (district_id) REFERENCES public.district(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.appreciation ADD CONSTRAINT "FK_29ae22414bad9bb74367b329b00" FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.appreciation ADD CONSTRAINT "FK_2a91e0b949799349a3a87aa220b" FOREIGN KEY (volunteer_id) REFERENCES public.volunteer(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal ADD CONSTRAINT "FK_2e866113f33c2e1a3f9d81a4133" FOREIGN KEY (category_id) REFERENCES public.category(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.post ADD CONSTRAINT "FK_2f1a9ca8908fc8168bc18437f62" FOREIGN KEY (author_id) REFERENCES public.person(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.communication ADD CONSTRAINT "FK_3120e867d4bf41caa7b8984440e" FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_skill ADD CONSTRAINT "FK_407bd56cbfa76cc7d99fb29f01a" FOREIGN KEY (deal_id) REFERENCES public.deal(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.event_translation ADD CONSTRAINT "FK_42df355dff4a2dd4edeb6f9fc66" FOREIGN KEY (eventn4d_id) REFERENCES public.event_n4d(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.opportunity_volunteer ADD CONSTRAINT "FK_4a47cea224192a18c9bb93c07b4" FOREIGN KEY (opportunity_id) REFERENCES public.opportunity(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.volunteer ADD CONSTRAINT "FK_4b1093af5610c75cfca53546c0d" FOREIGN KEY (deal_id) REFERENCES public.deal(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.opportunity ADD CONSTRAINT "FK_4f3dd494438470c347902f276d7" FOREIGN KEY (submitted_by_person_id) REFERENCES public.person(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_district ADD CONSTRAINT "FK_56838b99011fdc67a0b38169026" FOREIGN KEY (district_id) REFERENCES public.district(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.activity ADD CONSTRAINT "FK_5d3d888450207667a286922f945" FOREIGN KEY (category_id) REFERENCES public.category(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.post ADD CONSTRAINT "FK_60618b67a1e1043df6380caa22f" FOREIGN KEY (agent_id) REFERENCES public.agent(id) ON DELETE SET NULL; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_activity ADD CONSTRAINT "FK_60a29bcf00ea63da27514e8f748" FOREIGN KEY (activity_id) REFERENCES public.activity(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.post_opportunity ADD CONSTRAINT "FK_61a8575d8e7c30b3462fa19f6d0" FOREIGN KEY (opportunity_id) REFERENCES public.opportunity(id) ON UPDATE CASCADE ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.opportunity ADD CONSTRAINT "FK_62f9c6aaa610596f0d5f972e962" FOREIGN KEY (deal_id) REFERENCES public.deal(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent_language ADD CONSTRAINT "FK_655274b2246207a662e3940b0d4" FOREIGN KEY (agent_id) REFERENCES public.agent(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent ADD CONSTRAINT "FK_6b58af875f81124b0cd64dc843a" FOREIGN KEY (district_id) REFERENCES public.district(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.post_person ADD CONSTRAINT "FK_6d5bd5330c6c6d717fb4128190f" FOREIGN KEY (post_id) REFERENCES public.post(id) ON UPDATE CASCADE ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.opportunity ADD CONSTRAINT "FK_72b2a2637cc12f5d5b71bb3236e" FOREIGN KEY (agent_id) REFERENCES public.agent(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.communication ADD CONSTRAINT "FK_73aa11f1d453a65ba3cebda85fb" FOREIGN KEY (opportunity_id) REFERENCES public.opportunity(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.district_postcode ADD CONSTRAINT "FK_7b72f500f43de90b1a7d6e60ead" FOREIGN KEY (postcode_id) REFERENCES public.postcode(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent ADD CONSTRAINT "FK_7b8b0514632bffdf8d13afbc9de" FOREIGN KEY (address_id) REFERENCES public.address(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.activity_log ADD CONSTRAINT "FK_7f5f4c33b2fbdc19b1f8460b5f0" FOREIGN KEY (opportunity_volunteer_id) REFERENCES public.opportunity_volunteer(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.field_translation ADD CONSTRAINT "FK_804ca3b0c276af3e8b593664f06" FOREIGN KEY (language_id) REFERENCES public.language(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.comment_person ADD CONSTRAINT "FK_81d4ae770167ca1c3196b3c3b52" FOREIGN KEY (person_id) REFERENCES public.person(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_timeslot ADD CONSTRAINT "FK_8a6ceb170e7eb33cadfa425a455" FOREIGN KEY (timeslot_id) REFERENCES public.timeslot(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_skill ADD CONSTRAINT "FK_8d6f46ceb923a37da3891ace9ac" FOREIGN KEY (skill_id) REFERENCES public.skill(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent ADD CONSTRAINT "FK_92b5f704c0b5e65fb0698240744" FOREIGN KEY (organization_id) REFERENCES public.organization(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_language ADD CONSTRAINT "FK_9e1f3159d0361d8f7131f3db643" FOREIGN KEY (language_id) REFERENCES public.language(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.comment_person ADD CONSTRAINT "FK_a32624ba70e3dc1462329a6323e" FOREIGN KEY (comment_id) REFERENCES public.comment(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.accompanying ADD CONSTRAINT "FK_a48f002dffae26a9642e2f7cefb" FOREIGN KEY (postcode_id) REFERENCES public.postcode(id) ON DELETE SET NULL; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public."user" ADD CONSTRAINT "FK_a4cee7e601d219733b064431fba" FOREIGN KEY (person_id) REFERENCES public.person(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal ADD CONSTRAINT "FK_b709f61f789979d2087bfc41768" FOREIGN KEY (postcode_id) REFERENCES public.postcode(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_timeslot ADD CONSTRAINT "FK_b8ec1df4dc435355a2863782b81" FOREIGN KEY (deal_id) REFERENCES public.deal(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_district ADD CONSTRAINT "FK_b9f762e06a46859f822e66e11d1" FOREIGN KEY (deal_id) REFERENCES public.deal(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.comment ADD CONSTRAINT "FK_bbfe153fa60aa06483ed35ff4a7" FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.opportunity_volunteer ADD CONSTRAINT "FK_c0f508b980be4be0b244a5471a1" FOREIGN KEY (volunteer_id) REFERENCES public.volunteer(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.post_person ADD CONSTRAINT "FK_c2ec8325a53c239442778bd029d" FOREIGN KEY (person_id) REFERENCES public.person(id) ON UPDATE CASCADE ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent_language ADD CONSTRAINT "FK_c58ded607e284db17d03e9eb20b" FOREIGN KEY (language_id) REFERENCES public.language(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.testimonial ADD CONSTRAINT "FK_c6bdc688fecc9e338d0c4018c4c" FOREIGN KEY (language_id) REFERENCES public.language(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_activity ADD CONSTRAINT "FK_c8b28c14786d76527d121126259" FOREIGN KEY (deal_id) REFERENCES public.deal(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.person ADD CONSTRAINT "FK_cd587348ca3fec07931de208299" FOREIGN KEY (address_id) REFERENCES public.address(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.appreciation ADD CONSTRAINT "FK_ce5266cf486c563f4e2c8babe4c" FOREIGN KEY (opportunity_id) REFERENCES public.opportunity(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.district_postcode ADD CONSTRAINT "FK_d3b662cb01cdb6f22c7097e0b33" FOREIGN KEY (district_id) REFERENCES public.district(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.opportunity ADD CONSTRAINT "FK_dad52d4b309d2e04b9663fbb36d" FOREIGN KEY (contact_person_id) REFERENCES public.person(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.post_opportunity ADD CONSTRAINT "FK_dfcd3c6cd9a6db4700cd5ebe6c0" FOREIGN KEY (post_id) REFERENCES public.post(id) ON UPDATE CASCADE ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.document ADD CONSTRAINT "FK_e1d6fe65e869e2858d0acfef925" FOREIGN KEY (volunteer_id) REFERENCES public.volunteer(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.address ADD CONSTRAINT "FK_e1d98846fd3dcdea8e3f267e7eb" FOREIGN KEY (postcode_id) REFERENCES public.postcode(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.opportunity ADD CONSTRAINT "FK_e82fe25e9f9cbe9edbb6cba8c19" FOREIGN KEY (accompanying_id) REFERENCES public.accompanying(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.communication ADD CONSTRAINT "FK_e8f1435f58864dd4b55c47d1468" FOREIGN KEY (agent_id) REFERENCES public.agent(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.organization ADD CONSTRAINT "FK_e94553ff34338a3882ed305a74d" FOREIGN KEY (person_id) REFERENCES public.person(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent_postcode ADD CONSTRAINT "FK_ec2a5aa17ef1f489815ee8cefdf" FOREIGN KEY (postcode_id) REFERENCES public.postcode(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.comment ADD CONSTRAINT "FK_f02c3b7cc4d58ca622a90d682a0" FOREIGN KEY (language_id) REFERENCES public.language(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.deal_language ADD CONSTRAINT "FK_f65d3334e061a3490b70b0b4eb9" FOREIGN KEY (deal_id) REFERENCES public.deal(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.testimonial ADD CONSTRAINT "FK_f7086d54eec10b450adee1be7b9" FOREIGN KEY (person_id) REFERENCES public.person(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.communication ADD CONSTRAINT "FK_fb198b1a72d3e9fa9e006c824c0" FOREIGN KEY (volunteer_id) REFERENCES public.volunteer(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.volunteer ADD CONSTRAINT "FK_fc40d3eada517c3c9315e0c9e51" FOREIGN KEY (person_id) REFERENCES public.person(id); EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + await queryRunner.query( + `DO $$ BEGIN ALTER TABLE ONLY public.agent_person ADD CONSTRAINT "FK_ffb35bb3606febae68541916709" FOREIGN KEY (agent_id) REFERENCES public.agent(id) ON DELETE CASCADE; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ); + + // ─── INDEXES ───────────────────────────────────────────────────────────── + + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_0913ee135f62eccb90f53047ad" ON public.comment_person USING btree (comment_id, person_id)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_3998cf87b5faec4e52018bddbd" ON public.trusted_domain USING btree (domain)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_61a8575d8e7c30b3462fa19f6d" ON public.post_opportunity USING btree (opportunity_id)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_68fcd3be0d9a16a5b6c8371133" ON public.timeline USING btree (target_entity_type, target_entity_id, "timestamp")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_6d5bd5330c6c6d717fb4128190" ON public.post_person USING btree (post_id)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_81d4ae770167ca1c3196b3c3b5" ON public.comment_person USING btree (person_id)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_8c3af29493287693cdd82d99c1" ON public.comment USING btree (entity_type, entity_id, language_id)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_c2ec8325a53c239442778bd029" ON public.post_person USING btree (person_id)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_cd9cbf582b713498a61c626c2d" ON public.field_translation USING btree (language_id, entity_type, entity_id, field_name)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_dfcd3c6cd9a6db4700cd5ebe6c" ON public.post_opportunity USING btree (post_id)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_f15c4b743ddfba08409b99f797" ON public.agent_person USING btree (agent_id, person_id, role)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_person_search_gin" ON public.person USING gin (to_tsvector('simple'::regconfig, (((((COALESCE(first_name, ''::character varying))::text || ' '::text) || (COALESCE(last_name, ''::character varying))::text) || ' '::text) || (COALESCE(email, ''::character varying))::text)))`, + ); + + // ─── MARK ALL PRIOR MIGRATIONS AS ALREADY RUN ──────────────────────────── + // Idempotent: WHERE NOT EXISTS prevents duplicate inserts on existing DBs. + + const migrations: [number, string][] = [ + [1763036250587, "UpdateVolunteerListMv1763036250587"], + [1763636303849, "AddEventEntity1763636303849"], + [1765976202270, "AddCityToAddressEntity1765976202270"], + [1766003754834, "AddPreferredCommToVolunteerEntity1766003754834"], + [1766352158945, "ChangeVolunteerPreferredCommToArray1766352158945"], + [1767357208920, "AddDocumentEntity1767357208920"], + [1767816732428, "AddCommunicationEntity1767816732428"], + [1768772092135, "AddAppreciationEntity1768772092135"], + [1769012055276, "CatchTsJan211769012055276"], + [1769183878914, "AddVolunteerReturnDate1769183878914"], + [1769606450859, "SyncAndUpdateOppVolM2m1769606450859"], + [1770126767598, "UpdateOppProfile1770126767598"], + [1770226586693, "UpdateOppTypeEnum1770226586693"], + [1770655462205, "AddAccomponyingEntity1770655462205"], + [1770713139121, "AddConfigKey1770713139121"], + [1771086827549, "UpdateAgentEntity1771086827549"], + [1771201168124, "UpdateAgentEntity21771201168124"], + [1771505596306, "UpdateAgentEntity31771505596306"], + [1771858452728, "UpdateAgentEntity41771858452728"], + [1772005888557, "UpdateOppProfileProfLang1772005888557"], + [1772019993181, "UpdateCommentEtity1772019993181"], + [1772052519214, "UpdatePersonEntity1772052519214"], + [1772545971802, "MoveAccompanyingToOpportunity1772545971802"], + [1772609767417, "CheckIfInSync1772609767417"], + [1773268076415, "UpdateAgentEntity6AgentPerson1773268076415"], + [1773413991868, "UpdatePersonEntityAddLandline1773413991868"], + [1773423834140, "UpdateAgentEntity71773423834140"], + [1773438090614, "UpdateCommunicationEntity1773438090614"], + [1773746534596, "UpdateCommentEtity21773746534596"], + [1774135672526, "AddNotionRelationEntity1774135672526"], + [1776035229092, "AddVolIndeces1776035229092"], + [1776321570996, "UpdateOppOrphanage1776321570996"], + [1776699111911, "UpdateAgentEntity1776699111911"], + [1776935007602, "AddNidToAgentVolOpp1776935007602"], + [1777230024863, "UpdateVolOppAgentSetNids1777230024863"], + [1777284365646, "AddInactiveToOppStatus1777284365646"], + [1777382465431, "RemoveNotionRelUnique1777382465431"], + [1777462201044, "UpdateOppEntityAddMissingValuesToEnums1777462201044"], + [1777481403775, "LoadStatusesOppVol1777481403775"], + [1777884798498, "AddCommunicationsFromNotion1777884798498"], + [1777973964566, "ConsolidateLegacyDistricts1777973964566"], + [1778069345200, "AddDistrictToOpp1778069345200"], + [1778503017097, "AddVolunteerDocStatusDates1778503017097"], + [1778578280034, "AddPostcodeToAccompanying1778578280034"], + [1778579676871, "UpdateAccompanyingPostcodeFkSetNull1778579676871"], + [1778864931379, "SetAgentDistrictFromPlz1778864931379"], + [1779186682996, "FixTranslationOfLanguageTitles1779186682996"], + [1779737272182, "SyncEntitiesDdl1779737272182"], + [1779738026660, "AddUniqueOppVol1779738026660"], + [1779739039589, "SeedMatchedOppVol1779739039589"], + [1779809730247, "FixMatchedOppVolUseNid1779809730247"], + [1779900000000, "AddContactPersonToOpportunity1779900000000"], + [1780167679271, "AddSubmittedByPersonToOpportunity1780167679271"], + [1780169787517, "BackfillOpportunitySubmittedByPerson1780169787517"], + [ + 1780182685021, + "BackfillOpportunitySubmittedByPerson4FieldBlobs1780182685021", + ], + [1780430062141, "AddCommentPersonM2m1780430062141"], + [1780430801611, "AddUniqueAgentPersonRole1780430801611"], + [1780432006040, "AddIndexCommentPersonPersonId1780432006040"], + [1780485146870, "AddDealActivity1780485146870"], + [1780487570188, "AddUniqueDealActivity1780487570188"], + [1780488457577, "AddDealSkill1780488457577"], + [1780492917105, "FlattenDealLanguageDropProfile1780492917105"], + [1780512794386, "FlattenDealTimeslotDropTime1780512794386"], + [1780515101270, "AddDealDistrict1780515101270"], + [1780516402900, "DropLocation1780516402900"], + [1781004181023, "AddStatusToAgentPerson1781004181023"], + [1781277697986, "AddTrustedDomain1781277697986"], + [1781600000000, "BackfillOpportunityStatusMatch1781600000000"], + [1782222001656, "AddActivityLog1782222001656"], + [1782245067240, "AddReadAtToCommentPerson1782245067240"], + [1782897313819, "AddShareContactToVolunteer1782897313819"], + [ + 1782903516653, + "CommunicationNullableFksAndOpportunityRelation1782903516653", + ], + [1783342906122, "AddPostEntity1783342906122"], + ]; + + for (const [ts, migName] of migrations) { + await queryRunner.query( + `INSERT INTO public.be_migrations (timestamp, name) SELECT ${ts}, '${migName}' WHERE NOT EXISTS (SELECT 1 FROM public.be_migrations WHERE timestamp = ${ts} AND name = '${migName}')`, + ); + } + } + + public async down(_queryRunner: QueryRunner): Promise { + // genesis is irreversible — removing it would require recreating the original + // scrambled-dump bootstrap, which we explicitly replaced + } +} diff --git a/src/data/migrations/1782222001656-add-activity-log.ts b/src/data/migrations/1782222001656-add-activity-log.ts new file mode 100644 index 00000000..9eeb9fc4 --- /dev/null +++ b/src/data/migrations/1782222001656-add-activity-log.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddActivityLog1782222001656 implements MigrationInterface { + name = "AddActivityLog1782222001656"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "opportunity" DROP CONSTRAINT "opportunity_contact_person_id_fkey"`, + ); + await queryRunner.query( + `CREATE TABLE "activity_log" ("id" SERIAL NOT NULL, "date" date NOT NULL, "hours" numeric(5,2) NOT NULL, "opportunity_volunteer_id" integer NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_067d761e2956b77b14e534fd6f1" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "activity_log" ADD CONSTRAINT "FK_7f5f4c33b2fbdc19b1f8460b5f0" FOREIGN KEY ("opportunity_volunteer_id") REFERENCES "opportunity_volunteer"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "opportunity" ADD CONSTRAINT "FK_dad52d4b309d2e04b9663fbb36d" FOREIGN KEY ("contact_person_id") REFERENCES "person"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "opportunity" DROP CONSTRAINT "FK_dad52d4b309d2e04b9663fbb36d"`, + ); + await queryRunner.query( + `ALTER TABLE "activity_log" DROP CONSTRAINT "FK_7f5f4c33b2fbdc19b1f8460b5f0"`, + ); + await queryRunner.query(`DROP TABLE "activity_log"`); + await queryRunner.query( + `ALTER TABLE "opportunity" ADD CONSTRAINT "opportunity_contact_person_id_fkey" FOREIGN KEY ("contact_person_id") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/data/migrations/1782245067240-add-read-at-to-comment-person.ts b/src/data/migrations/1782245067240-add-read-at-to-comment-person.ts new file mode 100644 index 00000000..80b7fcac --- /dev/null +++ b/src/data/migrations/1782245067240-add-read-at-to-comment-person.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddReadAtToCommentPerson1782245067240 + implements MigrationInterface +{ + name = "AddReadAtToCommentPerson1782245067240"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "comment_person" ADD "read_at" TIMESTAMP`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "comment_person" DROP COLUMN "read_at"`, + ); + } +} diff --git a/src/data/migrations/1782897313819-add-share-contact-to-volunteer.ts b/src/data/migrations/1782897313819-add-share-contact-to-volunteer.ts new file mode 100644 index 00000000..06ad13e7 --- /dev/null +++ b/src/data/migrations/1782897313819-add-share-contact-to-volunteer.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddShareContactToVolunteer1782897313819 + implements MigrationInterface +{ + name = "AddShareContactToVolunteer1782897313819"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "volunteer" ADD "share_contact" boolean NOT NULL DEFAULT true`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "volunteer" DROP COLUMN "share_contact"`, + ); + } +} diff --git a/src/data/migrations/1782903516653-communication-nullable-fks-and-opportunity-relation.ts b/src/data/migrations/1782903516653-communication-nullable-fks-and-opportunity-relation.ts new file mode 100644 index 00000000..6c78e482 --- /dev/null +++ b/src/data/migrations/1782903516653-communication-nullable-fks-and-opportunity-relation.ts @@ -0,0 +1,86 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CommunicationNullableFksAndOpportunityRelation1782903516653 + implements MigrationInterface +{ + name = "CommunicationNullableFksAndOpportunityRelation1782903516653"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "communication" DROP CONSTRAINT "CHK_31faa29cb0aff6ef55be2b2146"`, + ); + await queryRunner.query( + `ALTER TABLE "communication" ADD "opportunity_id" integer`, + ); + await queryRunner.query( + `ALTER TYPE "public"."communication_communication_type_enum" RENAME TO "communication_communication_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."communication_communication_type_enum" AS ENUM('briefed', 'first-inquiry-sent', 'opportunity-list-sent', 'status-update', 'post-match-followup', 'matched', 'accompanying-not-found', 'accompanying-matched', 'opportunity-updated', 'opportunity-confirmation')`, + ); + await queryRunner.query( + `ALTER TABLE "communication" ALTER COLUMN "communication_type" TYPE "public"."communication_communication_type_enum" USING "communication_type"::"text"::"public"."communication_communication_type_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."communication_communication_type_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "communication" ALTER COLUMN "date" SET DEFAULT now()`, + ); + await queryRunner.query(`ALTER TABLE "communication" ADD CONSTRAINT "CHK_0ea4b79edeac5eaa19c54735c7" CHECK ( + (CASE WHEN "volunteer_id" IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN "agent_id" IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN "opportunity_id" IS NOT NULL THEN 1 ELSE 0 END) >= 1 +)`); + await queryRunner.query( + `ALTER TABLE "communication" ADD CONSTRAINT "FK_73aa11f1d453a65ba3cebda85fb" FOREIGN KEY ("opportunity_id") REFERENCES "opportunity"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove rows that cannot survive the rollback: + // (a) rows whose communication_type is one of the 5 new enum values added in up() — + // the USING cast to the old enum would throw 'invalid input value for enum'. + // (b) rows where only opportunity_id is set (volunteer_id = NULL, agent_id = NULL) — + // after opportunity_id is dropped, those rows would violate the restored CHECK (sum = 1). + await queryRunner.query( + `DELETE FROM "communication" WHERE "communication_type" IN ('matched','accompanying-not-found','accompanying-matched','opportunity-updated','opportunity-confirmation')`, + ); + await queryRunner.query( + `DELETE FROM "communication" WHERE "volunteer_id" IS NULL AND "agent_id" IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE "communication" DROP CONSTRAINT "FK_73aa11f1d453a65ba3cebda85fb"`, + ); + await queryRunner.query( + `ALTER TABLE "communication" DROP CONSTRAINT "CHK_0ea4b79edeac5eaa19c54735c7"`, + ); + await queryRunner.query( + `ALTER TABLE "communication" ALTER COLUMN "date" DROP DEFAULT`, + ); + await queryRunner.query( + `CREATE TYPE "public"."communication_communication_type_enum_old" AS ENUM('briefed', 'first-inquiry-sent', 'opportunity-list-sent', 'status-update', 'post-match-followup')`, + ); + await queryRunner.query( + `ALTER TABLE "communication" ALTER COLUMN "communication_type" TYPE "public"."communication_communication_type_enum_old" USING "communication_type"::"text"::"public"."communication_communication_type_enum_old"`, + ); + await queryRunner.query( + `DROP TYPE "public"."communication_communication_type_enum"`, + ); + await queryRunner.query( + `ALTER TYPE "public"."communication_communication_type_enum_old" RENAME TO "communication_communication_type_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "communication" DROP COLUMN "opportunity_id"`, + ); + await queryRunner.query(`ALTER TABLE "communication" ADD CONSTRAINT "CHK_31faa29cb0aff6ef55be2b2146" CHECK ((( +CASE + WHEN (volunteer_id IS NOT NULL) THEN 1 + ELSE 0 +END + +CASE + WHEN (agent_id IS NOT NULL) THEN 1 + ELSE 0 +END) = 1))`); + } +} diff --git a/src/data/migrations/1783342906122-add-post-entity.ts b/src/data/migrations/1783342906122-add-post-entity.ts new file mode 100644 index 00000000..53fabdd4 --- /dev/null +++ b/src/data/migrations/1783342906122-add-post-entity.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddPostEntity1783342906122 implements MigrationInterface { + name = "AddPostEntity1783342906122"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "post" ("id" SERIAL NOT NULL, "text" text NOT NULL, "author_id" integer NOT NULL, "agent_id" integer, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_be5fda3aac270b134ff9c21cdee" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "post_person" ("post_id" integer NOT NULL, "person_id" integer NOT NULL, CONSTRAINT "PK_2752a498efcea4ce067c3ced8b6" PRIMARY KEY ("post_id", "person_id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6d5bd5330c6c6d717fb4128190" ON "post_person" ("post_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c2ec8325a53c239442778bd029" ON "post_person" ("person_id") `, + ); + await queryRunner.query( + `CREATE TABLE "post_opportunity" ("post_id" integer NOT NULL, "opportunity_id" integer NOT NULL, CONSTRAINT "PK_83c02a05c6699152a7619ca8de2" PRIMARY KEY ("post_id", "opportunity_id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_dfcd3c6cd9a6db4700cd5ebe6c" ON "post_opportunity" ("post_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_61a8575d8e7c30b3462fa19f6d" ON "post_opportunity" ("opportunity_id") `, + ); + await queryRunner.query( + `ALTER TABLE "post" ADD CONSTRAINT "FK_2f1a9ca8908fc8168bc18437f62" FOREIGN KEY ("author_id") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "post" ADD CONSTRAINT "FK_60618b67a1e1043df6380caa22f" FOREIGN KEY ("agent_id") REFERENCES "agent"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "post_person" ADD CONSTRAINT "FK_6d5bd5330c6c6d717fb4128190f" FOREIGN KEY ("post_id") REFERENCES "post"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "post_person" ADD CONSTRAINT "FK_c2ec8325a53c239442778bd029d" FOREIGN KEY ("person_id") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "post_opportunity" ADD CONSTRAINT "FK_dfcd3c6cd9a6db4700cd5ebe6c0" FOREIGN KEY ("post_id") REFERENCES "post"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "post_opportunity" ADD CONSTRAINT "FK_61a8575d8e7c30b3462fa19f6d0" FOREIGN KEY ("opportunity_id") REFERENCES "opportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "post_opportunity" DROP CONSTRAINT "FK_61a8575d8e7c30b3462fa19f6d0"`, + ); + await queryRunner.query( + `ALTER TABLE "post_opportunity" DROP CONSTRAINT "FK_dfcd3c6cd9a6db4700cd5ebe6c0"`, + ); + await queryRunner.query( + `ALTER TABLE "post_person" DROP CONSTRAINT "FK_c2ec8325a53c239442778bd029d"`, + ); + await queryRunner.query( + `ALTER TABLE "post_person" DROP CONSTRAINT "FK_6d5bd5330c6c6d717fb4128190f"`, + ); + await queryRunner.query( + `ALTER TABLE "post" DROP CONSTRAINT "FK_60618b67a1e1043df6380caa22f"`, + ); + await queryRunner.query( + `ALTER TABLE "post" DROP CONSTRAINT "FK_2f1a9ca8908fc8168bc18437f62"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_61a8575d8e7c30b3462fa19f6d"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_dfcd3c6cd9a6db4700cd5ebe6c"`, + ); + await queryRunner.query(`DROP TABLE "post_opportunity"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_c2ec8325a53c239442778bd029"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_6d5bd5330c6c6d717fb4128190"`, + ); + await queryRunner.query(`DROP TABLE "post_person"`); + await queryRunner.query(`DROP TABLE "post"`); + } +} diff --git a/src/data/seeds/fixtures/nid-agents.json b/src/data/seeds/fixtures/nid-agents.json new file mode 100644 index 00000000..8819a9aa --- /dev/null +++ b/src/data/seeds/fixtures/nid-agents.json @@ -0,0 +1,164 @@ +[ + { + "nid": "RAC-001", + "title": "Musterhaus Berlin gGmbH", + "about": "A refugee accommodation center in Mitte offering German language support and tutoring.", + "website": "https://musterhaus-berlin.example.com", + "type": "GU2", + "trustLevel": "High", + "statusSearch": "Searching", + "statusEngagement": "Active", + "services": ["refugee-accommodation", "tutoring"], + "postcodes": ["10115", "10117"], + "person": [ + { + "firstName": "Maria", + "middleName": "", + "lastName": "Müller", + "email": "maria.mueller@example.com", + "phone": "+4930111001", + "address": null, + "role": "volunteer-coordinator" + } + ], + "opportunityNids": ["OPP-001", "OPP-002"] + }, + { + "nid": "RAC-002", + "title": "Unterkunft Testweg e.V.", + "about": "Family-focused accommodation center in Neukölln with childcare and youth programs.", + "website": "https://testweg-neukoelln.example.com", + "type": "GU1", + "trustLevel": "Low", + "statusSearch": "Filled", + "statusEngagement": "Active", + "services": ["childcare", "youth"], + "postcodes": ["12047", "12053"], + "person": [ + { + "firstName": "Hans", + "middleName": "", + "lastName": "Weber", + "email": "hans.weber@example.com", + "phone": "+4930111002", + "address": null, + "role": "social-worker" + } + ], + "opportunityNids": ["OPP-003", "OPP-004"] + }, + { + "nid": "RAC-003", + "title": "Beispiel-Wohnheim Kreuzberg gGmbH", + "about": "Translation and language support center in Friedrichshain-Kreuzberg.", + "website": "https://wohnheim-kreuzberg.example.com", + "type": "AE", + "trustLevel": "High", + "statusSearch": "Searching", + "statusEngagement": "Active", + "services": ["tutoring", "consultation"], + "postcodes": ["10997", "10999"], + "person": [ + { + "firstName": "Anna", + "middleName": "", + "lastName": "Schmidt", + "email": "anna.schmidt@example.com", + "phone": "+4930111003", + "address": null, + "role": "volunteer-coordinator" + } + ], + "opportunityNids": ["OPP-005", "OPP-006"] + }, + { + "nid": "RAC-004", + "title": "Probeheim Charlottenburg e.V.", + "about": "Arts, wellness and sport programs for residents in Charlottenburg-Wilmersdorf.", + "website": "https://probeheim-charlottenburg.example.com", + "type": "GU3", + "trustLevel": "agent-unknown", + "statusSearch": "agent-volunteers-found", + "statusEngagement": "Unresponsive", + "services": ["welfare", "sport"], + "postcodes": ["10627", "10629"], + "person": [ + { + "firstName": "Thomas", + "middleName": "", + "lastName": "Becker", + "email": "thomas.becker@example.com", + "phone": "+4930111004", + "address": null, + "role": "manager" + } + ], + "opportunityNids": ["OPP-007", "OPP-008"] + }, + { + "nid": "RAC-005", + "title": "Demo-Unterkunft Lichtenberg", + "about": "Employment and integration support center in Lichtenberg.", + "website": "https://demo-lichtenberg.example.com", + "type": "GU2+", + "trustLevel": "High", + "statusSearch": "Searching", + "statusEngagement": "Active", + "services": ["refugee-accommodation", "job-coaching"], + "postcodes": ["10317", "10319"], + "person": [ + { + "firstName": "Lisa", + "middleName": "", + "lastName": "Hoffmann", + "email": "lisa.hoffmann@example.com", + "phone": "+4930111005", + "address": null, + "role": "project-coordinator" + } + ], + "opportunityNids": ["OPP-009", "OPP-010"] + }, + { + "nid": "RAC-006", + "title": "Musterhaus Pankow", + "about": "Small family accommodation center in Pankow.", + "website": "", + "type": "GU1", + "trustLevel": "Low", + "statusSearch": "agent-not-needed", + "statusEngagement": "agent-inactive", + "services": ["childcare"], + "postcodes": ["10405"], + "person": [], + "opportunityNids": ["OPP-011"] + }, + { + "nid": "RAC-007", + "title": "Testheim Spandau", + "about": "New accommodation center in Spandau looking for accompanying volunteers.", + "website": "", + "type": "NU", + "trustLevel": "agent-unknown", + "statusSearch": "Searching", + "statusEngagement": "agent-new", + "services": ["voluntary-support"], + "postcodes": ["13591"], + "person": [], + "opportunityNids": ["OPP-012"] + }, + { + "nid": "RAC-008", + "title": "Beispiel Steglitz gGmbH", + "about": "Welfare and consultation center in Steglitz-Zehlendorf, recently onboarded.", + "website": "https://beispiel-steglitz.example.com", + "type": "ASOG", + "trustLevel": "High", + "statusSearch": "Searching", + "statusEngagement": "Active", + "services": ["welfare", "consultation"], + "postcodes": ["12169"], + "person": [], + "opportunityNids": [] + } +] diff --git a/src/data/seeds/fixtures/nid-opportunities.json b/src/data/seeds/fixtures/nid-opportunities.json new file mode 100644 index 00000000..99099688 --- /dev/null +++ b/src/data/seeds/fixtures/nid-opportunities.json @@ -0,0 +1,430 @@ +[ + { + "nid": "OPP-001", + "status": "active", + "title": "German Language Café Facilitation", + "timestamp": "2024-02-01T10:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Help facilitate our weekly German language café for residents. Bring games, conversation topics, or just your enthusiasm.", + "infoConfidential": "", + "numberVolunteers": "2", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "10115", + "profile": { + "info": "Weekly language café, every Monday and Wednesday morning.", + "activities": ["Language café"], + "skills": [], + "languages": [ + ["Arabic", "intermediate"], + ["Turkish", "beginner"] + ] + }, + "time": { + "timeslots": [ + { "day": "Monday", "daytime": ["morning"], "info": "", "start": "" }, + { + "day": "Wednesday", + "daytime": ["morning"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Mitte"] } + }, + "volunteerNids": [ + ["VOL-001", "opp-active"], + ["VOL-002", "opp-matched"] + ] + }, + { + "nid": "OPP-002", + "status": "active", + "title": "Homework Support for School Children", + "timestamp": "2024-02-05T10:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Assist school-age children with homework two afternoons per week. Basic German and patience required.", + "infoConfidential": "", + "numberVolunteers": "2", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "10115", + "profile": { + "info": "Homework support, Tuesday and Thursday afternoons.", + "activities": ["Tutoring"], + "skills": ["Teaching"], + "languages": [ + ["German", "fluent"], + ["Arabic", "beginner"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Tuesday", + "daytime": ["afternoon"], + "info": "", + "start": "" + }, + { + "day": "Thursday", + "daytime": ["afternoon"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Mitte"] } + }, + "volunteerNids": [["VOL-003", "opp-active"]] + }, + { + "nid": "OPP-003", + "status": "active", + "title": "Daycare Support at Accommodation", + "timestamp": "2024-02-10T09:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Support our childcare team on Monday and Friday mornings. Help care for toddlers while parents attend language classes.", + "infoConfidential": "", + "numberVolunteers": "2", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "12047", + "profile": { + "info": "Childcare support, Monday and Friday mornings.", + "activities": ["Daycare"], + "skills": [], + "languages": [ + ["German", "intermediate"], + ["Arabic", "beginner"] + ] + }, + "time": { + "timeslots": [ + { "day": "Monday", "daytime": ["morning"], "info": "", "start": "" }, + { "day": "Friday", "daytime": ["morning"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Neukölln"] } + }, + "volunteerNids": [ + ["VOL-004", "opp-active"], + ["VOL-005", "opp-matched"] + ] + }, + { + "nid": "OPP-004", + "status": "active", + "title": "Youth Sports Activities", + "timestamp": "2024-02-12T09:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Lead Saturday morning sports activities for teenagers. Football, basketball, or any sport you enjoy.", + "infoConfidential": "", + "numberVolunteers": "1", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "12047", + "profile": { + "info": "Youth sports, Saturday mornings.", + "activities": ["Sports"], + "skills": ["Teaching"], + "languages": [["German", "intermediate"]] + }, + "time": { + "timeslots": [ + { "day": "Saturday", "daytime": ["morning"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Neukölln"] } + }, + "volunteerNids": [["VOL-006", "opp-active"]] + }, + { + "nid": "OPP-005", + "status": "active", + "title": "Document Translation Support", + "timestamp": "2024-02-15T10:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Help residents understand official letters and documents. Arabic–German translation most needed.", + "infoConfidential": "", + "numberVolunteers": "2", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "10997", + "profile": { + "info": "Translation support, Wednesday and Thursday mornings.", + "activities": ["Translation"], + "skills": [], + "languages": [ + ["Arabic", "native"], + ["German", "fluent"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Wednesday", + "daytime": ["morning"], + "info": "", + "start": "" + }, + { "day": "Thursday", "daytime": ["morning"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Friedrichshain-Kreuzberg"] } + }, + "volunteerNids": [ + ["VOL-007", "opp-active"], + ["VOL-008", "opp-matched"] + ] + }, + { + "nid": "OPP-006", + "status": "active", + "title": "Assistance with German Forms", + "timestamp": "2024-02-18T10:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Guide residents through filling out German administrative forms (Anmeldung, health insurance, etc.).", + "infoConfidential": "", + "numberVolunteers": "1", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "10997", + "profile": { + "info": "Form assistance, Monday afternoons.", + "activities": ["Fillout German forms"], + "skills": [], + "languages": [ + ["German", "fluent"], + ["Turkish", "intermediate"] + ] + }, + "time": { + "timeslots": [ + { "day": "Monday", "daytime": ["afternoon"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Friedrichshain-Kreuzberg"] } + }, + "volunteerNids": [["VOL-009", "opp-active"]] + }, + { + "nid": "OPP-007", + "status": "active", + "title": "Arts and Crafts Workshop", + "timestamp": "2024-03-01T10:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Run a weekly arts and crafts session for residents of all ages. Materials provided.", + "infoConfidential": "", + "numberVolunteers": "1", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "10627", + "profile": { + "info": "Arts workshop, Saturday afternoons.", + "activities": ["Arts"], + "skills": ["Drawing", "Painting"], + "languages": [ + ["German", "intermediate"], + ["English", "intermediate"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Saturday", + "daytime": ["afternoon"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Charlottenburg-Wilmersdorf"] } + }, + "volunteerNids": [["VOL-010", "opp-matched"]] + }, + { + "nid": "OPP-008", + "status": "active", + "title": "Medical Appointment Accompaniment", + "timestamp": "2024-03-05T09:00:00.000Z", + "type": "accompanying", + "translationType": "deutsche", + "info": "Accompany a resident to a medical appointment and assist with German–Arabic interpretation.", + "infoConfidential": "Appointment at Charité outpatient clinic.", + "numberVolunteers": "1", + "agent": null, + "accompanying": { + "address": "Charitéplatz 1, 10117 Berlin", + "name": "Placeholder Patient", + "phone": "+4930450050", + "date": "2024-04-10T09:00:00.000Z", + "languageToTranslate": "German" + }, + "deal": { + "type": "opportunity", + "postcode": "10627", + "profile": { + "info": "One-off medical accompanying appointment.", + "activities": ["Accompanying to government appointments"], + "skills": [], + "languages": [ + ["Arabic", "fluent"], + ["German", "intermediate"] + ] + }, + "time": { "timeslots": [] }, + "location": { "districts": ["Charlottenburg-Wilmersdorf"] } + }, + "volunteerNids": [["VOL-011", "opp-matched"]] + }, + { + "nid": "OPP-009", + "status": "active", + "title": "Job Application Support", + "timestamp": "2024-03-10T10:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Help residents write CVs and cover letters, and prepare for job interviews. Tuesday and Thursday evenings.", + "infoConfidential": "", + "numberVolunteers": "1", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "10317", + "profile": { + "info": "Job coaching, Tuesday and Thursday evenings.", + "activities": ["Mentorship"], + "skills": ["Teaching"], + "languages": [ + ["German", "fluent"], + ["English", "intermediate"] + ] + }, + "time": { + "timeslots": [ + { "day": "Tuesday", "daytime": ["evening"], "info": "", "start": "" }, + { "day": "Thursday", "daytime": ["evening"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Lichtenberg"] } + }, + "volunteerNids": [["VOL-012", "opp-active"]] + }, + { + "nid": "OPP-010", + "status": "active", + "title": "Sorting Donated Clothing", + "timestamp": "2024-03-12T09:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Help sort and organise clothing donations every Saturday morning. No special skills needed.", + "infoConfidential": "", + "numberVolunteers": "2", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "10317", + "profile": { + "info": "Clothing sorting, Saturday mornings.", + "activities": ["Clothing Sorting"], + "skills": [], + "languages": [["German", "beginner"]] + }, + "time": { + "timeslots": [ + { "day": "Saturday", "daytime": ["morning"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Lichtenberg"] } + }, + "volunteerNids": [["VOL-013", "opp-matched"]] + }, + { + "nid": "OPP-011", + "status": "pending", + "title": "Reading Books for Children", + "timestamp": "2024-03-20T10:00:00.000Z", + "type": "regular", + "translationType": "noTranslation", + "info": "Read picture books and short stories to children aged 3–8 every Wednesday afternoon.", + "infoConfidential": "", + "numberVolunteers": "1", + "agent": null, + "deal": { + "type": "opportunity", + "postcode": "10405", + "profile": { + "info": "Children's reading, Wednesday afternoons.", + "activities": ["Reading"], + "skills": [], + "languages": [ + ["German", "fluent"], + ["Arabic", "beginner"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Wednesday", + "daytime": ["afternoon"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Pankow"] } + }, + "volunteerNids": [["VOL-014", "opp-pending"]] + }, + { + "nid": "OPP-012", + "status": "pending", + "title": "Administrative Appointment Accompaniment", + "timestamp": "2024-03-25T09:00:00.000Z", + "type": "accompanying", + "translationType": "deutsche", + "info": "Accompany a resident to a Jobcenter appointment and assist with German–Persian translation.", + "infoConfidential": "Jobcenter Spandau appointment.", + "numberVolunteers": "1", + "agent": null, + "accompanying": { + "address": "Galenstraße 14, 13587 Berlin", + "name": "Placeholder Resident", + "phone": "+493033001", + "date": "2024-04-20T10:00:00.000Z", + "languageToTranslate": "German" + }, + "deal": { + "type": "opportunity", + "postcode": "13591", + "profile": { + "info": "One-off Jobcenter accompanying appointment.", + "activities": ["Accompanying to government appointments"], + "skills": [], + "languages": [ + ["German", "fluent"], + ["Persian", "native"] + ] + }, + "time": { "timeslots": [] }, + "location": { "districts": ["Spandau"] } + }, + "volunteerNids": [["VOL-015", "opp-pending"]] + } +] diff --git a/src/data/seeds/fixtures/nid-volunteers.json b/src/data/seeds/fixtures/nid-volunteers.json new file mode 100644 index 00000000..1b797005 --- /dev/null +++ b/src/data/seeds/fixtures/nid-volunteers.json @@ -0,0 +1,641 @@ +[ + { + "nid": "VOL-001", + "timestamp": "2023-09-01T10:00:00.000Z", + "status": "Active regular", + "statusEngagement": "Active", + "accompanying": false, + "statusCGC": "Yes", + "statusVaccination": "Yes", + "infoAbout": "I am a language teacher interested in supporting language exchange programs.", + "infoExperience": "Taught German as a foreign language for 3 years.", + "person": { + "firstName": "Felix", + "middleName": "", + "lastName": "Neumann", + "email": "felix.neumann@example.com", + "phone": "+4930201001", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10115", + "profile": { + "info": "Available Monday and Wednesday mornings for language café.", + "activities": ["Language café", "Tutoring"], + "skills": ["Teaching"], + "languages": [ + ["German", "native"], + ["Arabic", "intermediate"], + ["English", "fluent"] + ] + }, + "time": { + "timeslots": [ + { "day": "Monday", "daytime": ["morning"], "info": "", "start": "" }, + { + "day": "Wednesday", + "daytime": ["morning", "afternoon"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Mitte"] } + } + }, + { + "nid": "VOL-002", + "timestamp": "2024-01-10T10:00:00.000Z", + "status": "New", + "statusEngagement": "New", + "accompanying": false, + "statusCGC": "", + "statusVaccination": "", + "infoAbout": "I moved to Berlin six months ago and want to give back to the community.", + "infoExperience": "No formal experience, but enthusiastic learner.", + "person": { + "firstName": "Sophie", + "middleName": "", + "lastName": "Braun", + "email": "sophie.braun@example.com", + "phone": "+4930201002", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10119", + "profile": { + "info": "Available Monday mornings and Friday afternoons.", + "activities": ["Language café", "Translation"], + "skills": [], + "languages": [ + ["German", "native"], + ["Arabic", "beginner"] + ] + }, + "time": { + "timeslots": [ + { "day": "Monday", "daytime": ["morning"], "info": "", "start": "" }, + { "day": "Friday", "daytime": ["afternoon"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Mitte", "Pankow"] } + } + }, + { + "nid": "VOL-003", + "timestamp": "2023-11-05T10:00:00.000Z", + "status": "Active regular", + "statusEngagement": "Active", + "accompanying": false, + "statusCGC": "Yes", + "statusVaccination": "Yes", + "infoAbout": "Retired school teacher, happy to help with homework and tutoring.", + "infoExperience": "30 years as a primary school teacher in Berlin.", + "person": { + "firstName": "Klaus", + "middleName": "", + "lastName": "Fischer", + "email": "klaus.fischer@example.com", + "phone": "+4930201003", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10115", + "profile": { + "info": "Tuesday and Thursday afternoons for homework support.", + "activities": ["Tutoring"], + "skills": ["Teaching"], + "languages": [ + ["German", "native"], + ["Arabic", "beginner"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Tuesday", + "daytime": ["afternoon"], + "info": "", + "start": "" + }, + { + "day": "Thursday", + "daytime": ["afternoon"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Mitte"] } + } + }, + { + "nid": "VOL-004", + "timestamp": "2023-10-15T10:00:00.000Z", + "status": "Active accompany", + "statusEngagement": "Active", + "accompanying": true, + "statusCGC": "Yes", + "statusVaccination": "Yes", + "infoAbout": "Parent of two young children, experienced in childcare settings.", + "infoExperience": "2 years volunteering at a Kita in Neukölln.", + "person": { + "firstName": "Laura", + "middleName": "", + "lastName": "Wagner", + "email": "laura.wagner@example.com", + "phone": "+4930201004", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "12047", + "profile": { + "info": "Monday and Friday mornings for childcare.", + "activities": ["Daycare", "Playing"], + "skills": [], + "languages": [ + ["German", "native"], + ["Arabic", "beginner"] + ] + }, + "time": { + "timeslots": [ + { "day": "Monday", "daytime": ["morning"], "info": "", "start": "" }, + { "day": "Friday", "daytime": ["morning"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Neukölln"] } + } + }, + { + "nid": "VOL-005", + "timestamp": "2024-01-20T10:00:00.000Z", + "status": "Matched", + "statusEngagement": "Available", + "accompanying": false, + "statusCGC": "", + "statusVaccination": "", + "infoAbout": "Student studying early childhood education.", + "infoExperience": "Internship at a Berlin Kita, six months.", + "person": { + "firstName": "Emma", + "middleName": "", + "lastName": "Koch", + "email": "emma.koch@example.com", + "phone": "+4930201005", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "12047", + "profile": { + "info": "Monday mornings and Wednesday mornings for childcare.", + "activities": ["Daycare", "Language café"], + "skills": [], + "languages": [ + ["German", "native"], + ["Turkish", "intermediate"] + ] + }, + "time": { + "timeslots": [ + { "day": "Monday", "daytime": ["morning"], "info": "", "start": "" }, + { + "day": "Wednesday", + "daytime": ["morning"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Neukölln"] } + } + }, + { + "nid": "VOL-006", + "timestamp": "2023-12-01T10:00:00.000Z", + "status": "Active regular", + "statusEngagement": "Active", + "accompanying": false, + "statusCGC": "Yes", + "statusVaccination": "Yes", + "infoAbout": "Football coach and fitness instructor looking to volunteer on weekends.", + "infoExperience": "Coaching youth football for 5 years in Neukölln.", + "person": { + "firstName": "Marco", + "middleName": "", + "lastName": "Richter", + "email": "marco.richter@example.com", + "phone": "+4930201006", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "12053", + "profile": { + "info": "Saturday mornings for sports.", + "activities": ["Sports", "Activities-men"], + "skills": ["Teaching"], + "languages": [ + ["German", "native"], + ["English", "intermediate"] + ] + }, + "time": { + "timeslots": [ + { "day": "Saturday", "daytime": ["morning"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Neukölln"] } + } + }, + { + "nid": "VOL-007", + "timestamp": "2023-09-20T10:00:00.000Z", + "status": "Active regular", + "statusEngagement": "Active", + "accompanying": false, + "statusCGC": "Yes", + "statusVaccination": "Yes", + "infoAbout": "Professional translator (Arabic–German) supporting refugees in legal and medical contexts.", + "infoExperience": "4 years as a freelance translator and interpreter.", + "person": { + "firstName": "Nour", + "middleName": "", + "lastName": "Hassan", + "email": "nour.hassan@example.com", + "phone": "+4930201007", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10997", + "profile": { + "info": "Wednesday and Thursday mornings for translation support.", + "activities": ["Translation", "Fillout German forms"], + "skills": [], + "languages": [ + ["Arabic", "native"], + ["German", "fluent"], + ["English", "advanced"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Wednesday", + "daytime": ["morning"], + "info": "", + "start": "" + }, + { "day": "Thursday", "daytime": ["morning"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Friedrichshain-Kreuzberg"] } + } + }, + { + "nid": "VOL-008", + "timestamp": "2024-01-25T10:00:00.000Z", + "status": "New", + "statusEngagement": "Available", + "accompanying": false, + "statusCGC": "", + "statusVaccination": "", + "infoAbout": "Arabic speaker with good German, eager to help with translation.", + "infoExperience": "Helped friends and family with official German correspondence.", + "person": { + "firstName": "Yusuf", + "middleName": "", + "lastName": "Al-Rashid", + "email": "yusuf.alrashid@example.com", + "phone": "+4930201008", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10997", + "profile": { + "info": "Wednesday mornings for translation.", + "activities": ["Translation", "Fillout German forms"], + "skills": [], + "languages": [ + ["Arabic", "native"], + ["German", "intermediate"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Wednesday", + "daytime": ["morning"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Friedrichshain-Kreuzberg"] } + } + }, + { + "nid": "VOL-009", + "timestamp": "2023-10-10T10:00:00.000Z", + "status": "Active regular", + "statusEngagement": "Active", + "accompanying": false, + "statusCGC": "Yes", + "statusVaccination": "Yes", + "infoAbout": "Administrative professional helping newcomers navigate German bureaucracy.", + "infoExperience": "1 year at a refugee advice centre in Kreuzberg.", + "person": { + "firstName": "Dilnoza", + "middleName": "", + "lastName": "Yıldız", + "email": "dilnoza.yildiz@example.com", + "phone": "+4930201009", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10999", + "profile": { + "info": "Monday and Wednesday afternoons for form assistance.", + "activities": ["Fillout German forms"], + "skills": [], + "languages": [ + ["German", "fluent"], + ["Turkish", "native"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Monday", + "daytime": ["afternoon"], + "info": "", + "start": "" + }, + { + "day": "Wednesday", + "daytime": ["afternoon"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Friedrichshain-Kreuzberg", "Neukölln"] } + } + }, + { + "nid": "VOL-010", + "timestamp": "2024-02-01T10:00:00.000Z", + "status": "Matched", + "statusEngagement": "Available", + "accompanying": false, + "statusCGC": "", + "statusVaccination": "", + "infoAbout": "Graphic designer and art teacher looking to run creative workshops.", + "infoExperience": "Ran monthly art workshops at a community centre for 2 years.", + "person": { + "firstName": "Petra", + "middleName": "", + "lastName": "Zimmermann", + "email": "petra.zimmermann@example.com", + "phone": "+4930201010", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10627", + "profile": { + "info": "Saturday afternoons for arts and crafts workshops.", + "activities": ["Arts"], + "skills": ["Drawing", "Painting"], + "languages": [ + ["German", "native"], + ["English", "fluent"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Saturday", + "daytime": ["afternoon"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Charlottenburg-Wilmersdorf"] } + } + }, + { + "nid": "VOL-011", + "timestamp": "2023-11-15T10:00:00.000Z", + "status": "Active accompany", + "statusEngagement": "Active", + "accompanying": true, + "statusCGC": "Yes", + "statusVaccination": "Yes", + "infoAbout": "Medical student with Arabic background, available to accompany patients to appointments.", + "infoExperience": "Informal interpreting at a Berlin hospital for 6 months.", + "person": { + "firstName": "Karim", + "middleName": "", + "lastName": "Mansour", + "email": "karim.mansour@example.com", + "phone": "+4930201011", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10629", + "profile": { + "info": "Available Monday and Wednesday mornings for accompanying.", + "activities": [ + "Accompanying to government appointments", + "Accompanying" + ], + "skills": [], + "languages": [ + ["Arabic", "native"], + ["German", "advanced"] + ] + }, + "time": { + "timeslots": [ + { "day": "Monday", "daytime": ["morning"], "info": "", "start": "" }, + { + "day": "Wednesday", + "daytime": ["morning"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Charlottenburg-Wilmersdorf"] } + } + }, + { + "nid": "VOL-012", + "timestamp": "2023-12-10T10:00:00.000Z", + "status": "Active regular", + "statusEngagement": "Active", + "accompanying": false, + "statusCGC": "Yes", + "statusVaccination": "Yes", + "infoAbout": "HR professional offering career coaching and CV writing support.", + "infoExperience": "3 years as a career counsellor, experience coaching refugees.", + "person": { + "firstName": "Jana", + "middleName": "", + "lastName": "Schneider", + "email": "jana.schneider@example.com", + "phone": "+4930201012", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10317", + "profile": { + "info": "Tuesday and Thursday evenings for job application support.", + "activities": ["Mentorship"], + "skills": ["Teaching"], + "languages": [ + ["German", "native"], + ["English", "advanced"] + ] + }, + "time": { + "timeslots": [ + { "day": "Tuesday", "daytime": ["evening"], "info": "", "start": "" }, + { "day": "Thursday", "daytime": ["evening"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Lichtenberg"] } + } + }, + { + "nid": "VOL-013", + "timestamp": "2024-02-05T10:00:00.000Z", + "status": "Matched", + "statusEngagement": "Available", + "accompanying": false, + "statusCGC": "", + "statusVaccination": "", + "infoAbout": "Looking for a practical weekend volunteering opportunity.", + "infoExperience": "No formal experience.", + "person": { + "firstName": "Ozan", + "middleName": "", + "lastName": "Demir", + "email": "ozan.demir@example.com", + "phone": "+4930201013", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10319", + "profile": { + "info": "Saturday mornings for practical tasks like clothing sorting.", + "activities": ["Clothing Sorting"], + "skills": [], + "languages": [ + ["German", "intermediate"], + ["Turkish", "native"] + ] + }, + "time": { + "timeslots": [ + { "day": "Saturday", "daytime": ["morning"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Lichtenberg", "Neukölln"] } + } + }, + { + "nid": "VOL-014", + "timestamp": "2024-03-01T10:00:00.000Z", + "status": "New", + "statusEngagement": "New", + "accompanying": false, + "statusCGC": "", + "statusVaccination": "", + "infoAbout": "Primary school librarian who loves reading to children.", + "infoExperience": "Reading groups at a local library, 1 year.", + "person": { + "firstName": "Sabine", + "middleName": "", + "lastName": "Krause", + "email": "sabine.krause@example.com", + "phone": "+4930201014", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "10405", + "profile": { + "info": "Wednesday afternoons for children's reading sessions.", + "activities": ["Reading"], + "skills": [], + "languages": [ + ["German", "native"], + ["Arabic", "beginner"] + ] + }, + "time": { + "timeslots": [ + { + "day": "Wednesday", + "daytime": ["afternoon"], + "info": "", + "start": "" + } + ] + }, + "location": { "districts": ["Pankow"] } + } + }, + { + "nid": "VOL-015", + "timestamp": "2024-03-10T10:00:00.000Z", + "status": "New", + "statusEngagement": "New", + "accompanying": true, + "statusCGC": "", + "statusVaccination": "", + "infoAbout": "Iranian-German bilingual available to accompany residents to official appointments.", + "infoExperience": "Helped family members navigate German administration.", + "person": { + "firstName": "Shirin", + "middleName": "", + "lastName": "Golestan", + "email": "shirin.golestan@example.com", + "phone": "+4930201015", + "address": null + }, + "deal": { + "type": "volunteer", + "postcode": "13591", + "profile": { + "info": "Monday mornings for government appointment accompanying.", + "activities": ["Accompanying to government appointments"], + "skills": [], + "languages": [ + ["German", "fluent"], + ["Persian", "native"] + ] + }, + "time": { + "timeslots": [ + { "day": "Monday", "daytime": ["morning"], "info": "", "start": "" } + ] + }, + "location": { "districts": ["Spandau"] } + } + } +] diff --git a/src/data/seeds/fixtures/test.ts b/src/data/seeds/fixtures/test.ts new file mode 100644 index 00000000..e1ad9556 --- /dev/null +++ b/src/data/seeds/fixtures/test.ts @@ -0,0 +1,462 @@ +import { + AgentRoleType, + DocumentStatusType, + LangProficiency, + OpportunityType, + OpportunityVolunteerStatusType, + UserRole, + VolunteerStateEngagementType, +} from "need4deed-sdk"; +import { DataSource } from "typeorm"; +import logger from "../../../logger"; +import Deal from "../../entity/deal.entity"; +import Address from "../../entity/location/address.entity"; +import District from "../../entity/location/district.entity"; +import Postcode from "../../entity/location/postcode.entity"; +import AgentPerson from "../../entity/m2m/agent-person"; +import AgentPostcode from "../../entity/m2m/agent-postcode"; +import DealActivity from "../../entity/m2m/deal-activity"; +import DealDistrict from "../../entity/m2m/deal-district"; +import DealLanguage from "../../entity/m2m/deal-language"; +import DealSkill from "../../entity/m2m/deal-skill"; +import DealTimeslot from "../../entity/m2m/deal-timeslot"; +import OpportunityVolunteer from "../../entity/m2m/opportunity-volunteer"; +import Accompanying from "../../entity/opportunity/accompanying.entity"; +import Agent from "../../entity/opportunity/agent.entity"; +import Opportunity from "../../entity/opportunity/opportunity.entity"; +import Organization from "../../entity/organization.entity"; +import Person from "../../entity/person.entity"; +import Activity from "../../entity/profile/activity.entity"; +import Language from "../../entity/profile/language.entity"; +import Skill from "../../entity/profile/skill.entity"; +import Timeslot from "../../entity/time/timeslot.entity"; +import User from "../../entity/user.entity"; +import Volunteer from "../../entity/volunteer/volunteer.entity"; +import { DealType } from "../../types"; +import { getRepository, hashPassword } from "../../utils"; + +export const TEST_EMAILS = { + admin: "admin@test.need4deed.org", + coordinator: "coordinator@test.need4deed.org", + agentUser: "agent@test.need4deed.org", + volunteerUser: "volunteer@test.need4deed.org", +}; + +function makeEventTimeslot( + date: Date, + startHour: number, + endHour: number, +): Partial { + const start = new Date(date); + start.setHours(startHour, 0, 0, 0); + const end = new Date(date); + end.setHours(endHour, 0, 0, 0); + return { start, end }; +} + +function daysFromNow(n: number): Date { + const d = new Date(); + d.setDate(d.getDate() + n); + return d; +} + +async function makeDeal( + dataSource: DataSource, + type: DealType, + postcodeId: number, + opts: { + activityIds?: number[]; + skillIds?: number[]; + languages?: Array<{ languageId: number; proficiency: LangProficiency }>; + timeslotIds?: number[]; + districtIds?: number[]; + } = {}, +): Promise { + const dealRepo = getRepository(dataSource, Deal); + const deal = await dealRepo.save(new Deal({ type, postcodeId })); + + if (opts.activityIds?.length) { + const repo = getRepository(dataSource, DealActivity); + for (const activityId of opts.activityIds) { + await repo.save(new DealActivity({ dealId: deal.id, activityId })); + } + } + if (opts.skillIds?.length) { + const repo = getRepository(dataSource, DealSkill); + for (const skillId of opts.skillIds) { + await repo.save(new DealSkill({ dealId: deal.id, skillId })); + } + } + if (opts.languages?.length) { + const repo = getRepository(dataSource, DealLanguage); + for (const { languageId, proficiency } of opts.languages) { + await repo.save( + new DealLanguage({ dealId: deal.id, languageId, proficiency }), + ); + } + } + if (opts.timeslotIds?.length) { + const repo = getRepository(dataSource, DealTimeslot); + for (const timeslotId of opts.timeslotIds) { + await repo.save(new DealTimeslot({ dealId: deal.id, timeslotId })); + } + } + if (opts.districtIds?.length) { + const repo = getRepository(dataSource, DealDistrict); + for (const districtId of opts.districtIds) { + await repo.save(new DealDistrict({ dealId: deal.id, districtId })); + } + } + + return deal; +} + +export async function seedTestFixtures(dataSource: DataSource): Promise { + const userRepo = getRepository(dataSource, User); + + const existing = await userRepo.findOneBy({ email: TEST_EMAILS.admin }); + if (existing) { + logger.info("Skipping test fixtures — already seeded."); + return; + } + + // --- reference lookups --- + const postcodeRepo = getRepository(dataSource, Postcode); + const languageRepo = getRepository(dataSource, Language); + const activityRepo = getRepository(dataSource, Activity); + const skillRepo = getRepository(dataSource, Skill); + const districtRepo = getRepository(dataSource, District); + const timeslotRepo = getRepository(dataSource, Timeslot); + + const [pc10115, pc12043, pc13347] = await Promise.all([ + postcodeRepo.findOneByOrFail({ value: "10115" }), + postcodeRepo.findOneByOrFail({ value: "12043" }), + postcodeRepo.findOneByOrFail({ value: "13347" }), + ]); + const [langDe, langEn, langAr] = await Promise.all([ + languageRepo.findOneByOrFail({ isoCode: "de" }), + languageRepo.findOneByOrFail({ isoCode: "en" }), + languageRepo.findOneByOrFail({ isoCode: "ar" }), + ]); + const [actLangCafe, actTranslation, actDaycare, actSports] = + await Promise.all([ + activityRepo.findOneByOrFail({ title: "Language café" }), + activityRepo.findOneByOrFail({ title: "Translation" }), + activityRepo.findOneByOrFail({ title: "Daycare" }), + activityRepo.findOneByOrFail({ title: "Sports" }), + ]); + const [skillDrawing, skillPainting] = await Promise.all([ + skillRepo.findOneByOrFail({ title: "Drawing" }), + skillRepo.findOneByOrFail({ title: "Painting" }), + ]); + const [distMitte, distNeukoelln] = await Promise.all([ + districtRepo.findOneByOrFail({ title: "Mitte" }), + districtRepo.findOneByOrFail({ title: "Neukölln" }), + ]); + const [tsMoMorning, tsWeeMorning, tsFriNoon, tsTueAfternoon] = + await Promise.all([ + timeslotRepo.findOneByOrFail({ info: "MO-morning" }), + timeslotRepo.findOneByOrFail({ info: "WE-morning" }), + timeslotRepo.findOneByOrFail({ info: "FR-noon" }), + timeslotRepo.findOneByOrFail({ info: "TU-afternoon" }), + ]); + + // --- users --- + const pwHash = await hashPassword("test_password"); + + const adminPerson = new Person({ firstName: "Test", lastName: "Admin" }); + const userAdmin = new User({ + email: TEST_EMAILS.admin, + password: pwHash, + role: UserRole.ADMIN, + isActive: true, + person: adminPerson, + }); + + const coordinatorPerson = new Person({ + firstName: "Test", + lastName: "Coordinator", + }); + const userCoordinator = new User({ + email: TEST_EMAILS.coordinator, + password: pwHash, + role: UserRole.COORDINATOR, + isActive: true, + person: coordinatorPerson, + }); + + const agentContactPerson = new Person({ + firstName: "Test", + lastName: "AgentContact", + email: TEST_EMAILS.agentUser, + }); + const userAgentUser = new User({ + email: TEST_EMAILS.agentUser, + password: pwHash, + role: UserRole.USER, + isActive: true, + person: agentContactPerson, + }); + + const vol1Person = new Person({ + firstName: "Test", + lastName: "Volunteer", + email: TEST_EMAILS.volunteerUser, + }); + const userVolunteerUser = new User({ + email: TEST_EMAILS.volunteerUser, + password: pwHash, + role: UserRole.USER, + isActive: true, + person: vol1Person, + }); + + await userRepo.save([ + userAdmin, + userCoordinator, + userAgentUser, + userVolunteerUser, + ]); + + // reload persons with their generated IDs (cascaded by User save) + const personAgentContact = userAgentUser.person; + const personVol1 = userVolunteerUser.person; + + // --- agents --- + const addressRepo = getRepository(dataSource, Address); + const orgRepo = getRepository(dataSource, Organization); + const agentRepo = getRepository(dataSource, Agent); + const agentPersonRepo = getRepository(dataSource, AgentPerson); + const agentPostcodeRepo = getRepository(dataSource, AgentPostcode); + const personRepo = getRepository(dataSource, Person); + + // Agent 1 + const address1 = await addressRepo.save( + new Address({ title: "Test RAB HQ", postcodeId: pc10115.id }), + ); + const org1 = await orgRepo.save( + new Organization({ + title: "Test RAB Org", + addressId: address1.id, + personId: personAgentContact.id, + }), + ); + const agent1 = await agentRepo.save( + new Agent({ title: "Test RAB", organizationId: org1.id }), + ); + await agentPersonRepo.save( + new AgentPerson({ + agentId: agent1.id, + personId: personAgentContact.id, + role: AgentRoleType.VOLUNTEER_COORDINATOR, + }), + ); + await agentPostcodeRepo.save( + new AgentPostcode({ agentId: agent1.id, postcodeId: pc10115.id }), + ); + + // Agent 2 + const agent2ContactPerson = await personRepo.save( + new Person({ firstName: "Test", lastName: "Agent2Contact" }), + ); + const address2 = await addressRepo.save( + new Address({ title: "Test WiB HQ", postcodeId: pc12043.id }), + ); + const org2 = await orgRepo.save( + new Organization({ + title: "Test WiB Org", + addressId: address2.id, + personId: agent2ContactPerson.id, + }), + ); + const agent2 = await agentRepo.save( + new Agent({ title: "Test WiB", organizationId: org2.id }), + ); + await agentPersonRepo.save( + new AgentPerson({ + agentId: agent2.id, + personId: agent2ContactPerson.id, + role: AgentRoleType.OTHER, + }), + ); + await agentPostcodeRepo.save( + new AgentPostcode({ agentId: agent2.id, postcodeId: pc12043.id }), + ); + + // --- volunteers --- + const volunteerRepo = getRepository(dataSource, Volunteer); + + const deal1 = await makeDeal(dataSource, DealType.VOLUNTEER, pc10115.id, { + activityIds: [actLangCafe.id], + skillIds: [skillDrawing.id], + languages: [ + { languageId: langDe.id, proficiency: LangProficiency.ADVANCED }, + { languageId: langEn.id, proficiency: LangProficiency.INTERMEDIATE }, + ], + timeslotIds: [tsMoMorning.id, tsWeeMorning.id], + districtIds: [distMitte.id], + }); + const volunteer1 = await volunteerRepo.save( + new Volunteer({ + person: personVol1, + deal: deal1, + statusEngagement: VolunteerStateEngagementType.ACTIVE, + statusCGC: DocumentStatusType.YES, + statusVaccination: DocumentStatusType.YES, + infoAbout: "Test volunteer 1 — enjoys language exchange.", + infoExperience: "Tutoring 2 years.", + }), + ); + + const personVol2 = await personRepo.save( + new Person({ firstName: "Maria", lastName: "Schmidt" }), + ); + const deal2 = await makeDeal(dataSource, DealType.VOLUNTEER, pc12043.id, { + activityIds: [actDaycare.id], + skillIds: [skillPainting.id], + languages: [{ languageId: langDe.id, proficiency: LangProficiency.NATIVE }], + timeslotIds: [tsTueAfternoon.id], + districtIds: [distNeukoelln.id], + }); + const volunteer2 = await volunteerRepo.save( + new Volunteer({ person: personVol2, deal: deal2 }), + ); + + const personVol3 = await personRepo.save( + new Person({ firstName: "Ahmet", lastName: "Yilmaz" }), + ); + const deal3 = await makeDeal(dataSource, DealType.VOLUNTEER, pc13347.id, { + activityIds: [actSports.id, actTranslation.id], + languages: [ + { languageId: langAr.id, proficiency: LangProficiency.NATIVE }, + { languageId: langDe.id, proficiency: LangProficiency.BEGINNER }, + ], + timeslotIds: [tsFriNoon.id], + districtIds: [distMitte.id], + }); + const volunteer3 = await volunteerRepo.save( + new Volunteer({ person: personVol3, deal: deal3 }), + ); + + // --- opportunities --- + const opportunityRepo = getRepository(dataSource, Opportunity); + const accompanyingRepo = getRepository(dataSource, Accompanying); + + const oppDeal1 = await makeDeal( + dataSource, + DealType.OPPORTUNITY, + pc10115.id, + { + activityIds: [actLangCafe.id], + languages: [ + { languageId: langDe.id, proficiency: LangProficiency.ADVANCED }, + ], + timeslotIds: [tsMoMorning.id, tsWeeMorning.id], + districtIds: [distMitte.id], + }, + ); + const opp1 = await opportunityRepo.save( + new Opportunity({ + title: "Test Language Café", + type: OpportunityType.REGULAR, + agentId: agent1.id, + deal: oppDeal1, + numberVolunteers: 2, + }), + ); + + const oppDeal2 = await makeDeal( + dataSource, + DealType.OPPORTUNITY, + pc10115.id, + { + activityIds: [actSports.id], + timeslotIds: [tsMoMorning.id], + districtIds: [distMitte.id], + }, + ); + const opp2 = await opportunityRepo.save( + new Opportunity({ + title: "Test Sports Event", + type: OpportunityType.EVENTS, + agentId: agent1.id, + deal: oppDeal2, + numberVolunteers: 5, + }), + ); + + const eventDate = daysFromNow(14); + const accompanyingTimeslot = makeEventTimeslot(eventDate, 9, 12); + const accompanying = await accompanyingRepo.save( + new Accompanying({ + address: "Musterstraße 1, Berlin", + name: "Test Appointment", + date: accompanyingTimeslot.start, + postcodeId: pc12043.id, + }), + ); + const oppDeal3 = await makeDeal( + dataSource, + DealType.OPPORTUNITY, + pc12043.id, + { activityIds: [actDaycare.id], districtIds: [distNeukoelln.id] }, + ); + const opp3 = await opportunityRepo.save( + new Opportunity({ + title: "Test Accompanying Appointment", + type: OpportunityType.ACCOMPANYING, + agentId: agent2.id, + deal: oppDeal3, + accompanyingId: accompanying.id, + numberVolunteers: 1, + }), + ); + + const oppDeal4 = await makeDeal( + dataSource, + DealType.OPPORTUNITY, + pc12043.id, + { + activityIds: [actTranslation.id], + languages: [ + { languageId: langAr.id, proficiency: LangProficiency.NATIVE }, + ], + districtIds: [distNeukoelln.id], + }, + ); + await opportunityRepo.save( + new Opportunity({ + title: "Test Translation Support", + type: OpportunityType.REGULAR, + agentId: agent2.id, + deal: oppDeal4, + numberVolunteers: 1, + }), + ); + + // --- matches --- + const ovRepo = getRepository(dataSource, OpportunityVolunteer); + await ovRepo.save( + new OpportunityVolunteer({ + opportunityId: opp1.id, + volunteerId: volunteer1.id, + status: OpportunityVolunteerStatusType.ACTIVE, + }), + ); + await ovRepo.save( + new OpportunityVolunteer({ + opportunityId: opp3.id, + volunteerId: volunteer2.id, + status: OpportunityVolunteerStatusType.PENDING, + }), + ); + await ovRepo.save( + new OpportunityVolunteer({ + opportunityId: opp2.id, + volunteerId: volunteer3.id, + status: OpportunityVolunteerStatusType.MATCHED, + }), + ); + + logger.info("Test fixtures seeded."); +} diff --git a/src/data/seeds/populate/agent-user.seed.ts b/src/data/seeds/populate/agent-user.seed.ts new file mode 100644 index 00000000..19e1a626 --- /dev/null +++ b/src/data/seeds/populate/agent-user.seed.ts @@ -0,0 +1,48 @@ +import { UserRole } from "need4deed-sdk"; +import { DataSource } from "typeorm"; +import { seedAgentsFile } from "../../../config/constants"; +import logger from "../../../logger"; +import Person from "../../entity/person.entity"; +import User from "../../entity/user.entity"; +import { fetchJsonFromUrl, getRepository, hashPassword } from "../../utils"; +import { AgentJSON } from "./types"; + +export async function seedAgentUsers(dataSource: DataSource): Promise { + const agentsJson = (await fetchJsonFromUrl(seedAgentsFile)) as AgentJSON[]; + const userRepository = getRepository(dataSource, User); + const personRepository = getRepository(dataSource, Person); + + const pwHash = await hashPassword("no_password"); + + for (const agentJson of agentsJson ?? []) { + for (const personJson of agentJson.person ?? []) { + const email = personJson.email; + if (!email) { + continue; + } + + const existing = await userRepository.findOne({ where: { email } }); + if (existing) { + continue; + } + + const person = await personRepository.findOne({ where: { email } }); + if (!person) { + logger.warn( + `Person ${email} not found — seedAgents must run before seedAgentUsers.`, + ); + continue; + } + + const user = new User({ + email, + password: pwHash, + role: UserRole.AGENT, + isActive: true, + person, + }); + await userRepository.save(user); + logger.info(`Created agent user: ${email}`); + } + } +} diff --git a/src/data/seeds/agent.seed.ts b/src/data/seeds/populate/agent.seed.ts similarity index 88% rename from src/data/seeds/agent.seed.ts rename to src/data/seeds/populate/agent.seed.ts index f727d045..e3f4a2a8 100644 --- a/src/data/seeds/agent.seed.ts +++ b/src/data/seeds/populate/agent.seed.ts @@ -1,14 +1,13 @@ import { EntityTableName } from "need4deed-sdk"; import { DataSource } from "typeorm"; -import { seedAgentsFile, titleOrphanageAgent } from "../../config"; -import { tryCatch } from "../../services/utils"; -import Postcode from "../entity/location/postcode.entity"; -import AgentPerson from "../entity/m2m/agent-person"; -import AgentPostcode from "../entity/m2m/agent-postcode"; -import NotionRelation from "../entity/notion-relation.entity"; -import Agent from "../entity/opportunity/agent.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { AgentJSON } from "./types"; +import { seedAgentsFile, titleOrphanageAgent } from "../../../config"; +import { tryCatch } from "../../../services/utils"; +import Postcode from "../../entity/location/postcode.entity"; +import AgentPerson from "../../entity/m2m/agent-person"; +import AgentPostcode from "../../entity/m2m/agent-postcode"; +import NotionRelation from "../../entity/notion-relation.entity"; +import Agent from "../../entity/opportunity/agent.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; import { getAgentEngagement, getAgentPersonRole, @@ -18,7 +17,8 @@ import { getAgentType, getCount, getOrCreatePerson, -} from "./utils"; +} from "../utils"; +import { AgentJSON } from "./types"; export async function seedAgents(dataSource: DataSource): Promise { const agentRepository = getRepository(dataSource, Agent); diff --git a/src/data/seeds/dev-parsing.ts b/src/data/seeds/populate/dev-parsing.ts similarity index 73% rename from src/data/seeds/dev-parsing.ts rename to src/data/seeds/populate/dev-parsing.ts index 60ea9bf0..4224bbf7 100644 --- a/src/data/seeds/dev-parsing.ts +++ b/src/data/seeds/populate/dev-parsing.ts @@ -1,8 +1,8 @@ import { DataSource } from "typeorm"; -import { seedVolunteersFile } from "../../config"; -import { fetchJsonFromUrl } from "../utils"; +import { seedVolunteersFile } from "../../../config"; +import { fetchJsonFromUrl } from "../../utils"; +import { createDealTimeslots } from "../utils"; import { VolunteerJSON } from "./types"; -import { createDealTimeslots } from "./utils"; export async function devTime(dataSource: DataSource) { const volunteersJson = (await fetchJsonFromUrl( diff --git a/src/data/seeds/opportunity.seed.ts b/src/data/seeds/populate/opportunity.seed.ts similarity index 89% rename from src/data/seeds/opportunity.seed.ts rename to src/data/seeds/populate/opportunity.seed.ts index be64bb14..164f6682 100644 --- a/src/data/seeds/opportunity.seed.ts +++ b/src/data/seeds/populate/opportunity.seed.ts @@ -4,21 +4,21 @@ import { TranslatedIntoType, } from "need4deed-sdk"; import { DataSource } from "typeorm"; -import { seedOpportunitiesFile } from "../../config/constants"; -import logger from "../../logger"; -import { tryCatch } from "../../services/utils"; -import NotionRelation from "../entity/notion-relation.entity"; -import Accompanying from "../entity/opportunity/accompanying.entity"; -import Opportunity from "../entity/opportunity/opportunity.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { OpportunityJSON } from "./types"; +import { seedOpportunitiesFile } from "../../../config/constants"; +import logger from "../../../logger"; +import { tryCatch } from "../../../services/utils"; +import NotionRelation from "../../entity/notion-relation.entity"; +import Accompanying from "../../entity/opportunity/accompanying.entity"; +import Opportunity from "../../entity/opportunity/opportunity.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; import { createDeal, getCount, getEnumValue, getOrCreateAgent, getToTranslate, -} from "./utils"; +} from "../utils"; +import { OpportunityJSON } from "./types"; export async function seedOpportunities(dataSource: DataSource): Promise { if (!dataSource) { diff --git a/src/data/seeds/types.ts b/src/data/seeds/populate/types.ts similarity index 98% rename from src/data/seeds/types.ts rename to src/data/seeds/populate/types.ts index c04f28c3..f1311ce0 100644 --- a/src/data/seeds/types.ts +++ b/src/data/seeds/populate/types.ts @@ -4,7 +4,7 @@ import { OpportunityType, VolunteerStateType, } from "need4deed-sdk"; -import { DealType } from "../types"; +import { DealType } from "../../types"; export interface ProfileJSON { info: string; diff --git a/src/data/seeds/volunteer.seed.ts b/src/data/seeds/populate/volunteer.seed.ts similarity index 86% rename from src/data/seeds/volunteer.seed.ts rename to src/data/seeds/populate/volunteer.seed.ts index 949a416e..e490c42c 100644 --- a/src/data/seeds/volunteer.seed.ts +++ b/src/data/seeds/populate/volunteer.seed.ts @@ -1,12 +1,11 @@ import { EntityTableName, OpportunityVolunteerStatusType } from "need4deed-sdk"; import { DataSource } from "typeorm"; -import { seedVolunteersFile } from "../../config/constants"; -import logger from "../../logger"; -import OpportunityVolunteer from "../entity/m2m/opportunity-volunteer"; -import NotionRelation from "../entity/notion-relation.entity"; -import Volunteer from "../entity/volunteer/volunteer.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { VolunteerJSON } from "./types"; +import { seedVolunteersFile } from "../../../config/constants"; +import logger from "../../../logger"; +import OpportunityVolunteer from "../../entity/m2m/opportunity-volunteer"; +import NotionRelation from "../../entity/notion-relation.entity"; +import Volunteer from "../../entity/volunteer/volunteer.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; import { createDeal, getCount, @@ -14,7 +13,8 @@ import { getEnumValue, getOrCreatePerson, getVolunteerState, -} from "./utils"; +} from "../utils"; +import { VolunteerJSON } from "./types"; export async function seedVolunteers(dataSource: DataSource): Promise { if (!dataSource) { diff --git a/src/data/seeds/activity.seed.ts b/src/data/seeds/reference/activity.seed.ts similarity index 84% rename from src/data/seeds/activity.seed.ts rename to src/data/seeds/reference/activity.seed.ts index 55f28544..d802f4ed 100644 --- a/src/data/seeds/activity.seed.ts +++ b/src/data/seeds/reference/activity.seed.ts @@ -1,10 +1,10 @@ import { DataSource } from "typeorm"; -import { seedActivityFile } from "../../config/constants"; -import logger from "../../logger"; -import Activity from "../entity/profile/activity.entity"; -import Category from "../entity/profile/category.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { getCount } from "./utils"; +import { seedActivityFile } from "../../../config/constants"; +import logger from "../../../logger"; +import Activity from "../../entity/profile/activity.entity"; +import Category from "../../entity/profile/category.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; +import { getCount } from "../utils"; interface ActivityJSON { id: string; diff --git a/src/data/seeds/category.seed.ts b/src/data/seeds/reference/category.seed.ts similarity index 82% rename from src/data/seeds/category.seed.ts rename to src/data/seeds/reference/category.seed.ts index cc1b0875..4a5ffe00 100644 --- a/src/data/seeds/category.seed.ts +++ b/src/data/seeds/reference/category.seed.ts @@ -1,9 +1,9 @@ import { DataSource } from "typeorm"; -import { seedCategoryFile } from "../../config/constants"; -import logger from "../../logger"; -import Category from "../entity/profile/category.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { getCount } from "./utils"; +import { seedCategoryFile } from "../../../config/constants"; +import logger from "../../../logger"; +import Category from "../../entity/profile/category.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; +import { getCount } from "../utils"; interface CategoryJSON { id: string; diff --git a/src/data/seeds/district.seed.ts b/src/data/seeds/reference/district.seed.ts similarity index 82% rename from src/data/seeds/district.seed.ts rename to src/data/seeds/reference/district.seed.ts index 3b4f8034..f716d081 100644 --- a/src/data/seeds/district.seed.ts +++ b/src/data/seeds/reference/district.seed.ts @@ -1,11 +1,11 @@ import { DataSource } from "typeorm"; -import { seedDistrictFile } from "../../config/constants"; -import logger from "../../logger"; -import District from "../entity/location/district.entity"; -import Postcode from "../entity/location/postcode.entity"; -import DistrictPostcode from "../entity/m2m/district-postcode"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { getCount } from "./utils"; +import { seedDistrictFile } from "../../../config/constants"; +import logger from "../../../logger"; +import District from "../../entity/location/district.entity"; +import Postcode from "../../entity/location/postcode.entity"; +import DistrictPostcode from "../../entity/m2m/district-postcode"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; +import { getCount } from "../utils"; interface DistrictJSON { district: string; diff --git a/src/data/seeds/field_translation.seed.ts b/src/data/seeds/reference/field_translation.seed.ts similarity index 96% rename from src/data/seeds/field_translation.seed.ts rename to src/data/seeds/reference/field_translation.seed.ts index 10a6e0e5..69a9e8bf 100644 --- a/src/data/seeds/field_translation.seed.ts +++ b/src/data/seeds/reference/field_translation.seed.ts @@ -6,16 +6,16 @@ import { seedLanguageInUseFile, seedLeadFromFile, seedSkillFile, -} from "../../config/constants"; -import logger from "../../logger"; -import FieldTranslation from "../entity/field_translation.entity"; -import LeadFrom from "../entity/lead.entity"; -import Activity from "../entity/profile/activity.entity"; -import Category from "../entity/profile/category.entity"; -import Language from "../entity/profile/language.entity"; -import Skill from "../entity/profile/skill.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { getCount } from "./utils"; +} from "../../../config/constants"; +import logger from "../../../logger"; +import FieldTranslation from "../../entity/field_translation.entity"; +import LeadFrom from "../../entity/lead.entity"; +import Activity from "../../entity/profile/activity.entity"; +import Category from "../../entity/profile/category.entity"; +import Language from "../../entity/profile/language.entity"; +import Skill from "../../entity/profile/skill.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; +import { getCount } from "../utils"; const fieldNameTitle = "title"; const fieldNameDescription = "description"; diff --git a/src/data/seeds/reference/index.ts b/src/data/seeds/reference/index.ts new file mode 100644 index 00000000..bdf350a1 --- /dev/null +++ b/src/data/seeds/reference/index.ts @@ -0,0 +1,24 @@ +import { DataSource } from "typeorm"; +import { seedActivity } from "./activity.seed"; +import { seedCategory } from "./category.seed"; +import { seedDistrict } from "./district.seed"; +import { seedFieldTranslation } from "./field_translation.seed"; +import { seedLanguage } from "./language.seed"; +import { seedLeadFrom } from "./lead_from.seed"; +import { seedOptions } from "./option.seed"; +import { seedPostcode } from "./postcode.seed"; +import { seedSkill } from "./skill.seed"; +import { seedTimeslots } from "./timeslot.seed"; + +export async function seedReference(dataSource: DataSource): Promise { + await seedLanguage(dataSource); + await seedCategory(dataSource); + await seedActivity(dataSource); + await seedSkill(dataSource); + await seedLeadFrom(dataSource); + await seedFieldTranslation(dataSource); + await seedPostcode(dataSource); + await seedDistrict(dataSource); + await seedOptions(dataSource); + await seedTimeslots(dataSource); +} diff --git a/src/data/seeds/language.seed.ts b/src/data/seeds/reference/language.seed.ts similarity index 83% rename from src/data/seeds/language.seed.ts rename to src/data/seeds/reference/language.seed.ts index 737d1b3f..33cdf54f 100644 --- a/src/data/seeds/language.seed.ts +++ b/src/data/seeds/reference/language.seed.ts @@ -1,9 +1,9 @@ import { DataSource } from "typeorm"; -import { seedLanguageFile } from "../../config/constants"; -import logger from "../../logger"; -import Language from "../entity/profile/language.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { getCount } from "./utils"; +import { seedLanguageFile } from "../../../config/constants"; +import logger from "../../../logger"; +import Language from "../../entity/profile/language.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; +import { getCount } from "../utils"; interface LanguageJSON { iso_code: string; diff --git a/src/data/seeds/lead_from.seed.ts b/src/data/seeds/reference/lead_from.seed.ts similarity index 82% rename from src/data/seeds/lead_from.seed.ts rename to src/data/seeds/reference/lead_from.seed.ts index a3e62b08..a8f3d45b 100644 --- a/src/data/seeds/lead_from.seed.ts +++ b/src/data/seeds/reference/lead_from.seed.ts @@ -1,9 +1,9 @@ import { DataSource } from "typeorm"; -import { seedLeadFromFile } from "../../config/constants"; -import logger from "../../logger"; -import LeadFrom from "../entity/lead.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { getCount } from "./utils"; +import { seedLeadFromFile } from "../../../config/constants"; +import logger from "../../../logger"; +import LeadFrom from "../../entity/lead.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; +import { getCount } from "../utils"; interface LeadJSON { id: string; diff --git a/src/data/seeds/option.seed.ts b/src/data/seeds/reference/option.seed.ts similarity index 84% rename from src/data/seeds/option.seed.ts rename to src/data/seeds/reference/option.seed.ts index b6c8633d..12048e20 100644 --- a/src/data/seeds/option.seed.ts +++ b/src/data/seeds/reference/option.seed.ts @@ -1,15 +1,15 @@ import { EntityTableName } from "need4deed-sdk"; import { DataSource, In, Repository } from "typeorm"; -import { seedLanguageInUseFile } from "../../config/constants"; -import logger from "../../logger"; -import LeadFrom from "../entity/lead.entity"; -import District from "../entity/location/district.entity"; -import Option from "../entity/option.entity"; -import Activity from "../entity/profile/activity.entity"; -import Language from "../entity/profile/language.entity"; -import Skill from "../entity/profile/skill.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { getCount } from "./utils"; +import { seedLanguageInUseFile } from "../../../config/constants"; +import logger from "../../../logger"; +import LeadFrom from "../../entity/lead.entity"; +import District from "../../entity/location/district.entity"; +import Option from "../../entity/option.entity"; +import Activity from "../../entity/profile/activity.entity"; +import Language from "../../entity/profile/language.entity"; +import Skill from "../../entity/profile/skill.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; +import { getCount } from "../utils"; interface ContentEnDe { en: string; @@ -74,7 +74,6 @@ async function getOptionsForEntity( dataSource, entity as new () => InstanceType, ); - // const itemType = entity.name.toLowerCase() as TranslationEntityType; const itemType = dataSource.getMetadata(entity).tableName as EntityTableName; const items = await entityRepository.find(); if (!items.length) { diff --git a/src/data/seeds/postcode.seed.ts b/src/data/seeds/reference/postcode.seed.ts similarity index 84% rename from src/data/seeds/postcode.seed.ts rename to src/data/seeds/reference/postcode.seed.ts index 77e88c1c..c9cd574c 100644 --- a/src/data/seeds/postcode.seed.ts +++ b/src/data/seeds/reference/postcode.seed.ts @@ -1,9 +1,9 @@ import { DataSource } from "typeorm"; -import { seedPLZFile } from "../../config/constants"; -import logger from "../../logger"; -import Postcode from "../entity/location/postcode.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { getCount } from "./utils"; +import { seedPLZFile } from "../../../config/constants"; +import logger from "../../../logger"; +import Postcode from "../../entity/location/postcode.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; +import { getCount } from "../utils"; interface PLZJSON { plz: string; diff --git a/src/data/seeds/skill.seed.ts b/src/data/seeds/reference/skill.seed.ts similarity index 81% rename from src/data/seeds/skill.seed.ts rename to src/data/seeds/reference/skill.seed.ts index 457970e8..eb8181f6 100644 --- a/src/data/seeds/skill.seed.ts +++ b/src/data/seeds/reference/skill.seed.ts @@ -1,9 +1,9 @@ import { DataSource } from "typeorm"; -import { seedSkillFile } from "../../config/constants"; -import logger from "../../logger"; -import Skill from "../entity/profile/skill.entity"; -import { fetchJsonFromUrl, getRepository } from "../utils"; -import { getCount } from "./utils"; +import { seedSkillFile } from "../../../config/constants"; +import logger from "../../../logger"; +import Skill from "../../entity/profile/skill.entity"; +import { fetchJsonFromUrl, getRepository } from "../../utils"; +import { getCount } from "../utils"; interface SkillJSON { id: string; diff --git a/src/data/seeds/timeslot.seed.ts b/src/data/seeds/reference/timeslot.seed.ts similarity index 90% rename from src/data/seeds/timeslot.seed.ts rename to src/data/seeds/reference/timeslot.seed.ts index f78a24bd..de405906 100644 --- a/src/data/seeds/timeslot.seed.ts +++ b/src/data/seeds/reference/timeslot.seed.ts @@ -1,8 +1,8 @@ import { OccasionalType } from "need4deed-sdk"; import { DataSource } from "typeorm"; -import Timeslot from "../entity/time/timeslot.entity"; -import { getRepository } from "../utils"; -import { getCount } from "./utils"; +import Timeslot from "../../entity/time/timeslot.entity"; +import { getRepository } from "../../utils"; +import { getCount } from "../utils"; export async function seedTimeslots(dataSource: DataSource): Promise { const timeslotRepository = getRepository(dataSource, Timeslot); diff --git a/src/data/seeds/run.ts b/src/data/seeds/run.ts new file mode 100644 index 00000000..05fcc406 --- /dev/null +++ b/src/data/seeds/run.ts @@ -0,0 +1,11 @@ +import { initDatabase } from ".."; +import { dataSource } from "../data-source"; +import { seed } from "./seed"; + +initDatabase() + .then(() => seed(dataSource)) + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/src/data/seeds/seed.ts b/src/data/seeds/seed.ts index 82556e80..2ff5017a 100644 --- a/src/data/seeds/seed.ts +++ b/src/data/seeds/seed.ts @@ -1,37 +1,23 @@ import { DataSource } from "typeorm"; import { check } from ".."; -import { seedActivity } from "./activity.seed"; -import { seedAgents } from "./agent.seed"; -import { seedCategory } from "./category.seed"; -import { devTime } from "./dev-parsing"; -import { seedDistrict } from "./district.seed"; -import { seedFieldTranslation } from "./field_translation.seed"; -import { seedLanguage } from "./language.seed"; -import { seedLeadFrom } from "./lead_from.seed"; -import { seedOpportunities } from "./opportunity.seed"; -import { seedOptions } from "./option.seed"; -import { seedPostcode } from "./postcode.seed"; -import { seedSkill } from "./skill.seed"; -import { seedTimeslots } from "./timeslot.seed"; +import { seedAgentUsers } from "./populate/agent-user.seed"; +import { seedAgents } from "./populate/agent.seed"; +import { devTime } from "./populate/dev-parsing"; +import { seedOpportunities } from "./populate/opportunity.seed"; +import { seedVolunteers } from "./populate/volunteer.seed"; +import { seedReference } from "./reference"; import { seedUser } from "./user.seed"; -import { seedVolunteers } from "./volunteer.seed"; + +export { seedReference } from "./reference"; export async function seed(dataSource: DataSource) { - await seedLanguage(dataSource); - await seedCategory(dataSource); - await seedActivity(dataSource); - await seedSkill(dataSource); - await seedLeadFrom(dataSource); - await seedFieldTranslation(dataSource); - await seedPostcode(dataSource); - await seedDistrict(dataSource); - await seedOptions(dataSource); + await seedReference(dataSource); await seedUser(dataSource); - await seedTimeslots(dataSource); if (check.flag) { devTime(dataSource); } else { await seedAgents(dataSource); + await seedAgentUsers(dataSource); await seedOpportunities(dataSource); await seedVolunteers(dataSource); } diff --git a/src/data/seeds/user.seed.ts b/src/data/seeds/user.seed.ts index 22a38e17..42af76a4 100644 --- a/src/data/seeds/user.seed.ts +++ b/src/data/seeds/user.seed.ts @@ -48,6 +48,34 @@ export async function seedUser(dataSource: DataSource) { person: personCoordinator, }); + const personCoordinator2 = new Person({ + firstName: "Michael", + middleName: "Coordinator", + lastName: "Doe", + }); + + const coordinatorUser2 = new User({ + email: "michael.doe@need4deed.org", + password: await hashPassword("no_password"), + role: UserRole.COORDINATOR, + isActive: true, + person: personCoordinator2, + }); + + const personCoordinator3 = new Person({ + firstName: "Julia", + middleName: "Coordinator", + lastName: "Doe", + }); + + const coordinatorUser3 = new User({ + email: "julia.doe@need4deed.org", + password: await hashPassword("no_password"), + role: UserRole.COORDINATOR, + isActive: true, + person: personCoordinator3, + }); + const postcode12345 = await postcodeRepository.findOne({ where: { value: "12345" }, }); @@ -81,7 +109,13 @@ export async function seedUser(dataSource: DataSource) { }); try { - await userRepository.save([userAdmin, coordinatorUser, userUser]); + await userRepository.save([ + userAdmin, + coordinatorUser, + coordinatorUser2, + coordinatorUser3, + userUser, + ]); } catch (_error) { logger.info("Skipping seeding users as they already seeded."); } diff --git a/src/data/seeds/utils.ts b/src/data/seeds/utils.ts index 92969028..93b5228e 100644 --- a/src/data/seeds/utils.ts +++ b/src/data/seeds/utils.ts @@ -51,7 +51,7 @@ import { PersonJSON, TimeJSON, VolunteerJSON, -} from "./types"; +} from "./populate/types"; const noGenderAvatarUrl = "all_genders_avatar.png"; diff --git a/src/server/index.ts b/src/server/index.ts index 06b4dda0..39982032 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -10,18 +10,23 @@ import logger from "../logger"; import cors, { corsOptions } from "./plugins/cors"; import jwtPlugin from "./plugins/jwt"; import notifyPlugin from "./plugins/notify"; +import schedulerDailyPlugin from "./plugins/scheduler-daily"; +import schedulerHourlyPlugin from "./plugins/scheduler-hourly"; import typeormPlugin from "./plugins/typeorm"; +import activityLogRoutes from "./routes/activity-log.routes"; import agentRoutes from "./routes/agent/agent.routes"; import appreciationRoutes from "./routes/appreciation.routes"; import authRoutes from "./routes/auth"; import commentRoutes from "./routes/comment"; import communicationRoutes from "./routes/communication.routes"; import healthRoutes from "./routes/health"; +import activityLogCollectionRoutes from "./routes/m2m/activity-log.routes"; import m2mOpportunityVolunteerRoutes from "./routes/m2m/opportunity-volunteer.routes"; import opportunityRoutes from "./routes/opportunity/opportunity.routes"; import optionRoutes from "./routes/option"; import organizationRoutes from "./routes/organization.routes"; import personRoutes from "./routes/person.routes"; +import postRoutes from "./routes/post.routes"; import trustedDomainRoutes from "./routes/trusted-domain.routes"; import userRoutes from "./routes/user"; import volunteerRoutes from "./routes/volunteer/volunteer.routes"; @@ -147,12 +152,15 @@ export async function createServer(): Promise { }, }); await fastifyInstance.register(notifyPlugin); + await fastifyInstance.register(schedulerHourlyPlugin); + await fastifyInstance.register(schedulerDailyPlugin); await fastifyInstance.register(healthRoutes, { prefix: RoutePrefix.HEALTH_CHECK, }); await fastifyInstance.register(authRoutes, { prefix: RoutePrefix.AUTH }); await fastifyInstance.register(userRoutes, { prefix: RoutePrefix.USER }); await fastifyInstance.register(personRoutes, { prefix: RoutePrefix.PERSON }); + await fastifyInstance.register(postRoutes, { prefix: RoutePrefix.POST }); await fastifyInstance.register(volunteerRoutes, { prefix: RoutePrefix.VOLUNTEER, }); @@ -166,6 +174,9 @@ export async function createServer(): Promise { await fastifyInstance.register(communicationRoutes, { prefix: RoutePrefix.COMMUNICATION, }); + await fastifyInstance.register(activityLogRoutes, { + prefix: RoutePrefix.ACTIVITY_LOG, + }); await fastifyInstance.register(appreciationRoutes, { prefix: RoutePrefix.APPRECIATION, }); @@ -173,6 +184,9 @@ export async function createServer(): Promise { await fastifyInstance.register(m2mOpportunityVolunteerRoutes, { prefix: RoutePrefix.OPPORTUNITY_VOLUNTEER, }); + await fastifyInstance.register(activityLogCollectionRoutes, { + prefix: RoutePrefix.OPPORTUNITY_VOLUNTEER, + }); await fastifyInstance.register(organizationRoutes, { prefix: RoutePrefix.ORGANIZATION, }); diff --git a/src/server/plugins/jwt.ts b/src/server/plugins/jwt.ts index c6adb7ac..91c8dbb7 100644 --- a/src/server/plugins/jwt.ts +++ b/src/server/plugins/jwt.ts @@ -1,7 +1,8 @@ import fastifyJwt from "@fastify/jwt"; -import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { FastifyInstance, FastifyRequest } from "fastify"; import fp from "fastify-plugin"; import { UserRole } from "need4deed-sdk"; +import { UnauthenticatedError, UnauthorizedError } from "../../config"; import { accessCookieName, cookieOptions } from "../../config/constants"; import logger from "../../logger"; import { AuthOptions } from "../types"; @@ -19,7 +20,7 @@ async function jwtPlugin( }); fastify.decorate("authenticate", function (opt?: AuthOptions) { - return async function (request: FastifyRequest, reply: FastifyReply) { + return async function (request: FastifyRequest) { logger.debug( `jwtPlugin:authenticate called with request.routeOptions.config: ${JSON.stringify(request.routeOptions.config)}`, ); @@ -32,67 +33,50 @@ async function jwtPlugin( } try { - try { - await request.jwtVerify(); - } catch (_error) { - reply.status(401); - throw new Error("Authorization failed."); - } - - const userId = request.user?.id; - logger.debug(`jwtPlugin:authenticated: ${userId}`); - - const userRepository = fastify.db.userRepository; - if (!userRepository) { - throw new Error("User repository is not initialized."); - } - - const user = await userRepository.findOne({ - where: { id: userId }, - }); + await request.jwtVerify(); + } catch { + throw new UnauthenticatedError("Authorization failed."); + } - if (!user) { - reply.status(404); - throw new Error("User not found."); - } + const userId = request.user?.id; + logger.debug(`jwtPlugin:authenticated: ${userId}`); - // Expose the already-loaded user (carries personId + DB-authoritative - // role) for downstream hooks (PII masking, self-auth) — avoids a second - // lookup and a JWT claim. - request.authUser = user; + const userRepository = fastify.db.userRepository; + const user = await userRepository.findOne({ + where: { id: userId }, + }); - if (user.role === UserRole.ADMIN) { - logger.debug( - `Admin user ${userId} authenticated, bypassing further checks.`, - ); - reply.status(200); - return; - } + if (!user) { + throw new UnauthorizedError("User not found."); + } - const { role, allowSelf } = opt || {}; + // Expose the already-loaded user (carries personId + DB-authoritative + // role) for downstream hooks (PII masking, self-auth) — avoids a second + // lookup and a JWT claim. + request.authUser = user; + if (user.role === UserRole.ADMIN) { logger.debug( - `authenticate role:${role}, allowSelf:${allowSelf}, userId:${userId}`, + `Admin user ${userId} authenticated, bypassing further checks.`, ); + return; + } - if (role && role !== user.role) { - reply.status(403); - throw new Error("Permission denied"); - } + const { role, allowSelf } = opt || {}; + + logger.debug( + `authenticate role:${role}, allowSelf:${allowSelf}, userId:${userId}`, + ); + + if (role && role !== user.role) { + throw new UnauthorizedError("Permission denied"); + } - if (allowSelf) { - const requestParamId = (request.params as { id?: string }).id; - if (String(userId) !== requestParamId) { - reply.status(403); - throw new Error("Permission denied"); - } + if (allowSelf) { + const requestParamId = (request.params as { id?: string }).id; + if (String(userId) !== requestParamId) { + throw new UnauthorizedError("Permission denied"); } - } catch (error) { - logger.warn(`JWT verification failed: ${error.message}`); // Log the warning - reply.send({ - message: `Authentication failed: ${error.message}`, - error: error.message, - }); } }; }); diff --git a/src/server/plugins/notify.ts b/src/server/plugins/notify.ts index 1382329d..1eb91705 100644 --- a/src/server/plugins/notify.ts +++ b/src/server/plugins/notify.ts @@ -1,22 +1,47 @@ import { FastifyInstance } from "fastify"; import fp from "fastify-plugin"; +import { isProd, TRUTHY } from "../../config/constants"; +import OpportunityVolunteer from "../../data/entity/m2m/opportunity-volunteer"; +import Opportunity from "../../data/entity/opportunity/opportunity.entity"; import User from "../../data/entity/user.entity"; import { - BrevoEmailTransport, CommentTaggedInput, + DryRunEmailTransport, + DryRunSlackTransport, EmailTransport, sendCommentTagged, + sendEmailAccompanyMatch, + sendEmailAccompanyNotFound, + sendEmailIntroduction, + sendEmailNewAccompanying, + sendEmailNewRegular, + sendEmailPostMatchCheckup, + sendEmailRegularUpdate, + sendEmailStale, + sendEmailSuggestion, sendEmailVerification, sendOpsAlert, + sendPasswordReset, SlackChannel, SlackTransport, SlackWebhookTransport, + SmtpEmailTransport, } from "../../services/notify"; interface NotifyService { emailVerification(user: User): Promise; + passwordReset(user: User): Promise; opsAlert(text: string): Promise; commentTagged(input: CommentTaggedInput): Promise; + emailSuggestion(ov: OpportunityVolunteer): Promise; + emailStale(ov: OpportunityVolunteer): Promise; + emailIntroduction(ov: OpportunityVolunteer): Promise; + emailPostMatchCheckup(ov: OpportunityVolunteer): Promise; + emailAccompanyNotFound(opportunity: Opportunity): Promise; + emailAccompanyMatch(ov: OpportunityVolunteer): Promise; + emailRegularUpdate(opportunity: Opportunity): Promise; + emailNewRegular(opportunity: Opportunity): Promise; + emailNewAccompanying(opportunity: Opportunity): Promise; } declare module "fastify" { @@ -25,11 +50,48 @@ declare module "fastify" { } } -function buildEmailTransport(): EmailTransport { - return new BrevoEmailTransport(process.env.BREVO_API_KEY ?? ""); +/** Resolve dry-run flag for a given transport key (e.g. "EMAIL", "SLACK"). + * Priority: per-transport env > global env > default (!isProd). */ +function isDryRun(transportKey: string): boolean { + const perTransport = process.env[`NOTIFY_${transportKey}_DRY_RUN`]; + if (perTransport !== undefined) { + return TRUTHY.has(perTransport); + } + + const global = process.env.NOTIFY_DRY_RUN; + if (global !== undefined) { + return TRUTHY.has(global); + } + + return !isProd; +} + +function buildVerifyEmailTransport(): EmailTransport { + const smtp = new SmtpEmailTransport({ + host: process.env.SMTP_HOST ?? "mail.infomaniak.com", + port: Number(process.env.SMTP_PORT ?? 587), + user: process.env.SMTP_USER ?? "", + password: process.env.SMTP_PASS ?? "", + }); + return isDryRun("EMAIL") ? new DryRunEmailTransport(smtp) : smtp; +} + +function buildNotifyEmailTransport(): EmailTransport { + const smtp = new SmtpEmailTransport({ + host: process.env.SMTP_NOTIFY_HOST ?? "mail.infomaniak.com", + port: Number(process.env.SMTP_NOTIFY_PORT ?? 587), + user: process.env.SMTP_NOTIFY_USER ?? "", + password: process.env.SMTP_NOTIFY_PASS ?? "", + from: process.env.EMAIL_FROM_NOTIFY ?? "", + }); + return isDryRun("EMAIL") ? new DryRunEmailTransport(smtp) : smtp; } function buildSlackTransport(): SlackTransport | undefined { + if (isDryRun("SLACK")) { + return new DryRunSlackTransport(); + } + const urls: Partial> = {}; if (process.env.SLACK_OPS_WEBHOOK_URL) { urls.ops = process.env.SLACK_OPS_WEBHOOK_URL; @@ -44,15 +106,35 @@ function buildSlackTransport(): SlackTransport | undefined { } async function notifyPlugin(fastify: FastifyInstance) { - const email = buildEmailTransport(); + const emailVerify = buildVerifyEmailTransport(); + const emailNotify = buildNotifyEmailTransport(); const slack = buildSlackTransport(); fastify.decorate("notify", { emailVerification: (user: User) => - sendEmailVerification({ email, jwt: fastify.jwt }, user), + sendEmailVerification({ email: emailVerify, jwt: fastify.jwt }, user), + passwordReset: (user: User) => + sendPasswordReset({ email: emailVerify, jwt: fastify.jwt }, user), opsAlert: (text: string) => sendOpsAlert({ slack }, text), commentTagged: (input: CommentTaggedInput) => sendCommentTagged({ slack }, input), + emailSuggestion: (ov: OpportunityVolunteer) => + sendEmailSuggestion(emailNotify, ov), + emailStale: (ov: OpportunityVolunteer) => sendEmailStale(emailNotify, ov), + emailIntroduction: (ov: OpportunityVolunteer) => + sendEmailIntroduction(emailNotify, ov), + emailPostMatchCheckup: (ov: OpportunityVolunteer) => + sendEmailPostMatchCheckup(emailNotify, ov), + emailAccompanyNotFound: (opportunity: Opportunity) => + sendEmailAccompanyNotFound(emailNotify, opportunity), + emailAccompanyMatch: (ov: OpportunityVolunteer) => + sendEmailAccompanyMatch(emailNotify, ov), + emailRegularUpdate: (opportunity: Opportunity) => + sendEmailRegularUpdate(emailNotify, opportunity), + emailNewRegular: (opportunity: Opportunity) => + sendEmailNewRegular(emailNotify, opportunity), + emailNewAccompanying: (opportunity: Opportunity) => + sendEmailNewAccompanying(emailNotify, opportunity), }); } diff --git a/src/server/plugins/scheduler-daily.ts b/src/server/plugins/scheduler-daily.ts new file mode 100644 index 00000000..a216f996 --- /dev/null +++ b/src/server/plugins/scheduler-daily.ts @@ -0,0 +1,45 @@ +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import cron from "node-cron"; +import logger from "../../logger"; +import { scanExpiredOnetimers } from "../../services/jobs/scan-expired-onetimers"; +import { runWithAdvisoryLock } from "../utils"; + +// Unique integer key for this app's advisory lock — prevents duplicate runs +// across multiple ECS instances. +const SCHEDULER_LOCK_ID = 20240707; + +async function schedulerDailyPlugin(fastify: FastifyInstance): Promise { + // Daily at the 6AM hour Berlin time, weekdays only. + // node-cron handles DST automatically when timezone is set. + const task = cron.schedule( + "0 6 * * *", + async () => { + try { + logger.info("scheduler: running daily scans"); + + await runWithAdvisoryLock(async () => { + const results = await Promise.allSettled([ + scanExpiredOnetimers(fastify), + ]); + + for (const r of results) { + if (r.status === "rejected") { + logger.error({ err: r.reason }, "scheduler: scan failed"); + } + } + }, SCHEDULER_LOCK_ID); + } catch (err) { + logger.error({ err }, "scheduler: unhandled error in cron callback"); + } + }, + { timezone: "Europe/Berlin" }, + ); + + fastify.addHook("onClose", () => task.stop()); +} + +export default fp(schedulerDailyPlugin, { + name: "scheduler-daily", + dependencies: ["typeorm-plugin", "notify"], +}); diff --git a/src/server/plugins/scheduler-hourly.ts b/src/server/plugins/scheduler-hourly.ts new file mode 100644 index 00000000..a1ae18d5 --- /dev/null +++ b/src/server/plugins/scheduler-hourly.ts @@ -0,0 +1,66 @@ +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import cron from "node-cron"; +import { TRUTHY } from "../../config/constants"; +import logger from "../../logger"; +import { + berlinToday, + isGermanPublicHoliday, +} from "../../services/jobs/german-holidays"; +import { scanAccompanyNotFound } from "../../services/jobs/scan-accompany-not-found"; +import { scanPostMatchCheckup } from "../../services/jobs/scan-post-match-checkup"; +import { scanRegularUpdate } from "../../services/jobs/scan-regular-update"; +import { scanStalePending } from "../../services/jobs/scan-stale-pending"; +import { runWithAdvisoryLock } from "../utils"; + +// Unique integer key for this app's advisory lock — prevents duplicate runs +// across multiple ECS instances. +const SCHEDULER_LOCK_ID = 20240701; + +async function schedulerHourlyPlugin(fastify: FastifyInstance): Promise { + // Hourly on the hour, 08:00–19:00 Berlin time, weekdays only. + // node-cron handles DST automatically when timezone is set. + const task = cron.schedule( + "0 8-19 * * 1-5", + async () => { + try { + if (TRUTHY.has(process.env.NOTIFY_CRON_MUTED ?? "")) { + logger.info("scheduler: skipping — NOTIFY_CRON_MUTED is set"); + return; + } + + if (isGermanPublicHoliday(berlinToday())) { + logger.info("scheduler: skipping — German public holiday"); + return; + } + + logger.info("scheduler: running hourly email scans"); + + await runWithAdvisoryLock(async () => { + const results = await Promise.allSettled([ + scanStalePending(fastify), + scanPostMatchCheckup(fastify), + scanAccompanyNotFound(fastify), + scanRegularUpdate(fastify), + ]); + + for (const r of results) { + if (r.status === "rejected") { + logger.error({ err: r.reason }, "scheduler: scan failed"); + } + } + }, SCHEDULER_LOCK_ID); + } catch (err) { + logger.error({ err }, "scheduler: unhandled error in cron callback"); + } + }, + { timezone: "Europe/Berlin" }, + ); + + fastify.addHook("onClose", () => task.stop()); +} + +export default fp(schedulerHourlyPlugin, { + name: "scheduler-hourly", + dependencies: ["typeorm-plugin", "notify"], +}); diff --git a/src/server/plugins/typeorm.ts b/src/server/plugins/typeorm.ts index 97bc8af5..997bd92d 100644 --- a/src/server/plugins/typeorm.ts +++ b/src/server/plugins/typeorm.ts @@ -8,6 +8,7 @@ import Deal from "../../data/entity/deal.entity"; import Document from "../../data/entity/document.entity"; import FieldTranslation from "../../data/entity/field_translation.entity"; import Postcode from "../../data/entity/location/postcode.entity"; +import ActivityLog from "../../data/entity/m2m/activity-log.entity"; import AgentPerson from "../../data/entity/m2m/agent-person"; import OpportunityVolunteer from "../../data/entity/m2m/opportunity-volunteer"; import Agent from "../../data/entity/opportunity/agent.entity"; @@ -15,6 +16,7 @@ import Opportunity from "../../data/entity/opportunity/opportunity.entity"; import Option from "../../data/entity/option.entity"; import Organization from "../../data/entity/organization.entity"; import Person from "../../data/entity/person.entity"; +import Post from "../../data/entity/post.entity"; import Language from "../../data/entity/profile/language.entity"; import TrustedDomain from "../../data/entity/trusted-domain.entity"; import User from "../../data/entity/user.entity"; @@ -41,6 +43,7 @@ const typeormPlugin: FastifyPluginAsync = async (fastify) => { commentRepository: dataSource.getRepository(Comment), documentRepository: dataSource.getRepository(Document), communicationRepository: dataSource.getRepository(Communication), + activityLogRepository: dataSource.getRepository(ActivityLog), appreciationRepository: dataSource.getRepository(Appreciation), opportunityVolunteerRepository: dataSource.getRepository(OpportunityVolunteer), @@ -50,6 +53,7 @@ const typeormPlugin: FastifyPluginAsync = async (fastify) => { agentPersonRepository: dataSource.getRepository(AgentPerson), organizationRepository: dataSource.getRepository(Organization), postcodeRepository: dataSource.getRepository(Postcode), + postRepository: dataSource.getRepository(Post), trustedDomainRepository: dataSource.getRepository(TrustedDomain), }); diff --git a/src/server/routes/activity-log.routes.ts b/src/server/routes/activity-log.routes.ts new file mode 100644 index 00000000..e541a3e8 --- /dev/null +++ b/src/server/routes/activity-log.routes.ts @@ -0,0 +1,89 @@ +import { FastifyInstance, FastifyPluginOptions } from "fastify"; +import { ApiActivityLogPatch, UserRole } from "need4deed-sdk"; +import { NotFoundError } from "../../config"; +import { dtoActivityLogEntry } from "../../services/dto/dto-activity-log"; +import { idParamSchema } from "../schema"; +import { ParamsId } from "../types"; + +export default async function activityLogRoutes( + fastify: FastifyInstance, + _options: FastifyPluginOptions, +) { + fastify.addHook("onRequest", fastify.authenticate()); + + // PATCH /activity-log/:id — COORDINATOR only (ADMIN bypasses) + fastify.patch<{ Params: ParamsId; Body: ApiActivityLogPatch }>( + "/:id", + { + onRequest: fastify.authenticate({ role: UserRole.COORDINATOR }), + schema: { + params: idParamSchema, + body: { $ref: "ApiActivityLogPatch#" }, + response: { + 200: { + type: "object", + properties: { + message: { type: "string" }, + data: { $ref: "ApiActivityLogEntry#" }, + }, + required: ["message", "data"], + }, + }, + }, + }, + async (request, reply) => { + const { id } = request.params; + + const log = await fastify.db.activityLogRepository.findOne({ + where: { id }, + }); + if (!log) { + throw new NotFoundError(`Activity log entry id:${id} not found`); + } + + const updated = await fastify.db.activityLogRepository.save({ + ...log, + ...request.body, + }); + + return reply.status(200).send({ + message: `Activity log entry id:${id} updated`, + data: dtoActivityLogEntry(updated), + }); + }, + ); + + // DELETE /activity-log/:id — COORDINATOR only (ADMIN bypasses) + fastify.delete<{ Params: ParamsId }>( + "/:id", + { + onRequest: fastify.authenticate({ role: UserRole.COORDINATOR }), + schema: { + params: idParamSchema, + response: { + 200: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + }, + }, + }, + async (request, reply) => { + const { id } = request.params; + + const log = await fastify.db.activityLogRepository.findOne({ + where: { id }, + }); + if (!log) { + throw new NotFoundError(`Activity log entry id:${id} not found`); + } + + await fastify.db.activityLogRepository.remove(log); + + return reply.status(200).send({ + message: `Activity log entry id:${id} deleted`, + }); + }, + ); +} diff --git a/src/server/routes/agent/agent-opportunity.routes.ts b/src/server/routes/agent/agent-opportunity.routes.ts index 290d9421..4ea1e7d2 100644 --- a/src/server/routes/agent/agent-opportunity.routes.ts +++ b/src/server/routes/agent/agent-opportunity.routes.ts @@ -27,7 +27,14 @@ export default async function agentOpportunityRoutes( async (request, reply) => { const { id } = request.params; const agentRepository = fastify.db.agentRepository; - const relations = ["opportunity.opportunityVolunteer.volunteer.person"]; + const relations = [ + "opportunity.opportunityVolunteer.volunteer.person", + "opportunity.deal.dealLanguage.language", + "opportunity.deal.dealActivity.activity", + "opportunity.deal.dealDistrict.district", + "opportunity.deal.dealTimeslot.timeslot", + "opportunity.district", + ]; const agent = await agentRepository.findOne({ where: { id }, relations }); if (!agent) { diff --git a/src/server/routes/agent/agent.routes.ts b/src/server/routes/agent/agent.routes.ts index a3c3a461..68001240 100644 --- a/src/server/routes/agent/agent.routes.ts +++ b/src/server/routes/agent/agent.routes.ts @@ -72,13 +72,13 @@ export default async function agentRoutes( preSerialization: makePiiSerialization(dtoAgentGetList), }, async (request, reply) => { - logger.debug(`GET /agents: request.query:${Object.keys(request.query)}`); + logger.debug(`GET /agent: request.query:${Object.keys(request.query)}`); const { page, limit, sortOrder, filter } = request.query; const [skip, take] = getSkipTake({ page, limit }); const where = getAgentWhere(filter); logger.debug( - `GET /agents: filters:${JSON.stringify(filter)}, skip:${skip}, take:${take}`, + `GET /agent: filters:${JSON.stringify(filter)}, skip:${skip}, take:${take}`, ); const agentRepository = fastify.db.agentRepository; diff --git a/src/server/routes/agent/register.routes.ts b/src/server/routes/agent/register.routes.ts index 81a2506f..ff557f66 100644 --- a/src/server/routes/agent/register.routes.ts +++ b/src/server/routes/agent/register.routes.ts @@ -91,7 +91,7 @@ export default async function agentRegisterRoutes( const street = (request.query.street ?? "").trim(); const postcode = (request.query.postcode ?? "").trim(); const domain = request.registrant?.email.split("@").pop()?.toLowerCase(); - if (street.length < 3 || !postcode || !domain) { + if (street.length < 3 || !domain) { return reply.status(200).send({ message: "No query", data: [] }); } diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index d9a4ede8..e7342b36 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -7,15 +7,21 @@ import { REFRESH_LIFESPAN_MS, refreshCookieName, } from "../../config/constants"; +import { hashPassword } from "../../data/utils"; import logger from "../../logger"; +import { responseSchema } from "../schema"; import { responseErrors } from "../schema/responseErrors"; import { + changePasswordSchema, + messageResponseSchema, refreshAccessResponseSchema, refreshAccessSchema, + requestResetSchema, + resetPasswordSchema, userLoginResponseSchema, userLoginSchema, } from "../schema/user.schema"; -import { RoutePrefix } from "../types"; +import { ReplyMessage, RoutePrefix } from "../types"; async function authRoutes( fastify: FastifyInstance, @@ -45,6 +51,9 @@ async function authRoutes( }, }, async (request, reply) => { + reply.clearCookie(accessCookieName, cookieOptions); + reply.clearCookie(refreshCookieName, cookieOptions); + const { email, password } = request.body; if (!email || !password) { @@ -216,11 +225,7 @@ async function authRoutes( { schema: { response: { - 200: { - type: "object", - properties: { message: { type: "string" } }, - required: ["message"], - }, + 200: messageResponseSchema, ...responseErrors, }, }, @@ -236,6 +241,116 @@ async function authRoutes( return reply.status(200).send({ message: "Logout successful." }); }, ); + + fastify.post<{ + Body: { email: string }; + Reply: ReplyMessage; + }>( + prefixedPath + RoutePrefix.REQUEST_RESET, + { + schema: { + body: requestResetSchema, + response: responseSchema(""), + }, + }, + async (request, reply) => { + const { email } = request.body; + + const user = await fastify.db.userRepository.findOne({ + where: { email }, + }); + + if (user?.isActive) { + fastify.notify.passwordReset(user).catch((err) => { + logger.error( + `Failed to send password reset for user ${user.id}: ${err instanceof Error ? err.message : err}`, + ); + }); + } + + return reply.status(200).send({ + message: + "If an account with that email exists, a reset link has been sent.", + }); + }, + ); + + fastify.post<{ + Body: { token: string; newPassword: string }; + Reply: ReplyMessage; + }>( + prefixedPath + RoutePrefix.RESET_PASSWORD, + { + schema: { + body: resetPasswordSchema, + response: responseSchema(""), + }, + }, + async (request, reply) => { + const { token, newPassword } = request.body; + const TOKEN_ERROR_MESSAGE = "Invalid reset token."; + + let payload: { id: number; email: string; type?: string }; + try { + payload = await fastify.jwt.verify(token); + } catch { + return reply.status(400).send({ message: TOKEN_ERROR_MESSAGE }); + } + + if (payload.type !== "reset") { + return reply.status(400).send({ message: TOKEN_ERROR_MESSAGE }); + } + + const user = await fastify.db.userRepository.findOne({ + where: { id: payload.id }, + }); + if (!user) { + return reply.status(400).send({ message: TOKEN_ERROR_MESSAGE }); + } + + await fastify.db.userRepository.update( + { id: user.id }, + { password: await hashPassword(newPassword) }, + ); + + return reply.status(200).send({ + message: "Password has been reset.", + }); + }, + ); + + fastify.post<{ + Body: { password: string; newPassword: string }; + Reply: ReplyMessage; + }>( + prefixedPath + RoutePrefix.CHANGE_PASSWORD, + { + onRequest: [fastify.authenticate()], + schema: { + body: changePasswordSchema, + response: responseSchema(""), + }, + }, + async (request, reply) => { + const { password, newPassword } = request.body; + const authUser = request.authUser!; + + if (!(await authUser.checkPassword(password))) { + return reply + .status(400) + .send({ message: "Current password is incorrect." }); + } + + await fastify.db.userRepository.update( + { id: authUser.id }, + { password: await hashPassword(newPassword) }, + ); + + return reply.status(200).send({ + message: "Password has been changed.", + }); + }, + ); } export default fp(authRoutes, { diff --git a/src/server/routes/comment.ts b/src/server/routes/comment.ts index 57d91a95..d3d19f80 100644 --- a/src/server/routes/comment.ts +++ b/src/server/routes/comment.ts @@ -397,6 +397,75 @@ export default async function commentRoutes( }, ); + // + // PATCH /comment/:id/read + // + fastify.patch( + "/:id/read", + { + schema: { + response: { + 200: { + type: "object", + properties: { + message: { type: "string" }, + data: { $ref: "ApiComment#" }, + }, + required: ["message", "data"], + }, + ...responseErrors, + }, + }, + onRequest: [fastify.authenticate()], + }, + async (request, reply) => { + const id = Number((request.params as { id: string }).id); + if (isNaN(id) || id <= 0) { + return reply + .status(400) + .send({ message: "Invalid comment ID provided." }); + } + + const personId = request.authUser?.personId; + if (!personId) { + return reply + .status(403) + .send({ message: "No person linked to this user." }); + } + + const cpRepository = + fastify.db.commentRepository.manager.getRepository(CommentPerson); + const cp = await cpRepository.findOne({ + where: { commentId: id, personId }, + }); + if (!cp) { + return reply + .status(404) + .send({ + message: `No tag found for comment id:${id} on your person.`, + }); + } + + cp.readAt = new Date(); + await cpRepository.save(cp); + + const comment = await fastify.db.commentRepository.findOne({ + where: { id }, + relations: ["user", "user.person", "language", "commentPerson"], + }); + if (!comment) { + return reply + .status(404) + .send({ message: `Comment id:${id} not found.` }); + } + + return reply.status(200).send({ + message: `Comment id:${id} marked as read`, + data: commentSerializer(comment), + }); + }, + ); + fastify.delete( "/:id", { diff --git a/src/server/routes/communication.routes.ts b/src/server/routes/communication.routes.ts index 8cc28016..d02e068f 100644 --- a/src/server/routes/communication.routes.ts +++ b/src/server/routes/communication.routes.ts @@ -88,7 +88,10 @@ export default async function communicationRoutes( }); } - if (communication.userId !== request.user.id) { + if ( + communication.userId !== null && + communication.userId !== request.user.id + ) { return reply.status(403).send({ message: `You do not have permission to delete communication with id:${id}.`, }); diff --git a/src/server/routes/health.ts b/src/server/routes/health.ts index 558472ce..7536ac2e 100644 --- a/src/server/routes/health.ts +++ b/src/server/routes/health.ts @@ -11,7 +11,7 @@ export default async function healthRoutes( { schema: { response: responseSchema("") } }, async (_request, reply) => { return reply.status(200).send({ - message: "Need4Deed API v1 is up and running.", + message: `Need4Deed API v1 is up and running.\nHEAD commit: ${process.env.GIT_COMMIT_SHA}`, }); }, ); diff --git a/src/server/routes/m2m/activity-log.routes.ts b/src/server/routes/m2m/activity-log.routes.ts new file mode 100644 index 00000000..71f656db --- /dev/null +++ b/src/server/routes/m2m/activity-log.routes.ts @@ -0,0 +1,105 @@ +import { FastifyInstance, FastifyPluginOptions } from "fastify"; +import { ApiActivityLogPost, UserRole } from "need4deed-sdk"; +import { NotFoundError, UnauthorizedError } from "../../../config"; +import ActivityLog from "../../../data/entity/m2m/activity-log.entity"; +import { + dtoActivityLogEntry, + dtoActivityLogGet, +} from "../../../services/dto/dto-activity-log"; +import { idParamSchema } from "../../schema"; +import { ParamsId } from "../../types"; + +export default async function activityLogCollectionRoutes( + fastify: FastifyInstance, + _options: FastifyPluginOptions, +) { + fastify.addHook("onRequest", fastify.authenticate()); + + // GET /opportunity-volunteer/:id/activity-log + fastify.get<{ Params: ParamsId }>( + "/:id/activity-log", + { + schema: { + params: idParamSchema, + response: { + 200: { $ref: "ApiActivityLogGet#" }, + }, + }, + }, + async (request, reply) => { + const role = request.authUser?.role; + if ( + role !== UserRole.COORDINATOR && + role !== UserRole.AGENT && + role !== UserRole.ADMIN + ) { + throw new UnauthorizedError(); + } + + const { id } = request.params; + + const ov = await fastify.db.opportunityVolunteerRepository.findOne({ + where: { id }, + }); + if (!ov) { + throw new NotFoundError(`OpportunityVolunteer id:${id} not found`); + } + + const logs = await fastify.db.activityLogRepository.find({ + where: { opportunityVolunteerId: id }, + order: { date: "ASC" }, + }); + + return reply.status(200).send(dtoActivityLogGet(logs)); + }, + ); + + // POST /opportunity-volunteer/:id/activity-log + fastify.post<{ Params: ParamsId; Body: ApiActivityLogPost }>( + "/:id/activity-log", + { + schema: { + params: idParamSchema, + body: { $ref: "ApiActivityLogPost#" }, + response: { + 201: { + type: "object", + properties: { + message: { type: "string" }, + data: { $ref: "ApiActivityLogEntry#" }, + }, + required: ["message", "data"], + }, + }, + }, + }, + async (request, reply) => { + const role = request.authUser?.role; + if ( + role !== UserRole.COORDINATOR && + role !== UserRole.AGENT && + role !== UserRole.ADMIN + ) { + throw new UnauthorizedError(); + } + + const { id } = request.params; + + const ov = await fastify.db.opportunityVolunteerRepository.findOne({ + where: { id }, + }); + if (!ov) { + throw new NotFoundError(`OpportunityVolunteer id:${id} not found`); + } + + const log = await fastify.db.activityLogRepository.save( + new ActivityLog({ ...request.body, opportunityVolunteerId: id }), + ); + + return reply.status(201).send({ + message: `Activity log entry created for OpportunityVolunteer id:${id}`, + data: dtoActivityLogEntry(log), + }); + }, + ); +} diff --git a/src/server/routes/m2m/opportunity-volunteer.routes.ts b/src/server/routes/m2m/opportunity-volunteer.routes.ts index 53ec8e99..638771fd 100644 --- a/src/server/routes/m2m/opportunity-volunteer.routes.ts +++ b/src/server/routes/m2m/opportunity-volunteer.routes.ts @@ -1,9 +1,16 @@ import { FastifyInstance, FastifyPluginOptions } from "fastify"; -import { OpportunityVolunteerStatusType, UserRole } from "need4deed-sdk"; +import { + CommunicationType, + OpportunityVolunteerStatusType, + ProfileVolunteeringType, + UserRole, +} from "need4deed-sdk"; import { BadRequestError, NotFoundError } from "../../../config"; import OpportunityVolunteer from "../../../data/entity/m2m/opportunity-volunteer"; +import logger from "../../../logger"; import { idParamSchema, responseSchema } from "../../schema"; import { ParamsId, ReplyMessage } from "../../types"; +import { logEmailCommunication } from "../../utils/data/log-email-communication"; export default async function m2mOpportunityVolunteerRoutes( fastify: FastifyInstance, @@ -72,11 +79,147 @@ export default async function m2mOpportunityVolunteerRoutes( throw new NotFoundError(`There's no M2M relation id:${id}`); } + const prevStatus = opportunityVolunteer.status; + const nextStatus = request.body.status; + opportunityVolunteerRepository.merge(opportunityVolunteer, request.body); await opportunityVolunteerRepository.save(opportunityVolunteer, { reload: true, }); + if (nextStatus && nextStatus !== prevStatus) { + const commRepo = fastify.db.communicationRepository; + + if (nextStatus === OpportunityVolunteerStatusType.PENDING) { + (async () => { + try { + // findOne inside the IIFE: handler returns 204 immediately; + // a DB hiccup here doesn't affect the HTTP response. + const ov = await opportunityVolunteerRepository.findOne({ + where: { id }, + relations: [ + "volunteer.person", + "volunteer.person.users", + "volunteer.deal.postcode", + "volunteer.deal.dealTimeslot.timeslot", + "opportunity", + ], + }); + if (!ov) { + return; + } + // Skip if FIRST_INQUIRY already sent (e.g. PENDING→DECLINED→PENDING). + const alreadySent = await commRepo.findOne({ + where: { + volunteerId: ov.volunteerId, + opportunityId: ov.opportunityId, + communicationType: CommunicationType.FIRST_INQUIRY, + }, + }); + if (alreadySent) { + return; + } + // Log before send; remove the dedup record on failure so the next toggle can retry. + const comm = await logEmailCommunication( + commRepo, + CommunicationType.FIRST_INQUIRY, + { + volunteerId: ov.volunteerId, + opportunityId: ov.opportunityId, + }, + ); + try { + await fastify.notify.emailSuggestion(ov); + } catch (sendErr) { + await commRepo.remove(comm).catch(logger.error); + throw sendErr; + } + } catch (err) { + logger.error( + `emailSuggestion side-effect failed (ov ${id}): ${err}`, + ); + } + })(); + } else if (nextStatus === OpportunityVolunteerStatusType.MATCHED) { + (async () => { + try { + // findOne inside the IIFE: handler returns 204 immediately; + // a DB hiccup here doesn't affect the HTTP response. + const ov = await opportunityVolunteerRepository.findOne({ + where: { id }, + relations: [ + "volunteer.person", + "volunteer.person.users", + "volunteer.deal.dealLanguage.language", + "volunteer.deal.dealSkill.skill", + "volunteer.deal.dealTimeslot.timeslot", + "opportunity.contactPerson", + "opportunity.contactPerson.users", + "opportunity.agent.address.postcode", + "opportunity.accompanying.postcode", + "opportunity.district", + ], + }); + if (!ov) { + return; + } + const isAccompany = + ov.opportunity?.type === ProfileVolunteeringType.ACCOMPANYING; + const commType = isAccompany + ? CommunicationType.ACCOMPANYING_MATCHED + : CommunicationType.MATCHED; + // Skip if a match email was already sent for this pair. + const alreadySent = await commRepo.findOne({ + where: { + volunteerId: ov.volunteerId, + opportunityId: ov.opportunityId, + communicationType: commType, + }, + }); + if (alreadySent) { + return; + } + if (isAccompany) { + // Log before send; remove the dedup record on failure so the next toggle can retry. + const comm = await logEmailCommunication( + commRepo, + CommunicationType.ACCOMPANYING_MATCHED, + { + volunteerId: ov.volunteerId, + opportunityId: ov.opportunityId, + }, + ); + try { + await fastify.notify.emailAccompanyMatch(ov); + } catch (sendErr) { + await commRepo.remove(comm).catch(logger.error); + throw sendErr; + } + } else { + const comm = await logEmailCommunication( + commRepo, + CommunicationType.MATCHED, + { + volunteerId: ov.volunteerId, + opportunityId: ov.opportunityId, + }, + ); + try { + await fastify.notify.emailIntroduction(ov); + } catch (sendErr) { + await commRepo.remove(comm).catch(logger.error); + throw sendErr; + } + } + } catch (err) { + logger.error( + `emailMatched side-effect failed (ov ${id}): ${err}`, + ); + } + })(); + } + } + return reply.status(204).send(); }, ); diff --git a/src/server/routes/opportunity/legacy.routes.ts b/src/server/routes/opportunity/legacy.routes.ts index 14df7fcf..7945aec6 100644 --- a/src/server/routes/opportunity/legacy.routes.ts +++ b/src/server/routes/opportunity/legacy.routes.ts @@ -4,18 +4,16 @@ import { FastifyPluginOptions, } from "fastify"; import { - AgentRoleType, OpportunityLegacyFormData, OpportunityStatusType, } from "need4deed-sdk"; import { In } from "typeorm"; import { dataSource } from "../../../data/data-source"; import Address from "../../../data/entity/location/address.entity"; -import AgentPerson from "../../../data/entity/m2m/agent-person"; import Agent from "../../../data/entity/opportunity/agent.entity"; import Opportunity from "../../../data/entity/opportunity/opportunity.entity"; import Person from "../../../data/entity/person.entity"; -import Comment from "../../../data/entity/comment.entity"; +import { getPostcode, getRepository } from "../../../data/utils"; import logger from "../../../logger"; import { accompanyingParserOpportunity, @@ -23,7 +21,6 @@ import { parseOpportunityLegacy, } from "../../../services"; import { dealParserOpportunity } from "../../../services/dto/parser-deal-opportunity"; -import { getPostcode, getRepository } from "../../../data/utils"; import { getAgentByAddress, getDistrictToOpportunityHandler, @@ -48,7 +45,9 @@ async function findOrCreateAgent( formData: OpportunityLegacyFormData, ): Promise { if (!formData.rac_address || !formData.rac_plz) { - logger.warn("Legacy form missing rac_address or rac_plz — falling back to orphanage agent"); + logger.warn( + "Legacy form missing rac_address or rac_plz — falling back to orphanage agent", + ); return getOpportunityOrphanageAgent(); } @@ -57,8 +56,12 @@ async function findOrCreateAgent( relations: ["address.postcode", "agentPostcode.postcode"], }); - const match = getAgentByAddress(agents, formData.rac_address, formData.rac_plz); - if (match) return match; + const match = getAgentByAddress( + agents, + formData.rac_address, + formData.rac_plz, + ); + if (match) {return match;} logger.info( `No agent found for address "${formData.rac_address}" ${formData.rac_plz} — creating new agent`, @@ -67,21 +70,18 @@ async function findOrCreateAgent( const postcode = await getPostcode(formData.rac_plz); return dataSource.manager.transaction(async (em) => { - const address = new Address({ street: formData.rac_address, postcodeId: postcode.id }); + const address = new Address({ + street: formData.rac_address, + postcodeId: postcode.id, + }); await em.save(address); - const person = parseContactPerson(formData); - await em.save(person); - - const agent = new Agent({ title: formData.rac_address, addressId: address.id }); + const agent = new Agent({ + title: formData.rac_address, + addressId: address.id, + }); await em.save(agent); - await em.save(new AgentPerson({ - agentId: agent.id, - personId: person.id, - role: AgentRoleType.VOLUNTEER_COORDINATOR, - })); - return agent; }); } @@ -107,21 +107,24 @@ export default async function opportunityLegacyRoutes( opportunity.agent = await findOrCreateAgent(request.body); - const personRepository = getRepository(dataSource, Person); - const contactPerson = parseContactPerson(request.body); - await personRepository.save(contactPerson); - opportunity.contactPersonId = contactPerson.id; - const { addDistrictToOpportunity } = getDistrictToOpportunityHandler(); Object.assign(opportunity, await addDistrictToOpportunity(opportunity)); if (opportunity.agent?.id) { - const submitter = await getOrCreateSubmitterPerson( + const person = await getOrCreateSubmitterPerson( request.body, opportunity.agent.id, ); - if (submitter) { - opportunity.submittedByPersonId = submitter.id; + if (person) { + opportunity.contactPersonId = person.id; + opportunity.submittedByPersonId = person.id; + } else { + // No rac_email on form — create a bare Person for contactPersonId + // without deduplication or an agent_person link. + const personRepository = getRepository(dataSource, Person); + const contactPerson = parseContactPerson(request.body); + await personRepository.save(contactPerson); + opportunity.contactPersonId = contactPerson.id; } } diff --git a/src/server/routes/opportunity/opportunity.routes.ts b/src/server/routes/opportunity/opportunity.routes.ts index e0763b8d..a5ecef98 100644 --- a/src/server/routes/opportunity/opportunity.routes.ts +++ b/src/server/routes/opportunity/opportunity.routes.ts @@ -2,8 +2,10 @@ import { FastifyInstance, FastifyPluginOptions } from "fastify"; import { ApiOpportunityGet, ApiOpportunityPatch, + CommunicationType, OpportunityFormDataWithAgentSubmitter, OpportunityLegacyFormData, + OpportunityLegacyType, SortOrder, UserRole, } from "need4deed-sdk"; @@ -60,6 +62,7 @@ import { updateOptionList, writeOpportunityLegacy, } from "../../utils"; +import { logEmailCommunication } from "../../utils/data/log-email-communication"; import { makePiiSerialization, maskForCaller, @@ -67,6 +70,46 @@ import { import opportunityLegacyRoutes from "./legacy.routes"; import opportunityOpportunityVolunteerRoutes from "./opportunity-volunteer.routes"; +async function sendNewOpportunityEmail( + fastify: FastifyInstance, + id: number, + relations: string[], + notifyFn: (opp: Opportunity) => Promise, + label: string, +): Promise { + const opp = await fastify.db.opportunityRepository.findOne({ + where: { id }, + relations, + }); + if (!opp) { + return; + } + const alreadySent = await fastify.db.communicationRepository.findOne({ + where: { + opportunityId: id, + communicationType: CommunicationType.OPPORTUNITY_CONFIRMATION, + }, + }); + if (alreadySent) { + return; + } + const comm = await logEmailCommunication( + fastify.db.communicationRepository, + CommunicationType.OPPORTUNITY_CONFIRMATION, + { opportunityId: id }, + ); + try { + await notifyFn(opp); + } catch (sendErr) { + await fastify.db.communicationRepository.remove(comm).catch((removeErr) => { + logger.error( + `${label} dedup rollback failed (opp ${id}, comm ${comm.id}): ${removeErr}`, + ); + }); + throw sendErr; + } +} + export default async function opportunityRoutes( fastify: FastifyInstance, _options: FastifyPluginOptions, @@ -323,7 +366,7 @@ export default async function opportunityRoutes( agent.address?.postcode?.value, ); opportunity.accompanying = - body.opportunity_type === "accompanying" + body.opportunity_type === OpportunityLegacyType.ACCOMPANYING ? await accompanyingParserOpportunity(legacyBody) : undefined; @@ -341,6 +384,46 @@ export default async function opportunityRoutes( getOpportunityNotificationText(opportunity.title), ); + if (body.opportunity_type === OpportunityLegacyType.VOLUNTEERING) { + (async () => { + try { + await sendNewOpportunityEmail( + fastify, + id, + ["contactPerson", "contactPerson.users"], + (opp) => fastify.notify.emailNewRegular(opp), + "emailNewRegular", + ); + } catch (err) { + logger.error( + `emailNewRegular side-effect failed (opp ${id}): ${err}`, + ); + } + })(); + } else if (body.opportunity_type === OpportunityLegacyType.ACCOMPANYING) { + (async () => { + try { + await sendNewOpportunityEmail( + fastify, + id, + [ + "accompanying", + "accompanying.postcode", + "contactPerson", + "contactPerson.users", + "district", + ], + (opp) => fastify.notify.emailNewAccompanying(opp), + "emailNewAccompanying", + ); + } catch (err) { + logger.error( + `emailNewAccompanying side-effect failed (opp ${id}): ${err}`, + ); + } + })(); + } + return reply.status(201).send({ message: `Opportunity (${id}) created.`, data: { id }, diff --git a/src/server/routes/post.routes.ts b/src/server/routes/post.routes.ts new file mode 100644 index 00000000..0c1af110 --- /dev/null +++ b/src/server/routes/post.routes.ts @@ -0,0 +1,273 @@ +import { FastifyInstance, FastifyPluginOptions } from "fastify"; +import { ApiPostGet, ApiPostPatch, ApiPostPost, UserRole } from "need4deed-sdk"; +import { In } from "typeorm"; +import { + BadRequestError, + NotFoundError, + UnauthorizedError, +} from "../../config/error/fastify"; +import { dtoPost } from "../../services/dto/dto-post"; +import { + idParamSchema, + paginationQuerySchema, + responseSchema, +} from "../schema"; +import { + ParamsId, + QuerystringPagination, + ReplyData, + ReplyDataCount, + ReplyMessage, +} from "../types"; +import { getSkipTake } from "../utils"; +import { getAgentPersonRepresentative } from "../utils/data/get-agent-person-representative"; +import { validateRelationIds } from "../utils/data/validate-relation-ids"; + +export default async function postRoutes( + fastify: FastifyInstance, + _options: FastifyPluginOptions, +) { + // + // GET /post + // + fastify.get<{ + Querystring: QuerystringPagination; + Reply: ReplyDataCount; + }>( + "/", + { + schema: { + querystring: paginationQuerySchema, + response: responseSchema({ + dataSchemaRef: "ApiPostGet#", + isArray: true, + count: true, + }), + }, + onRequest: [fastify.authenticate()], + }, + async (request, reply) => { + const { role } = request.user; + const [skip, take] = getSkipTake(request.query); + + const qb = fastify.db.postRepository + .createQueryBuilder("post") + .leftJoinAndSelect("post.author", "author") + .leftJoinAndSelect("post.taggedPersons", "taggedPerson") + .leftJoinAndSelect("post.linkedOpportunities", "opportunity") + .orderBy("post.createdAt", "DESC") + .skip(skip) + .take(take); + + if ( + role === UserRole.ADMIN || + role === UserRole.COORDINATOR || + role === UserRole.AGENT + ) { + // no filter — all posts visible + } else { + return reply + .status(200) + .send({ message: "Posts.", data: [], count: 0 }); + } + + const [posts, count] = await qb.getManyAndCount(); + return reply.status(200).send({ + message: "Posts.", + data: posts.map(dtoPost), + count, + }); + }, + ); + + // + // POST /post + // + fastify.post<{ Body: ApiPostPost; Reply: ReplyData }>( + "/", + { + schema: { + body: { $ref: "ApiPostPost#" }, + response: responseSchema({ + dataSchemaRef: "ApiPostGet#", + statusCode: 201, + }), + }, + onRequest: [fastify.authenticate({ role: UserRole.AGENT })], + }, + async (request, reply) => { + const personId = request.authUser?.personId; + if (!personId) { + throw new BadRequestError("No person linked to this user."); + } + + const { + text, + taggedPersonIds = [], + linkedOpportunityIds = [], + } = request.body; + + const agentPerson = await getAgentPersonRepresentative(personId); + + const [taggedPersons, linkedOpportunities] = await Promise.all([ + taggedPersonIds.length + ? fastify.db.personRepository.findBy({ id: In(taggedPersonIds) }) + : [], + linkedOpportunityIds.length + ? fastify.db.opportunityRepository.findBy({ + id: In(linkedOpportunityIds), + }) + : [], + ]); + + validateRelationIds(taggedPersonIds, taggedPersons, "tagged person"); + validateRelationIds( + linkedOpportunityIds, + linkedOpportunities, + "linked opportunity", + ); + + const post = fastify.db.postRepository.create({ + text, + authorId: personId, + agentId: agentPerson?.agentId ?? null, + taggedPersons, + linkedOpportunities, + }); + + const saved = await fastify.db.postRepository.save(post); + + const full = await fastify.db.postRepository.findOne({ + where: { id: saved.id }, + relations: ["author", "taggedPersons", "linkedOpportunities"], + }); + + if (!full) { + throw new NotFoundError("Post not found."); + } + return reply + .status(201) + .send({ message: "Post created.", data: dtoPost(full) }); + }, + ); + + // + // PATCH /post/:id + // + fastify.patch<{ + Params: ParamsId; + Body: ApiPostPatch; + Reply: ReplyData; + }>( + "/:id", + { + schema: { + params: idParamSchema, + body: { $ref: "ApiPostPatch#" }, + response: responseSchema("ApiPostGet#"), + }, + onRequest: [fastify.authenticate()], + }, + async (request, reply) => { + const { id } = request.params; + const { role } = request.user; + + if ( + role !== UserRole.ADMIN && + role !== UserRole.COORDINATOR && + role !== UserRole.AGENT + ) { + throw new UnauthorizedError("Permission denied."); + } + + const post = await fastify.db.postRepository.findOne({ + where: { id }, + relations: ["author", "taggedPersons", "linkedOpportunities"], + }); + if (!post) { + throw new NotFoundError(`Post ${id} not found.`); + } + + const isAuthor = request.authUser?.personId === post.authorId; + const isPrivileged = + role === UserRole.ADMIN || role === UserRole.COORDINATOR; + if (!isAuthor && !isPrivileged) { + throw new UnauthorizedError( + "Only the author, coordinators, or admins can edit posts.", + ); + } + + const { text, taggedPersonIds, linkedOpportunityIds } = request.body; + + if (text !== null && text !== undefined) { + post.text = text; + } + if (taggedPersonIds !== null && taggedPersonIds !== undefined) { + const found = taggedPersonIds.length + ? await fastify.db.personRepository.findBy({ + id: In(taggedPersonIds), + }) + : []; + validateRelationIds(taggedPersonIds, found, "tagged person"); + post.taggedPersons = found; + } + if (linkedOpportunityIds !== null && linkedOpportunityIds !== undefined) { + const found = linkedOpportunityIds.length + ? await fastify.db.opportunityRepository.findBy({ + id: In(linkedOpportunityIds), + }) + : []; + validateRelationIds(linkedOpportunityIds, found, "linked opportunity"); + post.linkedOpportunities = found; + } + + const updated = await fastify.db.postRepository.save(post); + return reply + .status(200) + .send({ message: `Post ${id} updated.`, data: dtoPost(updated) }); + }, + ); + + // + // DELETE /post/:id + // + fastify.delete<{ Params: ParamsId; Reply: ReplyMessage }>( + "/:id", + { + schema: { + params: idParamSchema, + response: responseSchema({ statusCode: 204 }), + }, + onRequest: [fastify.authenticate()], + }, + async (request, reply) => { + const { id } = request.params; + const { role } = request.user; + + if ( + role !== UserRole.ADMIN && + role !== UserRole.COORDINATOR && + role !== UserRole.AGENT + ) { + throw new UnauthorizedError("Permission denied."); + } + + const post = await fastify.db.postRepository.findOne({ where: { id } }); + if (!post) { + throw new NotFoundError(`Post ${id} not found.`); + } + + const isAuthor = request.authUser?.personId === post.authorId; + const isPrivileged = + role === UserRole.ADMIN || role === UserRole.COORDINATOR; + if (!isAuthor && !isPrivileged) { + throw new UnauthorizedError( + "Only the author, coordinators, or admins can delete posts.", + ); + } + + await fastify.db.postRepository.remove(post); + return reply.status(204).send(); + }, + ); +} diff --git a/src/server/routes/user.ts b/src/server/routes/user.ts index 64b03c68..5d9f660e 100644 --- a/src/server/routes/user.ts +++ b/src/server/routes/user.ts @@ -1,8 +1,6 @@ import { validate } from "class-validator"; import { FastifyInstance, FastifyPluginOptions } from "fastify"; import { - AgentMembershipStatus, - AgentRoleType, ApiUserGet, ApiUserPost, Lang, @@ -31,6 +29,7 @@ import { } from "../schema/user.schema"; import { QuerystringUserList, ReplyDataCount, RoutePrefix } from "../types"; import { getSkipTake, getUserWhere, isEmailDomainTrusted } from "../utils"; +import { getAgentPersonRepresentative } from "../utils/data/get-agent-person-representative"; export default async function userRoutes( fastify: FastifyInstance, @@ -163,24 +162,9 @@ export default async function userRoutes( } let agentId: number | undefined; - if (user.personId) { + if (user.role === UserRole.AGENT && user.personId) { // Prefer VOLUNTEER_COORDINATOR role; fall back to any active membership. - const membership = - (await fastify.db.agentPersonRepository.findOne({ - where: { - personId: user.personId, - status: AgentMembershipStatus.ACTIVE, - role: AgentRoleType.VOLUNTEER_COORDINATOR, - }, - order: { id: "ASC" }, - })) ?? - (await fastify.db.agentPersonRepository.findOne({ - where: { - personId: user.personId, - status: AgentMembershipStatus.ACTIVE, - }, - order: { id: "ASC" }, - })); + const membership = await getAgentPersonRepresentative(user.personId); agentId = membership?.agentId; } @@ -400,4 +384,89 @@ export default async function userRoutes( return reply.status(201).send(savedUser); }, ); + + // Admin-only user creation — accepts any role including admin/coordinator. + // Unlike POST /user/ this endpoint requires an authenticated admin session + // and activates the account immediately (no email verification flow). + fastify.post<{ + Body: ApiUserPost; + Reply: User | { message: string; errors?: any }; + }>( + "/admin", + { + schema: { + body: createUserBodySchema, + response: { + 201: userResponseSchemaIncludePerson, + ...responseErrors, + }, + }, + onRequest: [fastify.authenticate({ role: UserRole.ADMIN })], + preHandler: async (request) => { + const { person: personData, email } = request.body; + const personRepository = fastify.db.personRepository; + + if (personData.id) { + const resolvedPerson = await personRepository.findOneBy({ + id: personData.id, + }); + if (!resolvedPerson) { + throw new BadRequestError( + `Person with ID ${personData.id} not found.`, + ); + } + if (!resolvedPerson.email) { + resolvedPerson.email = email; + } + request.resolvedPerson = resolvedPerson; + return; + } + + const newPerson = new Person(personData); + newPerson.email = email; + const errors = await validate(newPerson); + if (errors.length > 0) { + const messages = errors.flatMap((err) => + Object.values(err.constraints || {}), + ); + throw new BadRequestError( + `Validation failed for new person data: ${messages.join("; ")}`, + ); + } + request.resolvedPerson = newPerson; + }, + }, + async (request, reply) => { + const { email, password: passwordPlain, role, language } = request.body; + const userRepository = fastify.db.userRepository; + + if (await userRepository.findOneBy({ email })) { + throw new ConflictError("User with this email already exists."); + } + + const newUser = new User({ + email, + password: await hashPassword(passwordPlain), + role, + isActive: true, + language: language ?? Lang.EN, + timezone: "CET", + person: request.resolvedPerson, + }); + + const errors = await validate(newUser); + if (errors.length > 0) { + logger.error( + `User entity validation errors: ${JSON.stringify(errors)}`, + ); + return reply.status(400).send({ + message: "Validation failed for newUser data", + errors: errors.flatMap((err) => Object.values(err.constraints || {})), + }); + } + + const savedUser = await userRepository.save(newUser); + return reply.status(201).send(savedUser); + }, + ); } diff --git a/src/server/routes/volunteer/communication.routes.ts b/src/server/routes/volunteer/communication.routes.ts index 43a1bdbf..470653c1 100644 --- a/src/server/routes/volunteer/communication.routes.ts +++ b/src/server/routes/volunteer/communication.routes.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyPluginOptions } from "fastify"; import { ApiVolunteerCommunicationPost, UserRole } from "need4deed-sdk"; +import { dtoCommunication } from "../../../services/dto/dto-communication"; import { idParamSchema } from "../../schema"; -import { maskForCaller } from "../../utils/pii/pre-serialization"; export default function volunteerCommunicationRoutes( fastify: FastifyInstance, @@ -10,6 +10,7 @@ export default function volunteerCommunicationRoutes( fastify.get<{ Params: { id: string } }>( "/", { + onRequest: fastify.authenticate({ role: UserRole.COORDINATOR }), schema: { params: idParamSchema, response: { @@ -27,17 +28,13 @@ export default function volunteerCommunicationRoutes( async (request, reply) => { const volunteerId = Number(request.params.id); - const communicationRepository = fastify.db.communicationRepository; - const communications = await communicationRepository.find({ - where: { - volunteerId, - }, + const communications = await fastify.db.communicationRepository.find({ + where: { volunteerId }, }); - await maskForCaller(request, communications); return reply.status(200).send({ message: `List of communications for volunteer_id:${volunteerId}`, - data: communications, + data: communications.map(dtoCommunication), }); }, ); @@ -64,8 +61,7 @@ export default function volunteerCommunicationRoutes( async (request, reply) => { const volunteerId = Number(request.params.id); - const communicationRepository = fastify.db.communicationRepository; - const communication = await communicationRepository.save({ + const communication = await fastify.db.communicationRepository.save({ ...request.body, userId: request.user.id, volunteerId, @@ -73,7 +69,7 @@ export default function volunteerCommunicationRoutes( return reply.status(201).send({ message: `Communication created for volunteer_id:${volunteerId}`, - data: communication, + data: dtoCommunication(communication), }); }, ); diff --git a/src/server/routes/volunteer/volunteer.routes.ts b/src/server/routes/volunteer/volunteer.routes.ts index 2f1e7c7f..bf5f27f7 100644 --- a/src/server/routes/volunteer/volunteer.routes.ts +++ b/src/server/routes/volunteer/volunteer.routes.ts @@ -26,6 +26,7 @@ import { } from "../../../services"; import { idParamSchema, + langQuerySchema, responseErrors, responseSchema, volunteerListQuerySchema, @@ -137,6 +138,7 @@ export default async function volunteerRoutes( { schema: { params: idParamSchema, + querystring: langQuerySchema, response: responseSchema("volunteer-api-id#"), }, }, @@ -267,6 +269,7 @@ export default async function volunteerRoutes( onRequest: fastify.authenticate({ role: UserRole.COORDINATOR }), schema: { params: idParamSchema, + querystring: langQuerySchema, body: { $ref: "volunteer-api-id-part#" }, response: { 200: { @@ -442,6 +445,7 @@ export default async function volunteerRoutes( { onRequest: fastify.authenticate({ role: UserRole.COORDINATOR }), schema: { + querystring: langQuerySchema, body: { $ref: "volunteer-form-data" }, response: { 201: { diff --git a/src/server/schema/opportunity-create.schema.ts b/src/server/schema/opportunity-create.schema.ts index b62e02e2..0e57342e 100644 --- a/src/server/schema/opportunity-create.schema.ts +++ b/src/server/schema/opportunity-create.schema.ts @@ -23,7 +23,7 @@ export const opportunityCreateBodySchema = { activities: { type: "array", items: { type: "string" } }, skills: { type: "array", items: { type: "string" } }, berlin_locations: { type: "array", items: { type: "string" } }, - timeslots: { type: "array" }, + timeslots: { type: ["array", "null"] }, onetime_date_time: { type: "string" }, accomp_address: { type: "string" }, accomp_postcode: { type: "string" }, diff --git a/src/server/schema/querystring.ts b/src/server/schema/querystring.ts index e80ecaf9..593e855f 100644 --- a/src/server/schema/querystring.ts +++ b/src/server/schema/querystring.ts @@ -7,6 +7,17 @@ const paginationProps = { }; const langProp = { language: { type: "string" } }; +export const paginationQuerySchema = { + type: "object", + properties: paginationProps, + additionalProperties: false, +}; + +export const langQuerySchema = { + type: "object", + properties: langProp, +}; + const sortOrderProps = { sortOrder: { type: "string", diff --git a/src/server/schema/sdk-types.json b/src/server/schema/sdk-types.json index 336369f2..2bad02cb 100644 --- a/src/server/schema/sdk-types.json +++ b/src/server/schema/sdk-types.json @@ -7,6 +7,69 @@ "type": "string", "enum": ["weekends", "weekdays"] }, + "ApiActivityLogEntry": { + "$id": "ApiActivityLogEntry", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "date": { + "type": "string", + "format": "date" + }, + "hours": { + "type": "number" + } + }, + "required": ["id", "date", "hours"] + }, + "ApiActivityLogGet": { + "$id": "ApiActivityLogGet", + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "ApiActivityLogEntry#" + } + }, + "totalHours": { + "type": "number" + }, + "count": { + "type": "integer" + } + }, + "required": ["data", "totalHours", "count"] + }, + "ApiActivityLogPost": { + "$id": "ApiActivityLogPost", + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "hours": { + "type": "number" + } + }, + "required": ["date", "hours"] + }, + "ApiActivityLogPatch": { + "$id": "ApiActivityLogPatch", + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "hours": { + "type": "number" + } + } + }, "Address": { "$id": "Address", "type": "object", @@ -147,34 +210,70 @@ "$id": "ApiComment", "type": "object", "properties": { - "id": { "type": "integer" }, - "content": { "type": "string" }, - "entityId": { "type": "number" }, - "entityType": { "type": "string" }, - "authorName": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" }, - "taggedPersonIds": { + "id": { + "type": "integer" + }, + "content": { + "type": "string" + }, + "entityId": { + "type": "integer" + }, + "entityType": { + "type": "string" + }, + "authorName": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "taggedPersons": { "type": "array", - "items": { "type": "integer" } + "items": { + "$ref": "ApiCommentTaggedPerson#" + } } - } + }, + "required": ["id", "content", "authorName", "timestamp", "taggedPersons"] }, "ApiPerson": { "$id": "ApiPerson", "type": "object", "properties": { - "id": { "type": "integer" }, - "avatarUrl": { "type": "string" }, - "firstName": { "type": "string" }, - "lastName": { "type": "string" }, - "middleName": { "type": "string" }, - "email": { "type": "string" }, - "phone": { "type": "string" }, - "landline": { "type": "string" }, - "address": { "$ref": "Address#" }, + "id": { + "type": "integer" + }, + "avatarUrl": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "middleName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "landline": { + "type": "string" + }, + "address": { + "$ref": "Address#" + }, "preferredComm": { "type": "array", - "items": { "$ref": "PreferredCommunicationType#" } + "items": { + "$ref": "PreferredCommunicationType#" + } } } }, @@ -265,8 +364,12 @@ "$id": "OptionItem", "type": "object", "properties": { - "title": { "type": "string" }, - "id": { "type": "integer" } + "title": { + "type": "string" + }, + "id": { + "type": "integer" + } }, "required": ["title", "id"], "additionalProperties": false @@ -294,7 +397,9 @@ "$id": "ApiAvailability", "type": "object", "properties": { - "id": { "type": "integer" }, + "id": { + "type": "integer" + }, "day": { "type": "string", "enum": [ @@ -351,9 +456,9 @@ }, "agentId": { "type": "integer" - }, - "additionalProperties": false + } }, + "additionalProperties": false, "required": ["id", "email", "role"] }, "DocumentType": { @@ -370,12 +475,25 @@ "$id": "ApiDocumentGet", "type": "object", "properties": { - "id": { "type": "integer" }, - "type": { "$ref": "DocumentType#" }, - "originalName": { "type": "string" }, - "url": { "type": "string" }, - "mimeType": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } + "id": { + "type": "integer" + }, + "type": { + "$ref": "DocumentType#" + }, + "originalName": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } }, "required": ["id", "type", "originalName", "url", "mimeType"] }, @@ -383,9 +501,15 @@ "$id": "ApiDocumentPost", "type": "object", "properties": { - "type": { "$ref": "DocumentType#" }, - "originalName": { "type": "string" }, - "mimeType": { "type": "string" } + "type": { + "$ref": "DocumentType#" + }, + "originalName": { + "type": "string" + }, + "mimeType": { + "type": "string" + } }, "required": ["type", "originalName", "mimeType"] }, @@ -422,28 +546,54 @@ "$id": "ApiCommunication", "type": "object", "properties": { - "contactType": { "$ref": "ContactType#" }, - "contactMethod": { "$ref": "ContactMethodType#" }, - "communicationType": { "$ref": "CommunicationType#" }, - "date": { "type": "string", "format": "date-time" } + "contactType": { + "$ref": "ContactType#" + }, + "contactMethod": { + "$ref": "ContactMethodType#" + }, + "communicationType": { + "$ref": "CommunicationType#" + }, + "date": { + "type": "string", + "format": "date-time" + } } }, "ApiCommunicationGet": { "$id": "ApiCommunicationGet", "type": "object", "allOf": [ - { "$ref": "ApiCommunication#" }, + { + "$ref": "ApiCommunication#" + }, { "type": "object", "properties": { - "id": { "type": "integer" }, - "userId": { "type": "number" }, - "agentId": { "type": "integer" }, - "volunteerId": { "type": "integer" } + "id": { + "type": "integer" + }, + "userId": { + "type": "number" + }, + "agentId": { + "type": "integer" + }, + "volunteerId": { + "type": "integer" + } } } ], - "oneOf": [{ "required": ["agentId"] }, { "required": ["volunteerId"] }], + "oneOf": [ + { + "required": ["agentId"] + }, + { + "required": ["volunteerId"] + } + ], "required": ["id", "contactType", "contactMethod", "date", "userId"] }, "ApiCommunicationPost": { @@ -459,13 +609,29 @@ "$id": "ApiAppreciation", "type": "object", "properties": { - "id": { "type": "integer" }, - "title": { "$ref": "VolunteerStateAppreciationType#" }, - "dateDue": { "type": ["string", "null"], "format": "date-time" }, - "dateDelivery": { "type": ["string", "null"], "format": "date-time" }, - "userId": { "type": "number" }, - "opportunityId": { "type": "number" }, - "volunteerId": { "type": "number" } + "id": { + "type": "integer" + }, + "title": { + "$ref": "VolunteerStateAppreciationType#" + }, + "dateDue": { + "type": ["string", "null"], + "format": "date-time" + }, + "dateDelivery": { + "type": ["string", "null"], + "format": "date-time" + }, + "userId": { + "type": "number" + }, + "opportunityId": { + "type": "number" + }, + "volunteerId": { + "type": "number" + } } }, "ApiAppreciationGet": { @@ -493,11 +659,22 @@ "$id": "VolunteerOpportunity", "type": "object", "properties": { - "id": { "type": "integer" }, - "status": { "$ref": "OpportunityVolunteerStatusType#" }, - "opportunityId": { "type": "number" }, - "volunteerId": { "type": "number" }, - "updatedAt": { "type": "string", "format": "date-time" } + "id": { + "type": "integer" + }, + "status": { + "$ref": "OpportunityVolunteerStatusType#" + }, + "opportunityId": { + "type": "number" + }, + "volunteerId": { + "type": "number" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } } }, "OpportunityVolunteer": { @@ -508,11 +685,15 @@ "$id": "ApiVolunteerOpportunityGet", "type": "object", "allOf": [ - { "$ref": "VolunteerOpportunity#" }, + { + "$ref": "VolunteerOpportunity#" + }, { "type": "object", "properties": { - "title": { "type": "string" } + "title": { + "type": "string" + } } } ], @@ -529,31 +710,56 @@ "$id": "ApiOpportunityVolunteerGet", "type": "object", "allOf": [ - { "$ref": "VolunteerOpportunity#" }, + { + "$ref": "VolunteerOpportunity#" + }, { "type": "object", "properties": { - "name": { "type": "string" }, - "volunteeringType": { "$ref": "VolunteerStateTypeType#" }, - "engagement": { "$ref": "VolunteerStateEngagementType#" }, - "communication": { "$ref": "VolunteerStateCommunicationType#" }, - "avatarUrl": { "type": "string" }, + "name": { + "type": "string" + }, + "volunteeringType": { + "$ref": "VolunteerStateTypeType#" + }, + "engagement": { + "$ref": "VolunteerStateEngagementType#" + }, + "communication": { + "$ref": "VolunteerStateCommunicationType#" + }, + "avatarUrl": { + "type": "string" + }, "activities": { "type": "array", - "items": { "$ref": "OptionItem#" } + "items": { + "$ref": "OptionItem#" + } + }, + "skills": { + "type": "array", + "items": { + "$ref": "OptionItem#" + } }, - "skills": { "type": "array", "items": { "$ref": "OptionItem#" } }, "locations": { "type": "array", - "items": { "$ref": "OptionItem#" } + "items": { + "$ref": "OptionItem#" + } }, "availability": { "type": "array", - "items": { "$ref": "ApiAvailability#" } + "items": { + "$ref": "ApiAvailability#" + } }, "languages": { "type": "array", - "items": { "$ref": "ApiLanguage#" } + "items": { + "$ref": "ApiLanguage#" + } } } } @@ -575,46 +781,91 @@ "$id": "ApiVolunteerOpportunityPatch", "type": "object", "properties": { - "statusOpportunity": { "$ref": "OpportunityStatusType#" }, - "numberVolunteers": { "type": "integer" }, - "description": { "type": "string" }, + "title": { + "type": "string" + }, + "statusOpportunity": { + "$ref": "OpportunityStatusType#" + }, + "numberVolunteers": { + "type": "integer" + }, + "description": { + "type": "string" + }, "languagesMain": { "type": "array", - "items": { "$ref": "OptionItem#" } + "items": { + "$ref": "OptionItem#" + } }, "languagesResidents": { "type": "array", - "items": { "$ref": "OptionItem#" } + "items": { + "$ref": "OptionItem#" + } + }, + "activities": { + "type": "array", + "items": { + "$ref": "OptionItem#" + } + }, + "skills": { + "type": "array", + "items": { + "$ref": "OptionItem#" + } }, - "activities": { "type": "array", "items": { "$ref": "OptionItem#" } }, - "skills": { "type": "array", "items": { "$ref": "OptionItem#" } }, "schedule": { "type": "array", - "items": { "$ref": "ApiAvailability#" } + "items": { + "$ref": "ApiAvailability#" + } }, "contact": { "type": "object", "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, - "phone": { "type": "string" }, - "email": { "type": "string" }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "email": { + "type": "string" + }, "waysToContact": { "type": "array", - "items": { "$ref": "PreferredCommunicationType#" } + "items": { + "$ref": "PreferredCommunicationType#" + } } } }, "agent": { "type": "object", "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, - "address": { "type": "string" }, - "district": { "type": "string" } + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "address": { + "type": "string" + }, + "district": { + "type": "string" + } } }, - "accompanyingDetails": { "$ref": "ApiAccompanyingPatch#" } + "accompanyingDetails": { + "$ref": "ApiAccompanyingPatch#" + } }, "additionalProperties": false }, @@ -622,9 +873,15 @@ "$id": "ApiVolunteerOpportunityPost", "type": "object", "properties": { - "opportunityId": { "type": "integer" }, - "volunteerId": { "type": "integer" }, - "status": { "$ref": "OpportunityVolunteerStatusType#" } + "opportunityId": { + "type": "integer" + }, + "volunteerId": { + "type": "integer" + }, + "status": { + "$ref": "OpportunityVolunteerStatusType#" + } }, "required": ["status"] }, @@ -660,31 +917,73 @@ "$id": "ApiOpportunityGetList", "type": "object", "properties": { - "id": { "type": "integer" }, - "title": { "type": "string" }, - "category": { "$ref": "OptionById#" }, - "district": { "$ref": "OptionById#" }, - "volunteerType": { "$ref": "VolunteerStateTypeType#" }, - "statusOpportunity": { "$ref": "OpportunityStatusType#" }, - "statusMatch": { "$ref": "OpportunityMatchStatusType#" }, - "numberOfVolunteers": { "type": "integer" }, - "createdAt": { "type": "string", "format": "date-time" }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "category": { + "$ref": "OptionById#" + }, + "district": { + "$ref": "OptionById#" + }, + "volunteerType": { + "$ref": "VolunteerStateTypeType#" + }, + "statusOpportunity": { + "$ref": "OpportunityStatusType#" + }, + "statusMatch": { + "$ref": "OpportunityMatchStatusType#" + }, + "numberOfVolunteers": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, "activities": { "type": "array", - "items": { "$ref": "OptionById#" } + "items": { + "$ref": "OptionById#" + } }, "languages": { "type": "array", - "items": { "$ref": "ApiLanguage#" } + "items": { + "$ref": "ApiLanguage#" + } }, "availability": { "type": "array", - "items": { "$ref": "ApiAvailability#" } + "items": { + "$ref": "ApiAvailability#" + } + }, + "location": { + "type": "array", + "items": { + "$ref": "OptionById#" + } }, - "location": { "type": "array", "items": { "$ref": "OptionById#" } }, - "accompanyingDetails": { "$ref": "ApiAccompanying#" }, - "agentTitle": { "type": "string" }, - "volunteerNames": { "type": "array", "items": { "type": "string" } } + "accompanyingDetails": { + "$ref": "ApiAccompanying#" + }, + "agentTitle": { + "type": "string" + }, + "agentId": { + "type": ["integer", "null"] + }, + "volunteerNames": { + "type": "array", + "items": { + "type": "string" + } + } }, "required": [ "id", @@ -701,45 +1000,90 @@ "availability", "location", "accompanyingDetails", - "agentTitle" + "agentTitle", + "agentId" ] }, "ApiOpportunityGet": { "$id": "ApiOpportunityGet", "allOf": [ - { "$ref": "ApiOpportunityGetList#" }, + { + "$ref": "ApiOpportunityGetList#" + }, { "type": "object", "properties": { - "createdAt": { "type": "string", "format": "date-time" }, - "description": { "type": "string" }, - "numberOfVolunteers": { "type": "integer" }, - "skills": { "type": "array", "items": { "$ref": "OptionById" } }, - "location": { "type": "array", "items": { "$ref": "OptionById" } }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string" + }, + "numberOfVolunteers": { + "type": "integer" + }, + "skills": { + "type": "array", + "items": { + "$ref": "OptionById" + } + }, + "location": { + "type": "array", + "items": { + "$ref": "OptionById" + } + }, "contact": { "type": "object", "properties": { - "id": { "type": "integer" }, - "name": { "type": "string" }, - "phone": { "type": "string" }, - "email": { "type": "string" }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "email": { + "type": "string" + }, "waysToContact": { "type": "array", - "items": { "$ref": "PreferredCommunicationType#" } + "items": { + "$ref": "PreferredCommunicationType#" + } } } }, "agent": { "type": "object", "properties": { - "id": { "type": "integer" }, - "type": { "$ref": "AgentType#" }, - "name": { "type": "string" }, - "address": { "type": "string" }, - "district": { "$ref": "OptionById#" } + "id": { + "type": "integer" + }, + "type": { + "$ref": "AgentType#" + }, + "name": { + "type": "string" + }, + "address": { + "type": "string" + }, + "district": { + "$ref": "OptionById#" + } } }, - "comments": { "type": "array", "items": { "$ref": "ApiComment#" } } + "comments": { + "type": "array", + "items": { + "$ref": "ApiComment#" + } + } } } ] @@ -748,11 +1092,21 @@ "$id": "ApiAgentOpportunityVolunteer", "type": "object", "properties": { - "id": { "type": "integer" }, - "volunteerId": { "type": "integer" }, - "status": { "$ref": "OpportunityVolunteerStatusType#" }, - "name": { "type": "string" }, - "avatarUrl": { "type": "string" } + "id": { + "type": "integer" + }, + "volunteerId": { + "type": "integer" + }, + "status": { + "$ref": "OpportunityVolunteerStatusType#" + }, + "name": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + } }, "required": ["id", "volunteerId", "status"] }, @@ -760,15 +1114,30 @@ "$id": "ApiAgentOpportunity", "type": "object", "properties": { - "id": { "type": "integer" }, - "title": { "type": "string" }, - "statusOpportunity": { "$ref": "OpportunityStatusType#" }, - "statusMatch": { "$ref": "OpportunityMatchStatusType#" }, - "numberOfVolunteers": { "type": "integer" }, - "createdAt": { "type": "string", "format": "date-time" }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "statusOpportunity": { + "$ref": "OpportunityStatusType#" + }, + "statusMatch": { + "$ref": "OpportunityMatchStatusType#" + }, + "numberOfVolunteers": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, "volunteers": { "type": "array", - "items": { "$ref": "ApiAgentOpportunityVolunteer#" } + "items": { + "$ref": "ApiAgentOpportunityVolunteer#" + } } }, "required": [ @@ -877,17 +1246,35 @@ "$id": "AgentDetails", "type": "object", "properties": { - "about": { "type": "string" }, - "address": { "type": "string" }, - "addressStreet": { "type": ["string", "null"] }, - "addressPostcode": { "type": ["string", "null"] }, - "website": { "type": "string" }, - "organizationType": { "type": "string" }, - "operator": { "type": "string" }, - "services": { "type": "string" }, + "about": { + "type": "string" + }, + "address": { + "type": "string" + }, + "addressStreet": { + "type": ["string", "null"] + }, + "addressPostcode": { + "type": ["string", "null"] + }, + "website": { + "type": "string" + }, + "organizationType": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "services": { + "type": "string" + }, "clientLanguages": { "type": "array", - "items": { "$ref": "OptionItem#" } + "items": { + "$ref": "OptionItem#" + } } } }, @@ -895,11 +1282,15 @@ "$id": "AgentRepresentative", "type": "object", "allOf": [ - { "$ref": "ApiPerson#" }, + { + "$ref": "ApiPerson#" + }, { "type": "object", "properties": { - "role": { "$ref": "AgentRoleType" } + "role": { + "$ref": "AgentRoleType" + } } } ] @@ -908,15 +1299,33 @@ "$id": "AgentList", "type": "object", "properties": { - "id": { "type": "integer" }, - "title": { "type": "string" }, - "type": { "$ref": "AgentType#" }, - "district": { "$ref": "Option#" }, - "trustLevel": { "$ref": "AgentTrustType#" }, - "activeVolunteers": { "type": "integer" }, - "numActiveVolunteers": { "type": "integer" }, - "email": { "type": "string" }, - "volunteerSearch": { "$ref": "AgentVolunteerSearchType#" } + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "$ref": "AgentType#" + }, + "district": { + "$ref": "Option#" + }, + "trustLevel": { + "$ref": "AgentTrustType#" + }, + "activeVolunteers": { + "type": "integer" + }, + "numActiveVolunteers": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "volunteerSearch": { + "$ref": "AgentVolunteerSearchType#" + } } }, "ApiAgentGetList": { @@ -929,6 +1338,7 @@ "trustLevel", "activeVolunteers", "numActiveVolunteers", + "numOpportunities", "email", "volunteerSearch" ] @@ -937,32 +1347,65 @@ "$id": "AgentProps", "type": "object", "properties": { - "title": { "type": "string" }, - "type": { "$ref": "AgentType#" }, - "volunteerSearch": { "$ref": "AgentVolunteerSearchType#" }, - "trustLevel": { "$ref": "AgentTrustType#" }, + "title": { + "type": "string" + }, + "type": { + "$ref": "AgentType#" + }, + "volunteerSearch": { + "$ref": "AgentVolunteerSearchType#" + }, + "trustLevel": { + "$ref": "AgentTrustType#" + }, "serviceType": { "type": "array", - "items": { "$ref": "AgentService#" } - }, - "statusEngagement": { "$ref": "AgentEngagementStatusType#" }, - "about": { "type": "string" }, - "website": { "type": "string" }, - "addressStreet": { "type": "string" }, - "addressPostcode": { "type": "string" }, - "statusSearch": { "$ref": "AgentVolunteerSearchType#" }, + "items": { + "$ref": "AgentService#" + } + }, + "statusEngagement": { + "$ref": "AgentEngagementStatusType#" + }, + "about": { + "type": "string" + }, + "website": { + "type": "string" + }, + "addressStreet": { + "type": "string" + }, + "addressPostcode": { + "type": "string" + }, + "statusSearch": { + "$ref": "AgentVolunteerSearchType#" + }, "services": { "type": "array", - "items": { "$ref": "AgentService#" } + "items": { + "$ref": "AgentService#" + } }, - "languages": { "type": "array", "items": { "$ref": "OptionById#" } }, - "districtId": { "type": "number" } + "languages": { + "type": "array", + "items": { + "$ref": "OptionById#" + } + }, + "districtId": { + "type": "number" + } } }, "AgentId": { "$id": "AgentId", "allOf": [ - { "$ref": "AgentList#" }, + { + "$ref": "AgentList#" + }, { "type": "object", "properties": { @@ -974,18 +1417,35 @@ "type": "string", "format": "date-time" }, - "operator": { "type": "string" }, - "representative": { "$ref": "AgentRepresentative#" }, + "operator": { + "type": "string" + }, + "representative": { + "$ref": "AgentRepresentative#" + }, "serviceType": { "type": "array", - "items": { "$ref": "AgentService#" } + "items": { + "$ref": "AgentService#" + } + }, + "statusEngagement": { + "$ref": "AgentEngagementStatusType#" + }, + "agentDetails": { + "$ref": "AgentDetails#" + }, + "comments": { + "type": "array", + "items": { + "$ref": "ApiComment#" + } }, - "statusEngagement": { "$ref": "AgentEngagementStatusType#" }, - "agentDetails": { "$ref": "AgentDetails#" }, - "comments": { "type": "array", "items": { "$ref": "ApiComment#" } }, "languages": { "type": "array", - "items": { "$ref": "ApiLanguage#" } + "items": { + "$ref": "ApiLanguage#" + } } } } @@ -1018,35 +1478,69 @@ "$id": "ApiAccompanying", "type": "object", "properties": { - "appointmentAddress": { "type": "string" }, - "appointmentDate": { "type": "string" }, - "appointmentTime": { "type": "string" }, - "refugeeNumber": { "type": "string" }, - "refugeeName": { "type": "string" }, - "appointmentLanguage": { "$ref": "TranslatedIntoType#" }, + "appointmentAddress": { + "type": "string" + }, + "appointmentDate": { + "type": "string" + }, + "appointmentTime": { + "type": "string" + }, + "refugeeNumber": { + "type": "string" + }, + "refugeeName": { + "type": "string" + }, + "appointmentLanguage": { + "$ref": "TranslatedIntoType#" + }, "refugeeLanguage": { "type": "array", - "items": { "$ref": "OptionById#" } + "items": { + "$ref": "OptionById#" + } + }, + "appointmentPostcode": { + "type": "string" }, - "appointmentPostcode": { "type": "string" }, - "appointmentDistrict": { "$ref": "OptionById#" } + "appointmentDistrict": { + "$ref": "OptionById#" + } } }, "ApiAccompanyingPatch": { "$id": "ApiAccompanyingPatch", "type": "object", "properties": { - "appointmentAddress": { "type": "string" }, - "appointmentDate": { "type": "string" }, - "appointmentTime": { "type": "string" }, - "refugeeNumber": { "type": "string" }, - "refugeeName": { "type": "string" }, - "appointmentLanguage": { "$ref": "TranslatedIntoType#" }, + "appointmentAddress": { + "type": "string" + }, + "appointmentDate": { + "type": "string" + }, + "appointmentTime": { + "type": "string" + }, + "refugeeNumber": { + "type": "string" + }, + "refugeeName": { + "type": "string" + }, + "appointmentLanguage": { + "$ref": "TranslatedIntoType#" + }, "refugeeLanguage": { "type": "array", - "items": { "$ref": "OptionById#" } + "items": { + "$ref": "OptionById#" + } }, - "appointmentPostcode": { "type": "string" } + "appointmentPostcode": { + "type": "string" + } }, "additionalProperties": false }, @@ -1054,11 +1548,110 @@ "$id": "ApiOrganizationPatch", "type": "object", "properties": { - "title": { "type": "string" }, - "email": { "type": "string" }, - "phone": { "type": "string" }, - "website": { "type": "string" } + "title": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "website": { + "type": "string" + } } + }, + "ApiCommentTaggedPerson": { + "$id": "ApiCommentTaggedPerson", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "readAt": { + "type": ["string", "null"], + "format": "date-time" + } + }, + "required": ["id", "readAt"] + }, + "ApiPostPerson": { + "$id": "ApiPostPerson", + "type": "object", + "properties": { + "id": { "type": "integer" }, + "fullName": { "type": "string" }, + "avatarUrl": { "type": "string" } + }, + "required": ["id", "fullName"] + }, + "ApiPostLinkedOpportunity": { + "$id": "ApiPostLinkedOpportunity", + "type": "object", + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" } + }, + "required": ["id", "title"] + }, + "ApiPostGet": { + "$id": "ApiPostGet", + "type": "object", + "properties": { + "id": { "type": "integer" }, + "text": { "type": "string" }, + "author": { "$ref": "ApiPostPerson#" }, + "agentId": { "type": ["integer", "null"] }, + "taggedPersons": { + "type": "array", + "items": { "$ref": "ApiPostPerson#" } + }, + "linkedOpportunities": { + "type": "array", + "items": { "$ref": "ApiPostLinkedOpportunity#" } + }, + "createdAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "id", + "text", + "author", + "agentId", + "taggedPersons", + "linkedOpportunities", + "createdAt" + ] + }, + "ApiPostPost": { + "$id": "ApiPostPost", + "type": "object", + "properties": { + "text": { "type": "string", "minLength": 1 }, + "taggedPersonIds": { "type": "array", "items": { "type": "integer" } }, + "linkedOpportunityIds": { + "type": "array", + "items": { "type": "integer" } + } + }, + "required": ["text"], + "additionalProperties": false + }, + "ApiPostPatch": { + "$id": "ApiPostPatch", + "type": "object", + "properties": { + "text": { "type": ["string", "null"], "minLength": 1 }, + "taggedPersonIds": { + "type": ["array", "null"], + "items": { "type": "integer" } + }, + "linkedOpportunityIds": { + "type": ["array", "null"], + "items": { "type": "integer" } + } + }, + "additionalProperties": false } } } diff --git a/src/server/schema/user.schema.ts b/src/server/schema/user.schema.ts index 0548f900..8af3892a 100644 --- a/src/server/schema/user.schema.ts +++ b/src/server/schema/user.schema.ts @@ -133,3 +133,33 @@ export const userResponseSchemaIncludePerson = { "person", ], }; + +export const messageResponseSchema = { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], +}; + +export const requestResetSchema = { + type: "object", + properties: { email: { type: "string", format: "email" } }, + required: ["email"], +}; + +export const resetPasswordSchema = { + type: "object", + properties: { + token: { type: "string" }, + newPassword: { type: "string", minLength: 8, maxLength: 50 }, + }, + required: ["token", "newPassword"], +}; + +export const changePasswordSchema = { + type: "object", + properties: { + password: { type: "string" }, + newPassword: { type: "string", minLength: 8, maxLength: 50 }, + }, + required: ["password", "newPassword"], +}; diff --git a/src/server/types/enums.ts b/src/server/types/enums.ts index 5d53840c..d3fc98ae 100644 --- a/src/server/types/enums.ts +++ b/src/server/types/enums.ts @@ -1,4 +1,5 @@ export const RoutePrefix = { + ACTIVITY_LOG: "/activity-log", AGENT: "/agent", AUTH: "/auth", APPRECIATION: "/appreciation", @@ -17,7 +18,11 @@ export const RoutePrefix = { ORGANIZATION: "/organization", OPTION: "/option", PERSON: "/person", + POST: "/post", REFRESH: "/refresh", + REQUEST_RESET: "/request-reset", + RESET_PASSWORD: "/password-reset", + CHANGE_PASSWORD: "/password-change", REGISTER: "/register", SWAGGER: "/swagger", TRUSTED_DOMAIN: "/trusted-domain", diff --git a/src/server/types/fastify.d.ts b/src/server/types/fastify.d.ts index 1158c7d9..4fab61a2 100644 --- a/src/server/types/fastify.d.ts +++ b/src/server/types/fastify.d.ts @@ -10,6 +10,7 @@ import Deal from "../../data/entity/deal.entity"; import Document from "../../data/entity/document.entity"; import FieldTranslation from "../../data/entity/field_translation.entity"; import Postcode from "../../data/entity/location/postcode.entity"; +import ActivityLog from "../../data/entity/m2m/activity-log.entity"; import AgentPerson from "../../data/entity/m2m/agent-person"; import OpportunityVolunteer from "../../data/entity/m2m/opportunity-volunteer"; import Agent from "../../data/entity/opportunity/agent.entity"; @@ -17,6 +18,7 @@ import Opportunity from "../../data/entity/opportunity/opportunity.entity"; import Option from "../../data/entity/option.entity"; import Organization from "../../data/entity/organization.entity"; import Person from "../../data/entity/person.entity"; +import Post from "../../data/entity/post.entity"; import Language from "../../data/entity/profile/language.entity"; import TrustedDomain from "../../data/entity/trusted-domain.entity"; import User from "../../data/entity/user.entity"; @@ -35,6 +37,7 @@ declare module "fastify" { commentRepository: Repository; documentRepository: Repository; communicationRepository: Repository; + activityLogRepository: Repository; appreciationRepository: Repository; opportunityRepository: Repository; opportunityVolunteerRepository: Repository; @@ -43,6 +46,7 @@ declare module "fastify" { agentPersonRepository: Repository; organizationRepository: Repository; postcodeRepository: Repository; + postRepository: Repository; trustedDomainRepository: Repository; }; jwt: JWT; @@ -61,7 +65,7 @@ declare module "@fastify/jwt" { // It's crucial to extend the original FastifyJWT interface here // so that your custom 'payload' and 'user' types merge correctly // with the types that @fastify/jwt already defines (like jwtSign and jwtVerify methods on reply/request). - type TokenType = "access" | "refresh" | "verify"; + type TokenType = "access" | "refresh" | "verify" | "reset"; interface FastifyJWT { // Payload type when signing a token (`reply.jwtSign(payload)`) payload: { diff --git a/src/server/utils/data/get-agent-by-postcode.ts b/src/server/utils/data/get-agent-by-postcode.ts index 75092475..d165e22c 100644 --- a/src/server/utils/data/get-agent-by-postcode.ts +++ b/src/server/utils/data/get-agent-by-postcode.ts @@ -5,8 +5,8 @@ function normalizeStreet(s: string): string { .trim() .toLowerCase() .replace(/\s*stra(?:ße|sse)\b/g, "str") // straße / strasse → str - .replace(/\s*str\./g, "str") // str. → str - .replace(/\s+str\b/g, "str"); // " str" → str + .replace(/\s*str\./g, "str") // str. → str + .replace(/\s+str\b/g, "str"); // " str" → str } const HOUSE_NUMBER_RE = /\s+(\d+[\w-]*(?:\s+[a-z]+)?)$/; @@ -22,7 +22,7 @@ function extractHouseNumber(s: string): string | undefined { } function agentHasPlz(a: Agent, plz: string): boolean { - if (a.address?.postcode?.value === plz) return true; + if (a.address?.postcode?.value === plz) {return true;} return !!a.agentPostcode?.some((ap) => ap.postcode?.value === plz); } @@ -34,34 +34,34 @@ function streetNameWordRegex(streetName: string): RegExp { export function getAgentByAddress( agents: Agent[], street: string, - plz: string, + plz?: string, ): Agent | undefined { const normStreet = normalizeStreet(street); // 1. Strict: agents created via new code have address.street set const strict = agents.find( (a) => - a.address?.postcode?.value === plz && + (!plz || a.address?.postcode?.value === plz) && normalizeStreet(a.address?.street ?? "") === normStreet, ); - if (strict) return strict; + if (strict) {return strict;} // 2. Fuzzy fallback for legacy agents: street name (no number) found as a - // whole word in agent title + PLZ from either address.postcode or agentPostcode. + // whole word in agent title. PLZ narrows the match when provided. // Number consistency: if the agent title contains a number it must match the // form's number — prevents "Heerstr 10" matching form input "Heerstr 110". // Agents with no number in title (e.g. "Refugium Hausvaterweg") match on // street name alone. const streetName = extractStreetName(street); - if (!streetName) return undefined; + if (!streetName) {return undefined;} const streetRegex = streetNameWordRegex(streetName); const houseNumber = extractHouseNumber(street); const fuzzyMatches = agents.filter((a) => { - if (!agentHasPlz(a, plz)) return false; - if (!streetRegex.test(normalizeStreet(a.title ?? ""))) return false; + if (plz && !agentHasPlz(a, plz)) {return false;} + if (!streetRegex.test(normalizeStreet(a.title ?? ""))) {return false;} const titleNumber = extractHouseNumber(a.title ?? ""); - if (titleNumber && houseNumber && titleNumber !== houseNumber) return false; + if (titleNumber && houseNumber && titleNumber !== houseNumber) {return false;} return true; }); diff --git a/src/server/utils/data/get-agent-person-representative.ts b/src/server/utils/data/get-agent-person-representative.ts new file mode 100644 index 00000000..2044a962 --- /dev/null +++ b/src/server/utils/data/get-agent-person-representative.ts @@ -0,0 +1,28 @@ +import { AgentMembershipStatus, AgentRoleType } from "need4deed-sdk"; +import { dataSource } from "../../../data/data-source"; +import AgentPerson from "../../../data/entity/m2m/agent-person"; +import { getRepository } from "../../../data/utils"; + +export async function getAgentPersonRepresentative( + personId: number, +): Promise { + const agentPersonRepository = getRepository(dataSource, AgentPerson); + const representative = + (await agentPersonRepository.findOne({ + where: { + personId, + status: AgentMembershipStatus.ACTIVE, + role: AgentRoleType.VOLUNTEER_COORDINATOR, + }, + order: { id: "ASC" }, + })) ?? + (await agentPersonRepository.findOne({ + where: { + personId, + status: AgentMembershipStatus.ACTIVE, + }, + order: { id: "ASC" }, + })); + + return representative; +} diff --git a/src/server/utils/data/index.ts b/src/server/utils/data/index.ts index aaf7a639..4ab789a8 100644 --- a/src/server/utils/data/index.ts +++ b/src/server/utils/data/index.ts @@ -14,8 +14,9 @@ export * from "./get-volunteer-clones"; export * from "./get-volunteer-form-data"; export * from "./get-volunteer-patch-data"; export * from "./get-volunteer-where"; -export * from "./sync-comment-tags"; export * from "./is-trusted-domain"; +export * from "./run-w-advisory-lock"; +export * from "./sync-comment-tags"; export * from "./update-leads"; export * from "./write-agent-registration"; export * from "./write-opportunity-contact-comment"; diff --git a/src/server/utils/data/log-email-communication.ts b/src/server/utils/data/log-email-communication.ts new file mode 100644 index 00000000..e72a241a --- /dev/null +++ b/src/server/utils/data/log-email-communication.ts @@ -0,0 +1,58 @@ +import { + CommunicationType, + ContactMethodType, + ContactType, +} from "need4deed-sdk"; +import { In, Repository } from "typeorm"; +import Communication from "../../../data/entity/communication.entity"; + +export async function logEmailCommunication( + repo: Repository, + communicationType: CommunicationType, + ids: { volunteerId?: number; opportunityId?: number; agentId?: number }, +): Promise { + return repo.save( + new Communication({ + contactType: ContactType.TEXT_EMAIL, + contactMethod: ContactMethodType.EMAIL, + communicationType, + ...ids, + }), + ); +} + +export async function buildSentPairSet( + repo: Repository, + volunteerIds: number[], + opportunityIds: number[], + communicationType: CommunicationType, +): Promise> { + const sentComms = await repo.find({ + where: { + volunteerId: In(volunteerIds), + opportunityId: In(opportunityIds), + communicationType, + }, + select: ["volunteerId", "opportunityId"], + }); + return new Set(sentComms.map((c) => `${c.volunteerId}-${c.opportunityId}`)); +} + +export async function buildLastSentMap( + repo: Repository, + opportunityIds: number[], + communicationType: CommunicationType, +): Promise> { + if (!opportunityIds.length) { + return new Map(); + } + const rows = await repo + .createQueryBuilder("c") + .select("c.opportunityId", "opportunityId") + .addSelect("MAX(c.date)", "date") + .where("c.opportunityId IN (:...ids)", { ids: opportunityIds }) + .andWhere("c.communicationType = :type", { type: communicationType }) + .groupBy("c.opportunityId") + .getRawMany<{ opportunityId: number; date: string | Date }>(); + return new Map(rows.map((r) => [Number(r.opportunityId), new Date(r.date)])); +} diff --git a/src/server/utils/data/run-w-advisory-lock.ts b/src/server/utils/data/run-w-advisory-lock.ts new file mode 100644 index 00000000..4831770e --- /dev/null +++ b/src/server/utils/data/run-w-advisory-lock.ts @@ -0,0 +1,35 @@ +import { dataSource } from "../../../data/data-source"; +import logger from "../../../logger"; + +export async function runWithAdvisoryLock( + fn: () => Promise, + lockId: number, +): Promise { + // Use a transaction-level advisory lock (pg_try_advisory_xact_lock) so the + // lock is released automatically at commit/rollback — no explicit unlock + // needed. This avoids the session-level pitfall where pg_advisory_unlock + // could fail and leave the connection holding the lock in the pool. + const qr = dataSource.createQueryRunner(); + try { + await qr.connect(); + await qr.startTransaction(); + const [row] = await qr.query( + "SELECT pg_try_advisory_xact_lock($1) AS acquired", + [lockId], + ); + if (!row?.acquired) { + logger.debug( + "scheduler: advisory lock held by another instance — skipping", + ); + await qr.rollbackTransaction(); + return; + } + await fn(); + await qr.commitTransaction(); + } catch (err) { + await qr.rollbackTransaction().catch(logger.error); + throw err; + } finally { + await qr.release(); + } +} diff --git a/src/server/utils/data/validate-relation-ids.ts b/src/server/utils/data/validate-relation-ids.ts new file mode 100644 index 00000000..3c117ba1 --- /dev/null +++ b/src/server/utils/data/validate-relation-ids.ts @@ -0,0 +1,15 @@ +import { BadRequestError } from "../../../config"; + +export function validateRelationIds( + requestedIds: number[], + foundEntities: { id: number }[], + label: string, +): void { + const existingIds = new Set(foundEntities.map((e) => e.id)); + const missing = [...new Set(requestedIds)].filter( + (id) => !existingIds.has(id), + ); + if (missing.length) { + throw new BadRequestError(`Invalid ${label} id(s): ${missing.join(", ")}`); + } +} diff --git a/src/services/dto/dto-activity-log.ts b/src/services/dto/dto-activity-log.ts new file mode 100644 index 00000000..9449f18a --- /dev/null +++ b/src/services/dto/dto-activity-log.ts @@ -0,0 +1,17 @@ +import { ApiActivityLogEntry, ApiActivityLogGet } from "need4deed-sdk"; +import ActivityLog from "../../data/entity/m2m/activity-log.entity"; + +export function dtoActivityLogEntry(log: ActivityLog): ApiActivityLogEntry { + return { + id: log.id, + date: log.date, + hours: log.hours, + }; +} + +export function dtoActivityLogGet(logs: ActivityLog[]): ApiActivityLogGet { + const data = logs.map(dtoActivityLogEntry); + const totalHours = + Math.round(logs.reduce((sum, l) => sum + l.hours, 0) * 100) / 100; + return { data, totalHours, count: data.length }; +} diff --git a/src/services/dto/dto-agent.ts b/src/services/dto/dto-agent.ts index e5263bc7..904e8aaf 100644 --- a/src/services/dto/dto-agent.ts +++ b/src/services/dto/dto-agent.ts @@ -13,6 +13,7 @@ import Opportunity from "../../data/entity/opportunity/opportunity.entity"; import { serializeAddress } from "./dto-address"; import { commentSerializer } from "./dto-comment"; import { dtoSerializePerson } from "./dto-person"; +import { getAvailability, getLanguages } from "./utils"; // Serializes an agent<->person membership for the moderation endpoints. // Expects the `agent` and `person` relations to be loaded. @@ -37,15 +38,20 @@ export function dtoAgentGetList(agent: Agent): ApiAgentGetList { trustLevel: agent.trustLevel, volunteerSearch: agent.searchStatus, activeVolunteers: agent.activeVolunteers, - numActiveVolunteers: agent.activeVolunteers, + numOpportunities: agent.opportunity?.length ?? 0, email: agent.representative?.person?.email || agent.organization?.email || "", district: { id: agent.districtId, title: { de: agent?.district?.title } }, }; } -type AgentDetailsExtended = AgentDetails & { addressStreet?: string | null; addressPostcode?: string | null }; -type ApiAgentGetExtended = Omit & { agentDetails: AgentDetailsExtended }; +type AgentDetailsExtended = AgentDetails & { + addressStreet?: string | null; + addressPostcode?: string | null; +}; +type ApiAgentGetExtended = Omit & { + agentDetails: AgentDetailsExtended; +}; export function dtoAgentGet( agent: Agent & { comments: Comment[] }, @@ -85,7 +91,10 @@ export function dtoOpportunityAgent(agent: Agent): ApiOpportunityAgent { }; } -function dtoAgentDetails(agent: Agent): AgentDetails & { addressStreet?: string | null; addressPostcode?: string | null } { +function dtoAgentDetails(agent: Agent): AgentDetails & { + addressStreet?: string | null; + addressPostcode?: string | null; +} { return { about: agent.info, address: serializeAddress(agent.address), @@ -115,10 +124,20 @@ export function dtoAgentOpportunity( return { id: opportunity.id, title: opportunity.title, + volunteerType: opportunity.type, statusOpportunity: opportunity.status, statusMatch: opportunity.statusMatch, numberOfVolunteers: opportunity.numberVolunteers, createdAt: opportunity.createdAt, + district: { id: opportunity.district?.id ?? opportunity.districtId }, + languages: getLanguages(opportunity.deal?.dealLanguage ?? []), + activities: (opportunity.deal?.dealActivity ?? []) + .filter(Boolean) + .map((da) => ({ id: da.activity.id })), + location: (opportunity.deal?.dealDistrict ?? []) + .filter(Boolean) + .map((dd) => ({ id: dd.district.id })), + availability: getAvailability(opportunity.deal?.dealTimeslot ?? []) ?? [], volunteers: (opportunity.opportunityVolunteer ?? []) .filter(Boolean) .map((ov) => ({ diff --git a/src/services/dto/dto-comment.ts b/src/services/dto/dto-comment.ts index a2a0368d..a042389c 100644 --- a/src/services/dto/dto-comment.ts +++ b/src/services/dto/dto-comment.ts @@ -9,7 +9,9 @@ export function commentSerializer(comment: Comment): ApiComment { entityType: comment.entityType, authorName: comment.user?.person?.name, timestamp: comment.updatedAt, - taggedPersonIds: - comment.commentPerson?.map((cp) => cp.personId).filter(Boolean) ?? [], + taggedPersons: + comment.commentPerson + ?.filter((cp) => cp.personId) + .map((cp) => ({ id: cp.personId, readAt: cp.readAt ?? null })) ?? [], }; } diff --git a/src/services/dto/dto-communication.ts b/src/services/dto/dto-communication.ts index 6baefad5..a7df27c2 100644 --- a/src/services/dto/dto-communication.ts +++ b/src/services/dto/dto-communication.ts @@ -10,6 +10,8 @@ export function dtoCommunication( contactMethod: communication.contactMethod, communicationType: communication.communicationType, date: communication.date, + volunteerId: communication.volunteerId, + opportunityId: communication.opportunityId, agentId: communication.agentId, userId: communication.userId, }; diff --git a/src/services/dto/dto-opportunity.ts b/src/services/dto/dto-opportunity.ts index 2fb9e20e..c3320732 100644 --- a/src/services/dto/dto-opportunity.ts +++ b/src/services/dto/dto-opportunity.ts @@ -82,6 +82,7 @@ export function dtoOpportunityGetList( availability: getAvailabilityTryCatch(opportunity.deal.dealTimeslot) ?? [], accompanyingDetails: dtoOpportunityAccompanying(opportunity.accompanying!), agentTitle: opportunity.agent?.title ?? "", + agentId: opportunity.agentId, // Names of the volunteers MATCHED to the opportunity (status opp-matched // only — not pending/active/past links). PII masking runs before this DTO, // so masked names pass through. Needs the @@ -143,6 +144,7 @@ export function dtoOpportunityGet( description: getOpportunityDescription(opportunityComments) ?? "", numberOfVolunteers: opportunityComments.numberVolunteers, agentTitle: opportunityComments.agent?.title ?? "", + agentId: opportunityComments.agentId, languages: opportunityComments.deal.dealLanguage .filter(Boolean) .map((pl) => ({ diff --git a/src/services/dto/dto-post.ts b/src/services/dto/dto-post.ts new file mode 100644 index 00000000..99e6e442 --- /dev/null +++ b/src/services/dto/dto-post.ts @@ -0,0 +1,25 @@ +import { ApiPostGet } from "need4deed-sdk"; +import Post from "../../data/entity/post.entity"; + +export function dtoPost(post: Post): ApiPostGet { + return { + id: post.id, + text: post.text, + author: { + id: post.author.id, + fullName: post.author.name, + avatarUrl: post.author.avatarUrl, + }, + agentId: post.agentId, + taggedPersons: (post.taggedPersons ?? []).map((p) => ({ + id: p.id, + fullName: p.name, + avatarUrl: p.avatarUrl, + })), + linkedOpportunities: (post.linkedOpportunities ?? []).map((o) => ({ + id: o.id, + title: o.title, + })), + createdAt: post.createdAt, + }; +} diff --git a/src/services/dto/dto-user.ts b/src/services/dto/dto-user.ts index 5f2c082d..ee16461f 100644 --- a/src/services/dto/dto-user.ts +++ b/src/services/dto/dto-user.ts @@ -1,10 +1,7 @@ import { ApiUserGet } from "need4deed-sdk"; import User from "../../data/entity/user.entity"; -export function serializeUserToMeDTO( - user: User, - agentId?: number, -): ApiUserGet & { agentId?: number } { +export function serializeUserToMeDTO(user: User, agentId?: number): ApiUserGet { return { id: user.id, // personId is nullable (person-less users); emit undefined so the diff --git a/src/services/dto/parser-opportunity-patch-data.ts b/src/services/dto/parser-opportunity-patch-data.ts index 50168292..2af13f6e 100644 --- a/src/services/dto/parser-opportunity-patch-data.ts +++ b/src/services/dto/parser-opportunity-patch-data.ts @@ -30,6 +30,7 @@ export function parseOpportunity(body: ApiOpportunityPatch) { return { ...getEmptyPropsNull({ opportunity: { + title: body.title, status: body.statusOpportunity, numberVolunteers: body.numberVolunteers, info: body.description, diff --git a/src/services/jobs/german-holidays.ts b/src/services/jobs/german-holidays.ts new file mode 100644 index 00000000..8b6a502d --- /dev/null +++ b/src/services/jobs/german-holidays.ts @@ -0,0 +1,130 @@ +function easterSunday(year: number): Date { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31) - 1; + const day = ((h + l - 7 * m + 114) % 31) + 1; + return new Date(year, month, day); +} + +function addDays(date: Date, n: number): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n); +} + +function sameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export function isGermanPublicHoliday(date: Date): boolean { + const y = date.getFullYear(); + const easter = easterSunday(y); + + const holidays = [ + new Date(y, 0, 1), // Neujahr + new Date(y, 2, 8), // Internationaler Frauentag (Berlin) + new Date(y, 4, 1), // Tag der Arbeit + new Date(y, 9, 3), // Tag der Deutschen Einheit + new Date(y, 11, 25), // 1. Weihnachtstag + new Date(y, 11, 26), // 2. Weihnachtstag + addDays(easter, -2), // Karfreitag + addDays(easter, 1), // Ostermontag + addDays(easter, 39), // Christi Himmelfahrt + addDays(easter, 50), // Pfingstmontag + ]; + + return holidays.some((h) => sameDay(h, date)); +} + +export function addWorkingDays(from: Date, days: number): Date { + let result = new Date(from.getFullYear(), from.getMonth(), from.getDate()); + const direction = days < 0 ? -1 : 1; + const remaining = Math.abs(days); + let added = 0; + while (added < remaining) { + result = addDays(result, direction); + const dow = result.getDay(); + if (dow !== 0 && dow !== 6 && !isGermanPublicHoliday(result)) { + added++; + } + } + return result; +} + +// Returns a Date n calendar months before now, clamping the day to the last +// day of the target month to avoid setMonth overflow (e.g. Apr 30 - 2 months +// should be Feb 28, not Mar 2). +export function monthsAgo(n: number): Date { + const now = new Date(); + const targetMonth = now.getMonth() - n; + const y = now.getFullYear() + Math.floor(targetMonth / 12); + const m = ((targetMonth % 12) + 12) % 12; + const maxDay = new Date(y, m + 1, 0).getDate(); + return new Date(y, m, Math.min(now.getDate(), maxDay)); +} + +export function berlinToday(): Date { + const s = new Date().toLocaleDateString("sv-SE", { + timeZone: "Europe/Berlin", + }); + const [y, m, d] = s.split("-").map(Number); + return new Date(y, m - 1, d); +} + +// Returns UTC timestamps for the start and end of the given Berlin calendar day. +// Necessary because Node.js `new Date(y, m, d)` uses the process-local timezone +// (UTC on AWS), so it would not correctly represent Berlin midnight. +export function berlinDayBoundaries(berlinDate: Date): { + startOfDay: Date; + endOfDay: Date; +} { + const y = berlinDate.getFullYear(); + const m = berlinDate.getMonth(); + const d = berlinDate.getDate(); + // Sample the Berlin UTC offset at noon on the target day. + // DST transitions happen at 02:00 in Europe, so noon is always post-transition. + // We use formatToParts (not toLocaleString + new Date()) because toLocaleString + // + new Date() parsing is implementation-defined and produces wrong offsets on + // non-UTC developer machines. formatToParts extracts numeric field values + // independently of the process local timezone. + const noonUTC = new Date(Date.UTC(y, m, d, 12)); + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(noonUTC); + const get = (type: string) => + parseInt(parts.find((p) => p.type === type)!.value, 10); + // Treating the Berlin wall-clock values as UTC gives us a timestamp we can + // subtract from noonUTC to obtain the Berlin UTC offset in milliseconds. + const berlinNoonAsUTC = Date.UTC( + get("year"), + get("month") - 1, + get("day"), + get("hour") === 24 ? 0 : get("hour"), + get("minute"), + get("second"), + ); + const berlinOffsetMs = berlinNoonAsUTC - noonUTC.getTime(); + return { + startOfDay: new Date(Date.UTC(y, m, d, 0) - berlinOffsetMs), + endOfDay: new Date(Date.UTC(y, m, d, 23, 59, 59, 999) - berlinOffsetMs), + }; +} diff --git a/src/services/jobs/scan-accompany-not-found.ts b/src/services/jobs/scan-accompany-not-found.ts new file mode 100644 index 00000000..584b83ab --- /dev/null +++ b/src/services/jobs/scan-accompany-not-found.ts @@ -0,0 +1,87 @@ +import { FastifyInstance } from "fastify"; +import { + CommunicationType, + OpportunityStatusType, + OpportunityType, + OpportunityVolunteerStatusType, +} from "need4deed-sdk"; +import { Between, In } from "typeorm"; +import logger from "../../logger"; +import { + buildLastSentMap, + logEmailCommunication, +} from "../../server/utils/data/log-email-communication"; +import { + addWorkingDays, + berlinDayBoundaries, + berlinToday, +} from "./german-holidays"; + +export async function scanAccompanyNotFound( + fastify: FastifyInstance, +): Promise { + const targetDay = addWorkingDays(berlinToday(), 4); + const { startOfDay, endOfDay } = berlinDayBoundaries(targetDay); + + const opps = await fastify.db.opportunityRepository.find({ + where: { + type: OpportunityType.ACCOMPANYING as never, + status: In([ + OpportunityStatusType.NEW, + OpportunityStatusType.SEARCHING, + OpportunityStatusType.ACTIVE, + ]), + accompanying: { date: Between(startOfDay, endOfDay) }, + }, + relations: [ + "accompanying", + "accompanying.postcode", + "contactPerson", + "contactPerson.users", + "district", + "opportunityVolunteer", + ], + }); + + const candidates = opps.filter( + (opp) => + !opp.opportunityVolunteer?.some( + (ov) => ov.status === OpportunityVolunteerStatusType.MATCHED, + ), + ); + + if (!candidates.length) { + return; + } + + const lastSentMap = await buildLastSentMap( + fastify.db.communicationRepository, + candidates.map((c) => c.id), + CommunicationType.ACCOMPANYING_NOT_FOUND, + ); + + for (const opp of candidates) { + try { + const lastSent = lastSentMap.get(opp.id); + if (lastSent && lastSent > opp.updatedAt) { + continue; + } + + const comm = await logEmailCommunication( + fastify.db.communicationRepository, + CommunicationType.ACCOMPANYING_NOT_FOUND, + { opportunityId: opp.id }, + ); + try { + await fastify.notify.emailAccompanyNotFound(opp); + } catch (sendErr) { + await fastify.db.communicationRepository + .remove(comm) + .catch(logger.error); + throw sendErr; + } + } catch (err) { + logger.error(`scanAccompanyNotFound: opp ${opp.id} failed: ${err}`); + } + } +} diff --git a/src/services/jobs/scan-expired-onetimers.ts b/src/services/jobs/scan-expired-onetimers.ts new file mode 100644 index 00000000..0848d533 --- /dev/null +++ b/src/services/jobs/scan-expired-onetimers.ts @@ -0,0 +1,84 @@ +import { FastifyInstance } from "fastify"; +import { + OpportunityStatusType, + OpportunityType, + OpportunityVolunteerStatusType, +} from "need4deed-sdk"; +import { Brackets } from "typeorm"; +import logger from "../../logger"; +import { addWorkingDays, berlinToday } from "./german-holidays"; + +export async function scanExpiredOnetimers( + fastify: FastifyInstance, +): Promise { + const dayBeforeToday = addWorkingDays(berlinToday(), -1); + + const expiredOpportunities = await fastify.db.opportunityRepository + .createQueryBuilder("opportunity") + .leftJoinAndSelect("opportunity.accompanying", "accompanying") + .leftJoinAndSelect("opportunity.deal", "deal") + .leftJoinAndSelect("deal.dealTimeslot", "dealTimeslot") + .leftJoinAndSelect("dealTimeslot.timeslot", "timeslot") + .leftJoinAndSelect( + "opportunity.opportunityVolunteer", + "opportunityVolunteer", + ) + .where("opportunity.type IN (:...types)", { + types: [OpportunityType.ACCOMPANYING, OpportunityType.EVENTS], + }) + .andWhere("opportunity.status != :inactive", { + inactive: OpportunityStatusType.INACTIVE, + }) + .andWhere( + new Brackets((qb) => { + qb.where("accompanying.date < :yesterday", { + yesterday: dayBeforeToday, + }).orWhere("timeslot.start < :yesterday", { + yesterday: dayBeforeToday, + }); + }), + ) + .getMany(); + + if (!expiredOpportunities.length) { + return; + } + + for (const opportunity of expiredOpportunities) { + try { + opportunity.status = OpportunityStatusType.INACTIVE; + await fastify.db.opportunityRepository.save(opportunity); + + for (const opportunityVolunteer of opportunity.opportunityVolunteer) { + if ( + opportunityVolunteer.status === OpportunityVolunteerStatusType.MATCHED + ) { + try { + opportunityVolunteer.status = OpportunityVolunteerStatusType.PAST; + await fastify.db.opportunityVolunteerRepository.save( + opportunityVolunteer, + ); + } catch (err) { + logger.error( + { + err, + opportunityId: opportunity.id, + opportunityVolunteerId: opportunityVolunteer.id, + }, + "scanExpiredOnetimers: failed to mark opportunity volunteer as PAST", + ); + } + } + } + } catch (err) { + logger.error( + { err, opportunityId: opportunity.id }, + "scanExpiredOnetimers: failed to mark opportunity as INACTIVE", + ); + } + } + + logger.info( + `scanExpiredOnetimers: marked ${expiredOpportunities.length} opportunities as INACTIVE`, + ); +} diff --git a/src/services/jobs/scan-post-match-checkup.ts b/src/services/jobs/scan-post-match-checkup.ts new file mode 100644 index 00000000..0aacccc4 --- /dev/null +++ b/src/services/jobs/scan-post-match-checkup.ts @@ -0,0 +1,63 @@ +import { FastifyInstance } from "fastify"; +import { + CommunicationType, + OpportunityVolunteerStatusType, + VolunteerStateEngagementType, +} from "need4deed-sdk"; +import { LessThan } from "typeorm"; +import logger from "../../logger"; +import { + buildSentPairSet, + logEmailCommunication, +} from "../../server/utils/data/log-email-communication"; +import { monthsAgo } from "./german-holidays"; + +export async function scanPostMatchCheckup( + fastify: FastifyInstance, +): Promise { + const twoMonthsAgo = monthsAgo(2); + + const ovs = await fastify.db.opportunityVolunteerRepository.find({ + where: { + status: OpportunityVolunteerStatusType.MATCHED, + updatedAt: LessThan(twoMonthsAgo), + volunteer: { statusEngagement: VolunteerStateEngagementType.AVAILABLE }, + }, + relations: ["volunteer.person", "volunteer.person.users"], + }); + + if (!ovs.length) { + return; + } + + const alreadySentSet = await buildSentPairSet( + fastify.db.communicationRepository, + ovs.map((ov) => ov.volunteerId), + ovs.map((ov) => ov.opportunityId), + CommunicationType.POST_FOLLOWUP, + ); + + for (const ov of ovs) { + try { + if (alreadySentSet.has(`${ov.volunteerId}-${ov.opportunityId}`)) { + continue; + } + + const comm = await logEmailCommunication( + fastify.db.communicationRepository, + CommunicationType.POST_FOLLOWUP, + { volunteerId: ov.volunteerId, opportunityId: ov.opportunityId }, + ); + try { + await fastify.notify.emailPostMatchCheckup(ov); + } catch (sendErr) { + await fastify.db.communicationRepository + .remove(comm) + .catch(logger.error); + throw sendErr; + } + } catch (err) { + logger.error(`scanPostMatchCheckup: ov ${ov.id} failed: ${err}`); + } + } +} diff --git a/src/services/jobs/scan-regular-update.ts b/src/services/jobs/scan-regular-update.ts new file mode 100644 index 00000000..fafce526 --- /dev/null +++ b/src/services/jobs/scan-regular-update.ts @@ -0,0 +1,67 @@ +import { FastifyInstance } from "fastify"; +import { + CommunicationType, + OpportunityStatusType, + OpportunityType, +} from "need4deed-sdk"; +import { In, LessThan } from "typeorm"; +import logger from "../../logger"; +import { + buildLastSentMap, + logEmailCommunication, +} from "../../server/utils/data/log-email-communication"; +import { monthsAgo } from "./german-holidays"; + +export async function scanRegularUpdate( + fastify: FastifyInstance, +): Promise { + const twoMonthsAgo = monthsAgo(2); + + const opps = await fastify.db.opportunityRepository.find({ + where: { + type: OpportunityType.REGULAR as never, + status: In([ + OpportunityStatusType.NEW, + OpportunityStatusType.SEARCHING, + OpportunityStatusType.ACTIVE, + ]), + updatedAt: LessThan(twoMonthsAgo), + }, + relations: ["contactPerson", "contactPerson.users"], + }); + + if (!opps.length) { + return; + } + + const lastSentMap = await buildLastSentMap( + fastify.db.communicationRepository, + opps.map((o) => o.id), + CommunicationType.OPPORTUNITY_UPDATED, + ); + + for (const opp of opps) { + try { + const lastSent = lastSentMap.get(opp.id); + if (lastSent && lastSent > opp.updatedAt) { + continue; + } + + const comm = await logEmailCommunication( + fastify.db.communicationRepository, + CommunicationType.OPPORTUNITY_UPDATED, + { opportunityId: opp.id }, + ); + try { + await fastify.notify.emailRegularUpdate(opp); + } catch (sendErr) { + await fastify.db.communicationRepository + .remove(comm) + .catch(logger.error); + throw sendErr; + } + } catch (err) { + logger.error(`scanRegularUpdate: opp ${opp.id} failed: ${err}`); + } + } +} diff --git a/src/services/jobs/scan-stale-pending.ts b/src/services/jobs/scan-stale-pending.ts new file mode 100644 index 00000000..cfbe56b3 --- /dev/null +++ b/src/services/jobs/scan-stale-pending.ts @@ -0,0 +1,63 @@ +import { FastifyInstance } from "fastify"; +import { + CommunicationType, + OpportunityVolunteerStatusType, + VolunteerStateEngagementType, +} from "need4deed-sdk"; +import { LessThan } from "typeorm"; +import logger from "../../logger"; +import { + buildSentPairSet, + logEmailCommunication, +} from "../../server/utils/data/log-email-communication"; +import { monthsAgo } from "./german-holidays"; + +export async function scanStalePending( + fastify: FastifyInstance, +): Promise { + const twoMonthsAgo = monthsAgo(2); + + const ovs = await fastify.db.opportunityVolunteerRepository.find({ + where: { + status: OpportunityVolunteerStatusType.PENDING, + updatedAt: LessThan(twoMonthsAgo), + volunteer: { statusEngagement: VolunteerStateEngagementType.AVAILABLE }, + }, + relations: ["volunteer.person", "volunteer.person.users"], + }); + + if (!ovs.length) { + return; + } + + const alreadySentSet = await buildSentPairSet( + fastify.db.communicationRepository, + ovs.map((ov) => ov.volunteerId), + ovs.map((ov) => ov.opportunityId), + CommunicationType.STATUS_UPDATE, + ); + + for (const ov of ovs) { + try { + if (alreadySentSet.has(`${ov.volunteerId}-${ov.opportunityId}`)) { + continue; + } + + const comm = await logEmailCommunication( + fastify.db.communicationRepository, + CommunicationType.STATUS_UPDATE, + { volunteerId: ov.volunteerId, opportunityId: ov.opportunityId }, + ); + try { + await fastify.notify.emailStale(ov); + } catch (sendErr) { + await fastify.db.communicationRepository + .remove(comm) + .catch(logger.error); + throw sendErr; + } + } catch (err) { + logger.error(`scanStalePending: ov ${ov.id} failed: ${err}`); + } + } +} diff --git a/src/services/notify/email-template.ts b/src/services/notify/email-template.ts new file mode 100644 index 00000000..45fae72d --- /dev/null +++ b/src/services/notify/email-template.ts @@ -0,0 +1,115 @@ +import { Lang } from "need4deed-sdk"; +import { + emailTemplateFetchTimeoutMs, + emailTemplateTtlMs, +} from "../../config/constants"; +import { fetchJsonFromUrl } from "../../data/utils"; +import logger from "../../logger"; + +export interface LocaleContent { + subject: string; + html?: string; + text?: string; +} + +export type Manifest = Partial>; +export type TemplateVars = Record; + +const DEFAULT_LOCALE = Lang.EN; +const PLACEHOLDER_RE = /\{\{\s*(\w+)\s*\}\}/g; + +/** + * Replace all {{ key }} placeholders in the template content with values from + * vars. Handles optional whitespace around keys. Each placeholder that has no + * matching key in vars is left unchanged and a warning is logged so template + * mismatches surface early. + */ +export function fillTemplate( + content: LocaleContent, + vars: TemplateVars, +): { subject: string; html?: string; text?: string } { + const fill = (s: string): string => + s.replace(PLACEHOLDER_RE, (match, key: string) => { + if (!(key in vars)) { + logger.warn(`email template: unresolved placeholder {{${key}}}`); + return match; + } + return String(vars[key]); + }); + + return { + subject: fill(content.subject), + ...(content.html !== undefined ? { html: fill(content.html) } : {}), + ...(content.text !== undefined ? { text: fill(content.text) } : {}), + }; +} + +export function resolveLocale(language: string | undefined): Lang { + return language === Lang.DE + ? Lang.DE + : language === Lang.EN + ? Lang.EN + : DEFAULT_LOCALE; +} + +function isValid(content: LocaleContent | undefined): content is LocaleContent { + return Boolean(content?.subject && (content.html || content.text)); +} + +export function resolveContent( + manifest: Manifest | null, + locale: Lang, + builtin: Record, +): LocaleContent { + const candidates = [manifest?.[locale], manifest?.[DEFAULT_LOCALE]]; + return candidates.find(isValid) ?? builtin[locale] ?? builtin[DEFAULT_LOCALE]; +} + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer!); + } +} + +/** + * Returns a cached CDN manifest loader bound to a specific URL. Each email + * type creates its own loader; they share the same TTL/timeout config but keep + * separate caches. resetCache() is exposed for test isolation. + */ +export function createManifestLoader(url: string): { + load(): Promise; + resetCache(): void; +} { + let cache: { value: Manifest; expires: number } | null = null; + + return { + resetCache() { + cache = null; + }, + async load(): Promise { + const now = Date.now(); + if (cache && now < cache.expires) { + return cache.value; + } + try { + const value = (await withTimeout( + fetchJsonFromUrl(url), + emailTemplateFetchTimeoutMs, + )) as Manifest; + cache = { value, expires: now + emailTemplateTtlMs }; + return value; + } catch (err) { + logger.warn( + `email manifest fetch failed (${url}): ${err instanceof Error ? err.message : err}`, + ); + return cache?.value ?? null; + } + }, + }; +} diff --git a/src/services/notify/events/email-accompany-match.ts b/src/services/notify/events/email-accompany-match.ts new file mode 100644 index 00000000..ccea07db --- /dev/null +++ b/src/services/notify/events/email-accompany-match.ts @@ -0,0 +1,131 @@ +import { Lang } from "need4deed-sdk"; +import { + emailAccompanyMatchManifestUrl, + emailFromContact, + emailFromNotify, +} from "../../../config/constants"; +import OpportunityVolunteer from "../../../data/entity/m2m/opportunity-volunteer"; +import { getLanguages } from "../../dto/utils"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailTransport } from "../types"; + +const BUILTIN: Record = { + [Lang.EN]: { + subject: + "Accompanying to an appointment on {{ appointmentDate }} in {{ appointmentDistrict }} for {{ clientName }}", + text: `Dear {{ contactpersonName }},\n\n{{ volunteerName }} would be glad to provide interpreting support for this appointment. {{ volunteerName }} speaks {{ volunteerLanguage }}.\n\n{{ volunteerName }} has already received {{ clientName }}'s contact details and will get in touch with them shortly.\n\n{{ contactSharing }}\n\nBest regards,\nNeed4Deed`, + }, + [Lang.DE]: { + subject: + "Begleitung zum Termin am {{ appointmentDate }} in {{ appointmentDistrict }} für {{ clientName }}", + text: `Hallo {{ contactpersonName }},\n\n{{ volunteerName }} übernimmt gerne die Sprachmittlung für diesen Termin. {{ volunteerName }} spricht {{ volunteerLanguage }}.\n\n{{ volunteerName }} hat schon die Kontaktdaten von {{ clientName }} bekommen und meldet sich zeitnah bei der Person.\n\n{{ contactSharing }}\n\nViele Grüße\nNeed4Deed`, + }, +}; + +const loader = createManifestLoader(emailAccompanyMatchManifestUrl); + +export function resetAccompanyMatchTemplateCache(): void { + loader.resetCache(); +} + +function resolveContactSharing( + shareContact: boolean, + volunteerName: string, + volunteerEmail: string, + volunteerPhone: string, + lang: Lang, +): string { + if (shareContact) { + return lang === Lang.DE + ? `${volunteerName}s Kontaktdaten findest Du unten: ${volunteerEmail} ${volunteerPhone} Sollte es zur Terminabsage kommen, lass uns bitte wissen. Falls es zu einer kurzfristigen Absage kommt, kontaktiere ${volunteerName} gerne direkt. Gib bitte die Kontaktdaten des Sprachmittlers auf keinen Fall an die zu begleitende Person weiter.` + : `You can find ${volunteerName}'s contact details below: ${volunteerEmail} ${volunteerPhone} Please let us know if the appointment is cancelled. If it is cancelled at short notice, feel free to contact ${volunteerName} directly. Please do not, under any circumstances, pass the interpreter's contact details on to the person being accompanied.`; + } + return lang === Lang.DE + ? `Nach der Absprache mit ${volunteerName} dürfen wir Dir leider die Kontaktdaten nicht weitergeben. Sollte es zur Terminabsage kommen, lass uns bitte wissen. Für Fragen stehe ich Dir gerne zur Verfügung.` + : `As agreed with ${volunteerName}, we are unfortunately unable to share their contact details with you. Please let us know if the appointment is cancelled. I am happy to help if you have any questions.`; +} + +export async function sendEmailAccompanyMatch( + email: EmailTransport, + ov: OpportunityVolunteer, +): Promise { + const contactPersonEmail = ov.opportunity?.contactPerson?.email; + if (!contactPersonEmail) { + throw new Error( + `sendEmailAccompanyMatch: missing contact email for opportunity ${ov.opportunityId}`, + ); + } + + const volunteer = ov.volunteer; + if (!volunteer?.person) { + throw new Error( + `sendEmailAccompanyMatch: missing volunteer or person relation for ov ${ov.id}`, + ); + } + if (!volunteer.deal) { + throw new Error( + `sendEmailAccompanyMatch: volunteer ${volunteer.id} has no deal relation`, + ); + } + + const opportunity = ov.opportunity; + const accompanying = opportunity.accompanying; + + const volunteerName = volunteer.person.name; + const volunteerEmail = volunteer.person.email ?? ""; + const volunteerPhone = volunteer.person.phone ?? ""; + const contactpersonName = opportunity.contactPerson!.name; + + const volunteerLanguage = getLanguages(volunteer.deal?.dealLanguage ?? []) + .map((l) => l.title) + .join(", "); + + const clientName = accompanying?.name ?? ""; + const appointmentDate = accompanying?.date + ? new Date(accompanying.date).toLocaleDateString("de-DE", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + : ""; + const appointmentDistrict = + opportunity.district?.title ?? accompanying?.postcode?.value ?? ""; + + const locale = resolveLocale(opportunity.contactPerson?.users?.[0]?.language); + const contactSharing = resolveContactSharing( + volunteer.shareContact ?? true, + volunteerName, + volunteerEmail, + volunteerPhone, + locale, + ); + + const content = resolveContent(await loader.load(), locale, BUILTIN); + const { subject, text, html } = fillTemplate(content, { + contactpersonName, + volunteerName, + volunteerLanguage, + clientName, + appointmentDate, + appointmentDistrict, + volunteerEmail, + volunteerPhone, + contactSharing, + }); + + await email.send({ + to: contactPersonEmail, + cc: emailFromContact, + from: emailFromNotify, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }); +} diff --git a/src/services/notify/events/email-accompany-not-found.ts b/src/services/notify/events/email-accompany-not-found.ts new file mode 100644 index 00000000..c55ae546 --- /dev/null +++ b/src/services/notify/events/email-accompany-not-found.ts @@ -0,0 +1,78 @@ +import { Lang } from "need4deed-sdk"; +import { + emailAccompanyNotFoundManifestUrl, + emailFromAccompanying, + emailFromNotify, +} from "../../../config/constants"; +import Opportunity from "../../../data/entity/opportunity/opportunity.entity"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailTransport } from "../types"; + +const BUILTIN: Record = { + [Lang.EN]: { + subject: + "Accompanying to an appointment on {{ appointmentDate }} in {{ appointmentDistrict }} for {{ clientName }}", + text: `Dear {{ contactpersonName }},\n\nUnfortunately, we were unable to find a volunteer for this appointment. We have now called off our search.\n\nBest regards,\nNeed4Deed`, + }, + [Lang.DE]: { + subject: + "Begleitung zum Termin am {{ appointmentDate }} in {{ appointmentDistrict }} für {{ clientName }}", + text: `Hallo {{ contactpersonName }},\n\nleider hat sich niemand für die Sprachmittlung gemeldet. Wir haben nun unsere Suche eingestellt.\n\nViele Grüße\nNeed4Deed`, + }, +}; + +const loader = createManifestLoader(emailAccompanyNotFoundManifestUrl); + +export function resetAccompanyNotFoundTemplateCache(): void { + loader.resetCache(); +} + +export async function sendEmailAccompanyNotFound( + email: EmailTransport, + opportunity: Opportunity, +): Promise { + const contactPersonEmail = opportunity.contactPerson?.email; + if (!contactPersonEmail) { + throw new Error( + `sendEmailAccompanyNotFound: missing contact email for opportunity ${opportunity.id}`, + ); + } + + const contactpersonName = opportunity.contactPerson!.name; + const accompanying = opportunity.accompanying; + const appointmentDate = accompanying?.date + ? new Date(accompanying.date).toLocaleDateString("de-DE", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + : ""; + const appointmentDistrict = + opportunity.district?.title ?? accompanying?.postcode?.value ?? ""; + const clientName = accompanying?.name ?? ""; + + const locale = resolveLocale(opportunity.contactPerson?.users?.[0]?.language); + const content = resolveContent(await loader.load(), locale, BUILTIN); + const { subject, text, html } = fillTemplate(content, { + contactpersonName, + appointmentDate, + appointmentDistrict, + clientName, + }); + + await email.send({ + to: contactPersonEmail, + cc: emailFromAccompanying, + from: emailFromNotify, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }); +} diff --git a/src/services/notify/events/email-introduction.ts b/src/services/notify/events/email-introduction.ts new file mode 100644 index 00000000..6b5d96d2 --- /dev/null +++ b/src/services/notify/events/email-introduction.ts @@ -0,0 +1,161 @@ +import { DocumentStatusType, Lang, VolunteerStateCGCType } from "need4deed-sdk"; +import { + emailFromContact, + emailFromNotify, + emailFromVolunteer, + emailIntroductionManifestUrl, +} from "../../../config/constants"; +import OpportunityVolunteer from "../../../data/entity/m2m/opportunity-volunteer"; +import { getLanguages, getOptionItems, getTitles } from "../../dto/utils"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailTransport } from "../types"; + +const BUILTIN: Record = { + [Lang.EN]: { + subject: + "Introduction — {{ volunteerName }} & {{ volunteeringopportunityName }}", + text: `Dear {{ contactpersonName }}, dear {{ volunteerName }},\n\nWe are delighted to introduce you to each other for the volunteering opportunity "{{ volunteeringopportunityName }}".\n\n{{ volunteerName }} speaks {{ volunteerLanguage }} and has the following skills: {{ volunteerSkills }}.\nAvailability: {{ volSchedule }}\n\n{{ statmentOnCertificates }}\n\nVolunteer contact:\n{{ volunteerName }}\n{{ volunteerEmail }}\n{{ volunteerPhone }}\n\nCenter contact:\n{{ contactpersonName }}\n{{ contactpersonEmail }}\n{{ contactpersonPhone }}\n{{ agentAddress }}\n\nPlease feel free to get in touch with each other directly to arrange the details. If you have any questions, do not hesitate to contact us.\n\nBest regards,\nNeed4Deed`, + }, + [Lang.DE]: { + subject: + "Vorstellung — {{ volunteerName }} & {{ volunteeringopportunityName }}", + text: `Hallo {{ contactpersonName }}, hallo {{ volunteerName }},\n\nwir freuen uns, euch für das Gesuch „{{ volunteeringopportunityName }}" miteinander bekannt zu machen.\n\n{{ volunteerName }} spricht {{ volunteerLanguage }} und hat folgende Fähigkeiten: {{ volunteerSkills }}.\nVerfügbarkeit: {{ volSchedule }}\n\n{{ statmentOnCertificates }}\n\nKontaktdaten Ehrenamt:\n{{ volunteerName }}\n{{ volunteerEmail }}\n{{ volunteerPhone }}\n\nKontaktdaten Unterkunft:\n{{ contactpersonName }}\n{{ contactpersonEmail }}\n{{ contactpersonPhone }}\n{{ agentAddress }}\n\nIhr könnt gerne direkt miteinander in Kontakt treten, um die Details zu klären. Bei Fragen stehen wir gerne zur Verfügung.\n\nViele Grüße\nNeed4Deed`, + }, +}; + +const loader = createManifestLoader(emailIntroductionManifestUrl); + +export function resetIntroductionTemplateCache(): void { + loader.resetCache(); +} + +function resolveStatmentOnCertificates( + statusCGC: DocumentStatusType, + statusCgcProcess: VolunteerStateCGCType | null | undefined, + statusVaccination: DocumentStatusType, + lang: Lang, +): string { + const cgcNo = statusCGC === DocumentStatusType.NO; + const cgcYes = statusCGC === DocumentStatusType.YES; + const missing = statusCgcProcess === VolunteerStateCGCType.MISSING; + const uploaded = statusCgcProcess === VolunteerStateCGCType.UPLOADED; + const vaccinationYes = statusVaccination === DocumentStatusType.YES; + + if (cgcNo && missing && vaccinationYes) { + return lang === Lang.DE + ? "Das erweiterte Führungszeugnis beantragen wir sofort. Der Masernschutznachweis liegt vor." + : "We will apply for the certificate of good conduct (das erweiterte Führungszeugnis) for them. Proof of measles vaccination has been provided."; + } + if (cgcNo && missing && !vaccinationYes) { + return lang === Lang.DE + ? "Das erweiterte Führungszeugnis beantragen wir sofort." + : "We will apply for the certificate of good conduct (das erweiterte Führungszeugnis) for them."; + } + if (cgcNo && uploaded && vaccinationYes) { + return lang === Lang.DE + ? "Das erweiterte Führungszeugnis haben wir bereits beantragt. Der Masernschutznachweis liegt vor." + : "We have applied for the certificate of good conduct (das erweiterte Führungszeugnis) for them. Proof of measles vaccination has been provided."; + } + if (cgcNo && uploaded && !vaccinationYes) { + return lang === Lang.DE + ? "Das erweiterte Führungszeugnis haben wir bereits beantragt." + : "We have applied for the certificate of good conduct (das erweiterte Führungszeugnis) for them."; + } + if (cgcYes && vaccinationYes) { + return lang === Lang.DE + ? "Das erweiterte Führungszeugnis sowie der Masernschutznachweis liegen vor." + : "They have already gotten their certificate of good conduct (das erweiterte Führungszeugnis). Proof of measles vaccination has been provided."; + } + if (cgcYes && !vaccinationYes) { + return lang === Lang.DE + ? "Das erweiterte Führungszeugnis liegt vor." + : "They have already gotten their certificate of good conduct (das erweiterte Führungszeugnis)."; + } + return ""; +} + +export async function sendEmailIntroduction( + email: EmailTransport, + ov: OpportunityVolunteer, +): Promise { + const volunteerEmail = ov.volunteer?.person?.email; + const contactPersonEmail = ov.opportunity?.contactPerson?.email; + + if (!volunteerEmail || !contactPersonEmail) { + throw new Error( + `sendEmailIntroduction: missing email(s) for ov ${ov.id} (volunteer=${volunteerEmail}, contact=${contactPersonEmail})`, + ); + } + + const volunteer = ov.volunteer; + const opportunity = ov.opportunity; + const locale = resolveLocale(volunteer.person?.users?.[0]?.language); + + const volunteerName = volunteer.person.name; + const contactpersonName = opportunity.contactPerson!.name; + const volunteeringopportunityName = opportunity.title; + + const volunteerLanguage = getLanguages(volunteer.deal?.dealLanguage ?? []) + .map((l) => l.title) + .join(", "); + + const volunteerSkills = getOptionItems( + volunteer.deal?.dealSkill ?? [], + "skill", + ) + .map((s) => s.title) + .join(", "); + + const volSchedule = + getTitles(volunteer.deal?.dealTimeslot ?? [], "timeslot") + .map((t) => String(t)) + .join(", ") || ""; + + const agentAddress = (() => { + const addr = opportunity.agent?.address; + if (!addr) { + return ""; + } + return [addr.street, addr.postcode?.value, addr.city] + .filter(Boolean) + .join(", "); + })(); + + const statmentOnCertificates = resolveStatmentOnCertificates( + volunteer.statusCGC, + volunteer.statusCgcProcess, + volunteer.statusVaccination, + locale, + ); + + const content = resolveContent(await loader.load(), locale, BUILTIN); + const { subject, text, html } = fillTemplate(content, { + contactpersonName, + volunteerName, + volunteeringopportunityName, + volunteerSkills, + volSchedule, + volunteerLanguage, + volunteerEmail, + volunteerPhone: volunteer.person.phone ?? "", + contactpersonEmail: contactPersonEmail, + contactpersonPhone: opportunity.contactPerson!.phone ?? "", + agentAddress, + statmentOnCertificates, + }); + + await email.send({ + to: [volunteerEmail, contactPersonEmail], + cc: [emailFromContact, emailFromVolunteer], + from: emailFromNotify, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }); +} diff --git a/src/services/notify/events/email-new-accompanying.ts b/src/services/notify/events/email-new-accompanying.ts new file mode 100644 index 00000000..5965ccf7 --- /dev/null +++ b/src/services/notify/events/email-new-accompanying.ts @@ -0,0 +1,93 @@ +import { Lang } from "need4deed-sdk"; +import { + emailFromAccompanying, + emailFromContact, + emailFromNotify, + emailNewAccompanyingManifestUrl, +} from "../../../config/constants"; +import Opportunity from "../../../data/entity/opportunity/opportunity.entity"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailTransport } from "../types"; + +const BUILTIN: Record = { + [Lang.EN]: { + subject: + "Accompanying appointment on {{ appointmentDate }} in {{ appointmentDistrict }} for {{ clientName }}", + text: `Dear {{ contactpersonName }},\n\nThank you for your request.\n\nHere are the details you provided. Please check that everything is correct:\n {{ appointmentTitle }}\n {{ appointmentDistrict }}\n {{ appointmentAddress }}\n {{ accompaniedpersonLanguage }}\n{{ appointmentaLanguage }}\n{{ accompaniedpersonName }}\n{{ accompaniedpersonPhone }}\n{{ appointmentComment }}\n\nWe will review the information promptly and get back to you within two days if anything is missing.\n\nIf all the details are correct and the accompaniment is straightforward (e.g. not a hospital treatment, a brief description is provided, and a direct phone number of the contact person is available), we will forward your request to our volunteers. We will get back to you once we have found someone for the appointment.\nIf we are unable to find a volunteer for the appointment, we will let you know no later than four working days beforehand.\n\nMore information about our guidelines can be found at https://need4deed.org/rac-guidelines\n\nBest regards,\nThe Team`, + }, + [Lang.DE]: { + subject: + "Begleitung zum Termin am {{ appointmentDate }} in {{ appointmentDistrict }} für {{ clientName }}", + text: `Hallo {{ contactpersonName }},\n\nvielen Dank für die Anfrage.\n\nHier sind die angegebenen Details. Bitte prüfe kurz, ob alles stimmt:\n {{ appointmentTitle }}\n {{ appointmentDistrict }}\n {{ appointmentAddress }}\n {{ accompaniedpersonLanguage }}\n{{ appointmentaLanguage }}\n{{ accompaniedpersonName }}\n{{ accompaniedpersonPhone }}\n{{ appointmentComment }}\n\nWir überprüfen die Informationen umgehend und melden uns innerhalb von zwei Tagen, falls etwas fehlt.\n\nFalls alle Angaben korrekt sind und die Begleitung klar ist (z. B. keine Krankenhausbehandlung, kurze Beschreibung und verfügbare Direktnummer der begleitenden Person), leiten wir deine Anfrage an die Freiwilligen weiter. Wir melden uns, sobald wir jemanden für den Termin gefunden haben.\nFalls wir keine Freiwilligen für den Termin vermitteln können, melden wir uns spätestens vier Werktage vorher.\n\nMehr Informationen über die Leitlinien findest Du unter https://need4deed.org/rac-guidelines\n\nViele Grüße\nDas Team`, + }, +}; + +const loader = createManifestLoader(emailNewAccompanyingManifestUrl); + +export function resetNewAccompanyingTemplateCache(): void { + loader.resetCache(); +} + +export async function sendEmailNewAccompanying( + email: EmailTransport, + opportunity: Opportunity, +): Promise { + const contactPersonEmail = opportunity.contactPerson?.email; + if (!contactPersonEmail) { + throw new Error( + `sendEmailNewAccompanying: missing contact email for opportunity ${opportunity.id}`, + ); + } + + const accompanying = opportunity.accompanying; + const contactpersonName = opportunity.contactPerson!.name; + const appointmentDate = accompanying?.date + ? new Date(accompanying.date).toLocaleDateString("de-DE", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + : ""; + const appointmentDistrict = + opportunity.district?.title ?? accompanying?.postcode?.value ?? ""; + const clientName = accompanying?.name ?? ""; + const appointmentTitle = opportunity.title; + const appointmentAddress = accompanying?.address ?? ""; + const accompaniedpersonLanguage = accompanying?.languageToTranslate ?? ""; + const appointmentaLanguage = opportunity.translationType ?? ""; + const accompaniedpersonName = accompanying?.name ?? ""; + const accompaniedpersonPhone = accompanying?.phone ?? ""; + const appointmentComment = opportunity.info ?? ""; + + const locale = resolveLocale(opportunity.contactPerson?.users?.[0]?.language); + const content = resolveContent(await loader.load(), locale, BUILTIN); + const { subject, text, html } = fillTemplate(content, { + contactpersonName, + appointmentDate, + appointmentDistrict, + clientName, + appointmentTitle, + appointmentAddress, + accompaniedpersonLanguage, + appointmentaLanguage, + accompaniedpersonName, + accompaniedpersonPhone, + appointmentComment, + }); + + await email.send({ + to: contactPersonEmail, + cc: [emailFromContact, emailFromAccompanying], + from: emailFromNotify, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }); +} diff --git a/src/services/notify/events/email-new-regular.ts b/src/services/notify/events/email-new-regular.ts new file mode 100644 index 00000000..2fd8446e --- /dev/null +++ b/src/services/notify/events/email-new-regular.ts @@ -0,0 +1,61 @@ +import { Lang } from "need4deed-sdk"; +import { + emailFromContact, + emailNewRegularManifestUrl, +} from "../../../config/constants"; +import Opportunity from "../../../data/entity/opportunity/opportunity.entity"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailTransport } from "../types"; + +const BUILTIN: Record = { + [Lang.EN]: { + subject: "Your request to Need4Deed", + text: `Dear {{ contactpersonName }},\n\nThank you for sending us your volunteering opportunity "{{ volunteeringopportunityName }}".\n\nWe will start looking for volunteers as soon as possible.\n\nWe will let you know when we find someone and introduce the volunteer to you.\n\nBest regards,\nNeed4Deed`, + }, + [Lang.DE]: { + subject: "Deine Anfrage bei Need4Deed", + text: `Hallo {{ contactpersonName }},\n\nvielen Dank für deine Anfrage zu "{{ volunteeringopportunityName }}".\n\nWir fangen bald mit der Suche an.\n\nWenn wir jemanden gefunden haben, melden wir uns bei dir und stellen dir die Person vor.\n\nViele Grüße\nNeed4Deed`, + }, +}; + +const loader = createManifestLoader(emailNewRegularManifestUrl); + +export function resetNewRegularTemplateCache(): void { + loader.resetCache(); +} + +export async function sendEmailNewRegular( + email: EmailTransport, + opportunity: Opportunity, +): Promise { + const contactPersonEmail = opportunity.contactPerson?.email; + if (!contactPersonEmail) { + throw new Error( + `sendEmailNewRegular: missing contact email for opportunity ${opportunity.id}`, + ); + } + + const contactpersonName = opportunity.contactPerson!.name; + const volunteeringopportunityName = opportunity.title; + + const locale = resolveLocale(opportunity.contactPerson?.users?.[0]?.language); + const content = resolveContent(await loader.load(), locale, BUILTIN); + const { subject, text, html } = fillTemplate(content, { + contactpersonName, + volunteeringopportunityName, + }); + + await email.send({ + to: contactPersonEmail, + from: emailFromContact, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }); +} diff --git a/src/services/notify/events/email-password-reset.ts b/src/services/notify/events/email-password-reset.ts new file mode 100644 index 00000000..7825517f --- /dev/null +++ b/src/services/notify/events/email-password-reset.ts @@ -0,0 +1,81 @@ +import type { JWT } from "@fastify/jwt"; +import { Lang } from "need4deed-sdk"; +import { + emailPasswordResetManifestUrl, + RESET_LIFESPAN_MS, + urlPasswordReset, +} from "../../../config/constants"; +import type User from "../../../data/entity/user.entity"; +import logger from "../../../logger"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailMessage, EmailTransport } from "../types"; + +export interface PasswordResetDeps { + email: EmailTransport; + jwt: JWT; +} + +const BUILTIN: Record = { + [Lang.EN]: { + subject: "Password Reset", + text: `A password reset has been requested for your account. To reset your password, follow this link:\n{{resetUrl}}\n\nIf you did not request this, please ignore this email.`, + html: `

A password reset has been requested for your account.

\n

Reset your password

\n

If you did not request this, please ignore this email.

`, + }, + [Lang.DE]: { + subject: "Passwort zurücksetzen", + text: `Es wurde ein Zurücksetzen des Passworts für dein Konto angefordert. Um dein Passwort zurückzusetzen, folge diesem Link:\n{{resetUrl}}\n\nFalls du dies nicht angefordert hast, ignoriere diese E-Mail bitte.`, + html: `

Es wurde ein Zurücksetzen des Passworts für dein Konto angefordert.

\n

Passwort zurücksetzen

\n

Falls du dies nicht angefordert hast, ignoriere diese E-Mail bitte.

`, + }, +}; + +const loader = createManifestLoader(emailPasswordResetManifestUrl); + +export function resetPasswordResetTemplateCache(): void { + loader.resetCache(); +} + +export async function sendPasswordReset( + { email, jwt }: PasswordResetDeps, + user: User, +): Promise { + if (!user?.email) { + throw new Error("User email is required for password reset"); + } + + const token = jwt.sign( + { + id: user.id, + email: user.email, + type: "reset", + }, + { expiresIn: `${RESET_LIFESPAN_MS}` }, + ); + + const url = `${urlPasswordReset}?token=${encodeURIComponent(token)}`; + + logger.debug(`sendPasswordReset: ${user.email}, url: ${url}`); + + const content = resolveContent( + await loader.load(), + resolveLocale(user.language), + BUILTIN, + ); + const { subject, html, text } = fillTemplate(content, { + resetUrl: url, + }); + + const message: EmailMessage = { + to: user.email, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }; + + await email.send(message); +} diff --git a/src/services/notify/events/email-post-match-checkup.ts b/src/services/notify/events/email-post-match-checkup.ts new file mode 100644 index 00000000..2f9b17bb --- /dev/null +++ b/src/services/notify/events/email-post-match-checkup.ts @@ -0,0 +1,58 @@ +import { Lang } from "need4deed-sdk"; +import { + emailFromNotify, + emailFromVolunteer, + emailPostMatchCheckupManifestUrl, +} from "../../../config/constants"; +import OpportunityVolunteer from "../../../data/entity/m2m/opportunity-volunteer"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailTransport } from "../types"; + +const BUILTIN: Record = { + [Lang.EN]: { + subject: "Checking in — are you still volunteering?", + text: `Dear {{ volunteerName }},\n\nWe wanted to check in. You were matched two months ago, have you had the chance to volunteer and are you still active?\n\nLet us know by replying to this email so we can keep your profile up to date.\n\nThank you,\nNeed4Deed`, + }, + [Lang.DE]: { + subject: "Kurze Nachfrage — bist du noch aktiv?", + text: `Hallo {{ volunteerName }},\n\nwir wollten kurz nachfragen. Du wurdest vor zwei Monaten vermittelt — hattest du die Gelegenheit, dich ehrenamtlich zu engagieren, und bist du noch aktiv?\n\nBitte antworte einfach auf diese E-Mail, damit wir dein Profil aktuell halten können.\n\nVielen Dank\nNeed4Deed`, + }, +}; + +const loader = createManifestLoader(emailPostMatchCheckupManifestUrl); + +export function resetPostMatchCheckupTemplateCache(): void { + loader.resetCache(); +} + +export async function sendEmailPostMatchCheckup( + email: EmailTransport, + ov: OpportunityVolunteer, +): Promise { + const volunteerEmail = ov.volunteer?.person?.email; + if (!volunteerEmail) { + throw new Error( + `sendEmailPostMatchCheckup: missing email for volunteer ${ov.volunteerId}`, + ); + } + + const volunteerName = ov.volunteer.person.name; + const locale = resolveLocale(ov.volunteer.person?.users?.[0]?.language); + const content = resolveContent(await loader.load(), locale, BUILTIN); + const { subject, text, html } = fillTemplate(content, { volunteerName }); + + await email.send({ + to: volunteerEmail, + cc: emailFromVolunteer, + from: emailFromNotify, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }); +} diff --git a/src/services/notify/events/email-regular-update.ts b/src/services/notify/events/email-regular-update.ts new file mode 100644 index 00000000..f6770a6e --- /dev/null +++ b/src/services/notify/events/email-regular-update.ts @@ -0,0 +1,63 @@ +import { Lang } from "need4deed-sdk"; +import { + emailFromContact, + emailFromNotify, + emailRegularUpdateManifestUrl, +} from "../../../config/constants"; +import Opportunity from "../../../data/entity/opportunity/opportunity.entity"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailTransport } from "../types"; + +const BUILTIN: Record = { + [Lang.EN]: { + subject: "Status of your volunteering opportunity — Need4Deed", + text: `Dear {{ contactpersonName }},\n\nWe are checking in to see if you are still looking for volunteers for "{{ volunteeringopportunityName }}".\n\nPlease let us know within two weeks. Otherwise we will mark this volunteering opportunity as inactive in our system.\n\nBest regards,\nNeed4Deed`, + }, + [Lang.DE]: { + subject: "Aktualisierung der Gesuche bei Need4Deed", + text: `Hallo {{ contactpersonName }},\n\nwir möchten gerne wissen, ob das Gesuch "{{ volunteeringopportunityName }}" noch aktuell ist.\n\nSollten wir innerhalb von 2 Wochen keine Rückmeldung bekommen, werden wir das Gesuch als "Inaktiv" markieren.\n\nWir freuen uns darauf, von Dir zu hören.\n\nViele Grüße\nNeed4Deed`, + }, +}; + +const loader = createManifestLoader(emailRegularUpdateManifestUrl); + +export function resetRegularUpdateTemplateCache(): void { + loader.resetCache(); +} + +export async function sendEmailRegularUpdate( + email: EmailTransport, + opportunity: Opportunity, +): Promise { + const contactPersonEmail = opportunity.contactPerson?.email; + if (!contactPersonEmail) { + throw new Error( + `sendEmailRegularUpdate: missing contact email for opportunity ${opportunity.id}`, + ); + } + + const contactpersonName = opportunity.contactPerson!.name; + const volunteeringopportunityName = opportunity.title; + + const locale = resolveLocale(opportunity.contactPerson?.users?.[0]?.language); + const content = resolveContent(await loader.load(), locale, BUILTIN); + const { subject, text, html } = fillTemplate(content, { + contactpersonName, + volunteeringopportunityName, + }); + + await email.send({ + to: contactPersonEmail, + cc: emailFromContact, + from: emailFromNotify, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }); +} diff --git a/src/services/notify/events/email-stale.ts b/src/services/notify/events/email-stale.ts new file mode 100644 index 00000000..eb28fa70 --- /dev/null +++ b/src/services/notify/events/email-stale.ts @@ -0,0 +1,58 @@ +import { Lang } from "need4deed-sdk"; +import { + emailFromNotify, + emailFromVolunteer, + emailStaleManifestUrl, +} from "../../../config/constants"; +import OpportunityVolunteer from "../../../data/entity/m2m/opportunity-volunteer"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailTransport } from "../types"; + +const BUILTIN: Record = { + [Lang.EN]: { + subject: "Are you still interested in volunteering? — Need4Deed", + text: `Dear {{ volunteerName }},\n\nWe wanted to check in. Two months ago we sent you a volunteering opportunity, but we have not heard back yet.\n\nIf you are still interested in volunteering, please reply to this email.\n\nBest regards,\nNeed4Deed`, + }, + [Lang.DE]: { + subject: "Bist du noch interessiert? — Need4Deed", + text: `Hallo {{ volunteerName }},\n\nwir wollten kurz nachfragen. Vor zwei Monaten haben wir dir eine ehrenamtliche Möglichkeit vorgeschlagen, aber bisher haben wir keine Rückmeldung erhalten.\n\nFalls du weiterhin Interesse hast, antworte bitte auf diese E-Mail.\n\nViele Grüße\nNeed4Deed`, + }, +}; + +const loader = createManifestLoader(emailStaleManifestUrl); + +export function resetStaleTemplateCache(): void { + loader.resetCache(); +} + +export async function sendEmailStale( + email: EmailTransport, + ov: OpportunityVolunteer, +): Promise { + const volunteerEmail = ov.volunteer?.person?.email; + if (!volunteerEmail) { + throw new Error( + `sendEmailStale: missing email for volunteer ${ov.volunteerId}`, + ); + } + + const volunteerName = ov.volunteer.person.name; + const locale = resolveLocale(ov.volunteer.person?.users?.[0]?.language); + const content = resolveContent(await loader.load(), locale, BUILTIN); + const { subject, text, html } = fillTemplate(content, { volunteerName }); + + await email.send({ + to: volunteerEmail, + cc: emailFromVolunteer, + from: emailFromNotify, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }); +} diff --git a/src/services/notify/events/email-suggestion.ts b/src/services/notify/events/email-suggestion.ts new file mode 100644 index 00000000..56e84e7d --- /dev/null +++ b/src/services/notify/events/email-suggestion.ts @@ -0,0 +1,71 @@ +import { Lang } from "need4deed-sdk"; +import { + emailFromNotify, + emailFromVolunteer, + emailSuggestionManifestUrl, +} from "../../../config/constants"; +import OpportunityVolunteer from "../../../data/entity/m2m/opportunity-volunteer"; +import { getTitles } from "../../dto/utils"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; +import type { EmailTransport } from "../types"; + +const BUILTIN: Record = { + [Lang.EN]: { + subject: "Volunteering opportunity match — Need4Deed", + text: `Dear {{ volunteerName }},\n\nWe have found a volunteering opportunity that matches your profile.\n\nOpportunity: {{ opportunityName }}\nPostcode: {{ plz }}\nSchedule: {{ schedule }}\n\nIf you are interested, please reply to this email.\n\nBest regards,\nNeed4Deed`, + }, + [Lang.DE]: { + subject: "Möglicher Einsatz — Need4Deed", + text: `Hallo {{ volunteerName }},\n\nwir haben eine ehrenamtliche Möglichkeit gefunden, die zu deinem Profil passt.\n\nGesuch: {{ opportunityName }}\nPostleitzahl: {{ plz }}\nZeiten: {{ schedule }}\n\nFalls du Interesse hast, antworte bitte auf diese E-Mail.\n\nViele Grüße\nNeed4Deed`, + }, +}; + +const loader = createManifestLoader(emailSuggestionManifestUrl); + +export function resetSuggestionTemplateCache(): void { + loader.resetCache(); +} + +export async function sendEmailSuggestion( + email: EmailTransport, + ov: OpportunityVolunteer, +): Promise { + const volunteerEmail = ov.volunteer?.person?.email; + if (!volunteerEmail) { + throw new Error( + `sendEmailSuggestion: missing email for volunteer ${ov.volunteerId}`, + ); + } + + const volunteerName = ov.volunteer.person.name; + const opportunityName = ov.opportunity?.title ?? ""; + const plz = ov.volunteer.deal?.postcode?.value ?? ""; + const schedule = + getTitles(ov.volunteer.deal?.dealTimeslot ?? [], "timeslot") + .map((t) => String(t)) + .join(", ") || ""; + + const locale = resolveLocale(ov.volunteer.person?.users?.[0]?.language); + const content = resolveContent(await loader.load(), locale, BUILTIN); + const { subject, text, html } = fillTemplate(content, { + volunteerName, + opportunityName, + plz, + schedule, + }); + + await email.send({ + to: volunteerEmail, + cc: emailFromVolunteer, + from: emailFromNotify, + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), + }); +} diff --git a/src/services/notify/events/email-verification.ts b/src/services/notify/events/email-verification.ts index fda6fb8f..ee0565f3 100644 --- a/src/services/notify/events/email-verification.ts +++ b/src/services/notify/events/email-verification.ts @@ -1,14 +1,18 @@ import type { JWT, TokenType } from "@fastify/jwt"; -import { Lang } from "need4deed-sdk"; +import { Lang, UserRole } from "need4deed-sdk"; import { - emailTemplateFetchTimeoutMs, - emailTemplateTtlMs, emailVerificationManifestUrl, urlEmailVerification, } from "../../../config/constants"; import type User from "../../../data/entity/user.entity"; -import { fetchJsonFromUrl } from "../../../data/utils"; import logger from "../../../logger"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../email-template"; import type { EmailMessage, EmailTransport } from "../types"; export interface EmailVerificationDeps { @@ -16,94 +20,24 @@ export interface EmailVerificationDeps { jwt: JWT; } -interface LocaleContent { - subject: string; - html?: string; - text?: string; -} -type VerificationManifest = Partial>; - -const URL_PLACEHOLDER = "{{verificationUrl}}"; -const DEFAULT_LOCALE = Lang.EN; - -// Built-in fallback used when the CDN manifest is missing/invalid, so a -// verification email always sends (and in the user's language where possible). const BUILTIN: Record = { [Lang.EN]: { subject: "Account Created", - text: `Your account has been created successfully. Please verify your email:\n${URL_PLACEHOLDER}`, - html: `

Your account has been created successfully. Please verify your email:

${URL_PLACEHOLDER}

`, + text: `Your account has been created successfully. Please verify your email:\n{{verificationUrl}}`, + html: `

Your account has been created successfully. Please verify your email:

{{verificationUrl}}

`, }, [Lang.DE]: { subject: "Konto erstellt", - text: `Dein Konto wurde erfolgreich erstellt. Bitte bestätige deine E-Mail:\n${URL_PLACEHOLDER}`, - html: `

Dein Konto wurde erfolgreich erstellt. Bitte bestätige deine E-Mail:

${URL_PLACEHOLDER}

`, + text: `Dein Konto wurde erfolgreich erstellt. Bitte bestätige deine E-Mail:\n{{verificationUrl}}`, + html: `

Dein Konto wurde erfolgreich erstellt. Bitte bestätige deine E-Mail:

{{verificationUrl}}

`, }, }; -let manifestCache: { value: VerificationManifest; expires: number } | null = - null; +const loader = createManifestLoader(emailVerificationManifestUrl); /** Test-only: drop the cached manifest so each test fetches fresh. */ export function resetVerificationTemplateCache(): void { - manifestCache = null; -} - -async function withTimeout(promise: Promise, ms: number): Promise { - let timer: ReturnType; - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms); - }); - try { - return await Promise.race([promise, timeout]); - } finally { - clearTimeout(timer!); - } -} - -/** - * Load the email manifest from the CDN, cached in-memory for emailTemplateTtlMs. - * On any failure, serve the last good value if we have one, else null (→ the - * caller falls back to BUILTIN). Never throws. - */ -async function loadManifest(): Promise { - const now = Date.now(); - if (manifestCache && now < manifestCache.expires) { - return manifestCache.value; - } - try { - const value = (await withTimeout( - fetchJsonFromUrl(emailVerificationManifestUrl), - emailTemplateFetchTimeoutMs, - )) as VerificationManifest; - manifestCache = { value, expires: now + emailTemplateTtlMs }; - return value; - } catch (err) { - logger.warn( - `email verification manifest fetch failed: ${err instanceof Error ? err.message : err}`, - ); - return manifestCache?.value ?? null; // last-good (stale) or built-in - } -} - -function resolveLocale(language: string | undefined): Lang { - return language === Lang.DE - ? Lang.DE - : language === Lang.EN - ? Lang.EN - : DEFAULT_LOCALE; -} - -function isValid(content: LocaleContent | undefined): content is LocaleContent { - return Boolean(content?.subject && (content.html || content.text)); -} - -function resolveContent( - manifest: VerificationManifest | null, - locale: Lang, -): LocaleContent { - const candidates = [manifest?.[locale], manifest?.[DEFAULT_LOCALE]]; - return candidates.find(isValid) ?? BUILTIN[locale] ?? BUILTIN[DEFAULT_LOCALE]; + loader.resetCache(); } export async function sendEmailVerification( @@ -119,21 +53,26 @@ export async function sendEmailVerification( email: user.email, type: "verify" as TokenType, }); - const url = `${urlEmailVerification}/${token}`; + const roleParam = + user.role === UserRole.AGENT ? `?role=${UserRole.AGENT}` : ""; + const url = `${urlEmailVerification}/${token}${roleParam}`; logger.debug(`sendEmailVerification: ${user.email}, url: ${url}`); const content = resolveContent( - await loadManifest(), + await loader.load(), resolveLocale(user.language), + BUILTIN, ); - const fill = (s?: string) => s?.split(URL_PLACEHOLDER).join(url); + const { subject, html, text } = fillTemplate(content, { + verificationUrl: url, + }); const message: EmailMessage = { to: user.email, - subject: content.subject, - ...(content.text ? { text: fill(content.text) } : {}), - ...(content.html ? { html: fill(content.html) } : {}), + subject, + ...(text !== undefined ? { text } : {}), + ...(html !== undefined ? { html } : {}), }; await email.send(message); diff --git a/src/services/notify/index.ts b/src/services/notify/index.ts index da197f3e..e64eb9d9 100644 --- a/src/services/notify/index.ts +++ b/src/services/notify/index.ts @@ -1,7 +1,19 @@ export * from "./types"; export * from "./dispatch"; export * from "./transports/email-brevo"; +export * from "./transports/email-smtp"; export * from "./transports/slack-webhook"; +export * from "./transports/dry-run"; export * from "./events/email-verification"; export * from "./events/ops-alert"; export * from "./events/comment-tagged"; +export * from "./events/email-password-reset"; +export * from "./events/email-suggestion"; +export * from "./events/email-stale"; +export * from "./events/email-introduction"; +export * from "./events/email-post-match-checkup"; +export * from "./events/email-accompany-not-found"; +export * from "./events/email-accompany-match"; +export * from "./events/email-regular-update"; +export * from "./events/email-new-regular"; +export * from "./events/email-new-accompanying"; diff --git a/src/services/notify/transports/dry-run.ts b/src/services/notify/transports/dry-run.ts new file mode 100644 index 00000000..1f7a193b --- /dev/null +++ b/src/services/notify/transports/dry-run.ts @@ -0,0 +1,43 @@ +import logger from "../../../logger"; +import type { + EmailMessage, + EmailTransport, + SlackMessage, + SlackTransport, +} from "../types"; + +const DRY_RUN_RECIPIENT = "test@need4deed.org"; + +export class DryRunEmailTransport implements EmailTransport { + constructor(private readonly realTransport: EmailTransport) {} + + async send(msg: EmailMessage): Promise { + const originalTo = Array.isArray(msg.to) ? msg.to.join(", ") : msg.to; + const originalCc = msg.cc + ? Array.isArray(msg.cc) + ? msg.cc.join(", ") + : msg.cc + : undefined; + const prefix = originalCc + ? `[TO: ${originalTo} CC: ${originalCc}]` + : `[TO: ${originalTo}]`; + const redirected: EmailMessage = { + ...msg, + to: DRY_RUN_RECIPIENT, + cc: undefined, + subject: `${prefix} ${msg.subject}`, + }; + logger.info( + `[notify:dry-run] redirecting email to ${DRY_RUN_RECIPIENT} — original to: "${originalTo}"${originalCc ? `, cc: "${originalCc}"` : ""}, subject: "${msg.subject}"`, + ); + await this.realTransport.send(redirected); + } +} + +export class DryRunSlackTransport implements SlackTransport { + async send(msg: SlackMessage): Promise { + logger.info( + `[notify:dry-run] slack suppressed — channel: ${msg.channel}, text: "${msg.text}"`, + ); + } +} diff --git a/src/services/notify/transports/email-brevo.ts b/src/services/notify/transports/email-brevo.ts index e415dc3e..c7ecf9c8 100644 --- a/src/services/notify/transports/email-brevo.ts +++ b/src/services/notify/transports/email-brevo.ts @@ -1,4 +1,4 @@ -import { defaultFrom, isDev, isStaging } from "../../../config/constants"; +import { defaultFrom } from "../../../config/constants"; import type { EmailMessage, EmailTransport } from "../types"; const BREVO_API_URL = "https://api.brevo.com/v3/smtp/email"; @@ -26,15 +26,9 @@ export class BrevoEmailTransport implements EmailTransport { }, body: JSON.stringify({ sender: { email: msg.from ?? defaultFrom }, - to: [{ email: msg.to }], - ...(isDev || isStaging - ? { - bcc: [ - { email: "dev@need4deed.org" }, - { email: "info@need4deed.org" }, - ], - } - : {}), // for monitoring/logging; not user-facing + to: (Array.isArray(msg.to) ? msg.to : [msg.to]).map((email) => ({ + email, + })), subject: msg.subject, ...(msg.html ? { htmlContent: msg.html } : {}), ...(msg.text ? { textContent: msg.text } : {}), @@ -42,8 +36,7 @@ export class BrevoEmailTransport implements EmailTransport { }); if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`Brevo email failed (${res.status}): ${body}`); + throw new Error(`Brevo email failed (${res.status})`); } } } diff --git a/src/services/notify/transports/email-smtp.ts b/src/services/notify/transports/email-smtp.ts new file mode 100644 index 00000000..4bfa6486 --- /dev/null +++ b/src/services/notify/transports/email-smtp.ts @@ -0,0 +1,37 @@ +import nodemailer from "nodemailer"; +import { defaultFrom } from "../../../config/constants"; +import type { EmailMessage, EmailTransport } from "../types"; + +export interface SmtpConfig { + host: string; + port: number; + user: string; + password: string; + from?: string; +} + +export class SmtpEmailTransport implements EmailTransport { + private readonly transporter: nodemailer.Transporter; + private readonly from: string; + + constructor(config: SmtpConfig) { + this.from = config.from ?? defaultFrom; + this.transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.port === 465, + auth: { user: config.user, pass: config.password }, + }); + } + + async send(msg: EmailMessage): Promise { + await this.transporter.sendMail({ + from: msg.from ?? this.from, + to: msg.to, + ...(msg.cc !== undefined ? { cc: msg.cc } : {}), + subject: msg.subject, + text: msg.text, + html: msg.html, + }); + } +} diff --git a/src/services/notify/types.ts b/src/services/notify/types.ts index 383ed82f..76763f68 100644 --- a/src/services/notify/types.ts +++ b/src/services/notify/types.ts @@ -1,5 +1,6 @@ export interface EmailMessage { - to: string; + to: string | string[]; + cc?: string | string[]; subject: string; text?: string; html?: string; diff --git a/src/test/data/utils/fetch-json.test.ts b/src/test/data/utils/fetch-json.test.ts new file mode 100644 index 00000000..d719b702 --- /dev/null +++ b/src/test/data/utils/fetch-json.test.ts @@ -0,0 +1,48 @@ +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join, relative } from "path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { fetchJsonFromUrl } from "../../../data/utils"; + +// `fs/promises` is an externalized node builtin and is not intercepted by +// `vi.mock` under the swc transform used here, so the file-path branches are +// tested against a real temp file rather than a mocked readFile. + +describe("fetchJsonFromUrl", () => { + let dir: string; + let filePath: string; + + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "fetch-json-")); + filePath = join(dir, "data.json"); + await writeFile(filePath, '{"key":"value"}', "utf-8"); + }); + + afterAll(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("reads and parses JSON from a dot-relative file path", async () => { + const relPath = `./${relative(process.cwd(), filePath)}`; + const result = await fetchJsonFromUrl(relPath); + expect(result).toEqual({ key: "value" }); + }); + + it("treats absolute paths as file system paths", async () => { + const result = await fetchJsonFromUrl(filePath); + expect(result).toEqual({ key: "value" }); + }); + + it("fetches JSON from a URL", async () => { + const mockFetch = vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ remote: true }), + }); + vi.stubGlobal("fetch", mockFetch); + + const result = await fetchJsonFromUrl("https://example.com/data.json"); + expect(result).toEqual({ remote: true }); + expect(mockFetch).toHaveBeenCalledWith("https://example.com/data.json"); + + vi.unstubAllGlobals(); + }); +}); diff --git a/src/test/data/utils/get-rrule.test.ts b/src/test/data/utils/get-rrule.test.ts new file mode 100644 index 00000000..cf96fad5 --- /dev/null +++ b/src/test/data/utils/get-rrule.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { getRRULE } from "../../../data/utils"; + +describe("getRRULE", () => { + it("returns RRULE string for each valid weekday (case-insensitive)", () => { + expect(getRRULE("monday")).toBe("FREQ=WEEKLY;BYDAY=MO;"); + expect(getRRULE("Tuesday")).toBe("FREQ=WEEKLY;BYDAY=TU;"); + expect(getRRULE("WEDNESDAY")).toBe("FREQ=WEEKLY;BYDAY=WE;"); + expect(getRRULE("thursday")).toBe("FREQ=WEEKLY;BYDAY=TH;"); + expect(getRRULE("Friday")).toBe("FREQ=WEEKLY;BYDAY=FR;"); + expect(getRRULE("saturday")).toBe("FREQ=WEEKLY;BYDAY=SA;"); + expect(getRRULE("sunday")).toBe("FREQ=WEEKLY;BYDAY=SU;"); + }); + + it("returns null for invalid day names", () => { + expect(getRRULE("weekday")).toBeNull(); + expect(getRRULE("mon")).toBeNull(); + expect(getRRULE("")).toBeNull(); + expect(getRRULE("holiday")).toBeNull(); + }); + + it("returns null for non-string input", () => { + expect(getRRULE(null as any)).toBeNull(); + expect(getRRULE(undefined as any)).toBeNull(); + expect(getRRULE(1 as any)).toBeNull(); + }); + + it("returns null for strings with leading or trailing whitespace", () => { + expect(getRRULE(" monday")).toBeNull(); + expect(getRRULE("monday ")).toBeNull(); + }); +}); diff --git a/src/test/data/utils/get-start-end-dates.test.ts b/src/test/data/utils/get-start-end-dates.test.ts new file mode 100644 index 00000000..429d24fc --- /dev/null +++ b/src/test/data/utils/get-start-end-dates.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { getStartEnd } from "../../../data/utils"; + +describe("getStartEnd", () => { + it("maps known morning/noon/afternoon/evening labels to hour ranges", () => { + const morning = getStartEnd("morning"); + expect(morning!.start.getHours()).toBe(8); + expect(morning!.end.getHours()).toBe(11); + + const noon = getStartEnd("noon"); + expect(noon!.start.getHours()).toBe(11); + expect(noon!.end.getHours()).toBe(14); + + const afternoon = getStartEnd("afternoon"); + expect(afternoon!.start.getHours()).toBe(14); + expect(afternoon!.end.getHours()).toBe(17); + + const evening = getStartEnd("evening"); + expect(evening!.start.getHours()).toBe(17); + expect(evening!.end.getHours()).toBe(20); + }); + + it("maps time-range string formats", () => { + const r1 = getStartEnd("08-11"); + expect(r1!.start.getHours()).toBe(8); + expect(r1!.end.getHours()).toBe(11); + + // "8:00 - 10:00" is mapped to startHour:8, endHour:11 in the source map + const r2 = getStartEnd("8:00 - 10:00"); + expect(r2!.start.getHours()).toBe(8); + expect(r2!.end.getHours()).toBe(11); + }); + + it("returns null for unrecognized or unavailability strings", () => { + expect(getStartEnd("not")).toBeNull(); + expect(getStartEnd("available")).toBeNull(); + expect(getStartEnd("verfügbar")).toBeNull(); + expect(getStartEnd("unknown")).toBeNull(); + }); +}); diff --git a/src/test/data/utils/passwd.test.ts b/src/test/data/utils/passwd.test.ts new file mode 100644 index 00000000..77af201a --- /dev/null +++ b/src/test/data/utils/passwd.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { hashPassword, verifyPassword } from "../../../data/utils"; + +// These tests exercise the real bcrypt implementation. Externalized node +// modules (like bcrypt) are not intercepted by `vi.mock` under the swc +// transform used here, so mocking bcrypt is not reliable — we test behaviour. + +describe("hashPassword", () => { + it("returns a bcrypt hash that differs from the plain text", async () => { + const hash = await hashPassword("mypassword"); + expect(typeof hash).toBe("string"); + expect(hash).not.toBe("mypassword"); + expect(hash.startsWith("$2")).toBe(true); + }); + + it("wraps bcrypt errors in a generic message", async () => { + // bcrypt.hash throws when given non-string data, hitting the catch branch. + await expect(hashPassword(undefined as never)).rejects.toThrow( + "Could not hash password.", + ); + }); +}); + +describe("verifyPassword", () => { + it("returns true when the password matches the hash", async () => { + const hash = await hashPassword("correct horse"); + expect(await verifyPassword("correct horse", hash)).toBe(true); + }); + + it("returns false when the password does not match the hash", async () => { + const hash = await hashPassword("correct horse"); + expect(await verifyPassword("wrong", hash)).toBe(false); + }); + + it("wraps bcrypt errors in a generic message", async () => { + // bcrypt.compare throws when the hash argument is missing. + await expect(verifyPassword("plain", undefined as never)).rejects.toThrow( + "Could not verify password.", + ); + }); +}); diff --git a/src/test/e2e/helpers/app.ts b/src/test/e2e/helpers/app.ts new file mode 100644 index 00000000..171764fa --- /dev/null +++ b/src/test/e2e/helpers/app.ts @@ -0,0 +1,15 @@ +import { FastifyInstance } from "fastify"; +import { createServer } from "../../../server"; + +export async function createTestApp(): Promise { + const app = await createServer(); + await app.ready(); + return app; +} + +export function getCookie( + cookies: { name: string; value: string }[], + name: string, +): string | undefined { + return cookies.find((c) => c.name === name)?.value; +} diff --git a/src/test/e2e/setup/global.ts b/src/test/e2e/setup/global.ts new file mode 100644 index 00000000..580657ad --- /dev/null +++ b/src/test/e2e/setup/global.ts @@ -0,0 +1,28 @@ +/** + * Vitest global setup for e2e tests. + * + * Runs once in the main vitest process before any test workers start. + * Applies migrations and seeds all reference + test fixture data so that + * test files can connect to an already-prepared database. + * + * Seeds are idempotent — re-running against an existing DB is safe. + */ +export async function setup(): Promise { + // Dynamic imports avoid decorator metadata issues in the global setup context. + const { dataSource } = await import("../../../data/data-source"); + const { seedReference } = await import("../../../data/seeds/seed"); + const { seedUser } = await import("../../../data/seeds/user.seed"); + const { seedTestFixtures } = await import( + "../../../data/seeds/fixtures/test" + ); + + await dataSource.initialize(); + await dataSource.runMigrations(); + await seedReference(dataSource); + await seedUser(dataSource); + await seedTestFixtures(dataSource); + // Do not destroy: @AfterInsert hooks on OpportunityVolunteer fire async and + // use this same dataSource singleton. Destroying here would cut them off. + // Vitest forks get their own module instances; this connection is only in + // the main process and is released when vitest exits. +} diff --git a/src/test/e2e/smoke/auth.test.ts b/src/test/e2e/smoke/auth.test.ts new file mode 100644 index 00000000..79352f84 --- /dev/null +++ b/src/test/e2e/smoke/auth.test.ts @@ -0,0 +1,95 @@ +import { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { accessCookieName, refreshCookieName } from "../../../config/constants"; +import { TEST_EMAILS } from "../../../data/seeds/fixtures/test"; +import { createTestApp, getCookie } from "../helpers/app"; + +describe("smoke: auth flow", () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe("POST /auth/login", () => { + it("rejects invalid credentials with 401", async () => { + const res = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { email: TEST_EMAILS.admin, password: "wrong_password" }, + }); + expect(res.statusCode).toBe(401); + }); + + it("accepts valid admin credentials and sets httpOnly cookies", async () => { + const res = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { email: TEST_EMAILS.admin, password: "test_password" }, + }); + expect(res.statusCode).toBe(200); + + const access = getCookie(res.cookies, accessCookieName); + const refresh = getCookie(res.cookies, refreshCookieName); + expect(access).toBeDefined(); + expect(refresh).toBeDefined(); + + const accessCookieMeta = res.cookies.find( + (c) => c.name === accessCookieName, + ); + expect(accessCookieMeta?.httpOnly).toBe(true); + }); + }); + + describe("GET /user/me", () => { + it("returns 401 without auth cookie", async () => { + const res = await app.inject({ method: "GET", url: "/user/me" }); + expect(res.statusCode).toBe(401); + }); + + it("returns current user data with valid access cookie", async () => { + const loginRes = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { email: TEST_EMAILS.admin, password: "test_password" }, + }); + const accessToken = getCookie(loginRes.cookies, accessCookieName); + + const res = await app.inject({ + method: "GET", + url: "/user/me", + cookies: { [accessCookieName]: accessToken }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data.email).toBe(TEST_EMAILS.admin); + }); + }); + + describe("role enforcement", () => { + it("coordinator can hit /user/me", async () => { + const loginRes = await app.inject({ + method: "POST", + url: "/auth/login", + payload: { + email: TEST_EMAILS.coordinator, + password: "test_password", + }, + }); + const accessToken = getCookie(loginRes.cookies, accessCookieName); + + const res = await app.inject({ + method: "GET", + url: "/user/me", + cookies: { [accessCookieName]: accessToken }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().data.email).toBe(TEST_EMAILS.coordinator); + }); + }); +}); diff --git a/src/test/e2e/smoke/health.test.ts b/src/test/e2e/smoke/health.test.ts new file mode 100644 index 00000000..7f9cd3cb --- /dev/null +++ b/src/test/e2e/smoke/health.test.ts @@ -0,0 +1,23 @@ +import { FastifyInstance } from "fastify"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createTestApp } from "../helpers/app"; + +describe("smoke: health check", () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("GET /health-check returns 200", async () => { + const res = await app.inject({ method: "GET", url: "/health-check" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ + message: expect.stringContaining("up and running"), + }); + }); +}); diff --git a/src/test/server/index.test.ts b/src/test/server/index.test.ts index 273da0cb..25b7437b 100644 --- a/src/test/server/index.test.ts +++ b/src/test/server/index.test.ts @@ -16,13 +16,15 @@ describe("Fastify sanity check", () => { it("should create the server without errors", async () => { await fastify.ready(); - const health = { message: "Need4Deed API v1 is up and running." }; + const healthMsg = "Need4Deed API v1 is up and running."; const response = await fastify.inject({ method: "GET", url: "/health-check", }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual(health); + expect(response.json()).toMatchObject({ + message: expect.stringContaining(healthMsg), + }); }); }); diff --git a/src/test/server/routes/auth.test.ts b/src/test/server/routes/auth.test.ts index f658b93e..b52404b7 100644 --- a/src/test/server/routes/auth.test.ts +++ b/src/test/server/routes/auth.test.ts @@ -1,8 +1,36 @@ import { FastifyInstance } from "fastify"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; import { accessCookieName, refreshCookieName } from "../../../config/constants"; import { createServer } from "../../../server"; +vi.mock("../../../data", async () => { + const actual = await vi.importActual("../../../data"); + return { + ...actual, + initDatabase: vi.fn().mockImplementation(() => Promise.resolve()), + }; +}); + +vi.mock("../../../data/utils", async () => { + const actual = await vi.importActual("../../../data/utils"); + return { + ...actual, + hashPassword: vi + .fn() + .mockImplementation((password: string) => + Promise.resolve("hashed-password-" + password), + ), + }; +}); + describe("POST /auth/logout", () => { let fastify: FastifyInstance; @@ -42,3 +70,258 @@ describe("POST /auth/logout", () => { } }); }); + +describe("POST /auth/reset-password", () => { + let fastify: FastifyInstance; + + beforeAll(async () => { + fastify = await createServer(); + await fastify.ready(); + }); + + afterAll(async () => { + await fastify.close(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects a token of type 'access'", async () => { + const nonResetToken = fastify.jwt.sign({ + id: 999, + email: "test@example.com", + type: "access", + }); + + const response = await fastify.inject({ + method: "POST", + url: "/auth/password-reset", + payload: { token: nonResetToken, newPassword: "newpass123456" }, + }); + + // Since 400 can also be returned for other reasons, check message + expect(response.json()).toEqual({ + message: "Invalid reset token.", + }); + expect(response.statusCode).toBe(400); + }); + + it("rejects a token of type 'verify'", async () => { + const nonResetToken = fastify.jwt.sign({ + id: 999, + email: "test@example.com", + type: "verify", + }); + + const response = await fastify.inject({ + method: "POST", + url: "/auth/password-reset", + payload: { token: nonResetToken, newPassword: "newpass123456" }, + }); + + // Since 400 can also be returned for other reasons, check message + expect(response.json()).toEqual({ + message: "Invalid reset token.", + }); + expect(response.statusCode).toBe(400); + }); + + it("rejects an invalid token", async () => { + const response = await fastify.inject({ + method: "POST", + url: "/auth/password-reset", + payload: { token: "not-a-valid-jwt", newPassword: "newpass123456" }, + }); + + // Since 400 can also be returned for other reasons, check message + expect(response.json()).toEqual({ + message: "Invalid reset token.", + }); + expect(response.statusCode).toBe(400); + }); + + it("resets password with a valid reset token", async () => { + const resetToken = fastify.jwt.sign({ + id: 999, + email: "test@example.com", + type: "reset", + }); + + vi.spyOn(fastify.db.userRepository, "findOne").mockResolvedValue({ + id: 999, + } as any); + const updateSpy = vi + .spyOn(fastify.db.userRepository, "update") + .mockResolvedValue({} as any); + + const response = await fastify.inject({ + method: "POST", + url: "/auth/password-reset", + payload: { token: resetToken, newPassword: "newpass123456" }, + }); + + expect(updateSpy).toHaveBeenCalledWith( + { id: 999 }, + { password: "hashed-password-newpass123456" }, + ); + expect(response.statusCode).toBe(200); + }); +}); + +describe("POST /auth/password-change", () => { + let fastify: FastifyInstance; + + beforeAll(async () => { + fastify = await createServer(); + await fastify.ready(); + }); + + afterAll(async () => { + await fastify.close(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns 401 when not authenticated", async () => { + const response = await fastify.inject({ + method: "POST", + url: "/auth/password-change", + payload: { password: "currentpass123", newPassword: "newpass123456" }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("returns 400 when current password is incorrect", async () => { + const accessToken = fastify.jwt.sign({ + id: 999, + email: "test@example.com", + type: "access", + }); + + const checkPassword = vi.fn().mockResolvedValue(false); + vi.spyOn(fastify.db.userRepository, "findOne").mockResolvedValue({ + id: 999, + checkPassword, + } as any); + + const response = await fastify.inject({ + method: "POST", + url: "/auth/password-change", + cookies: { access: accessToken }, + payload: { password: "wrongpass", newPassword: "newpass123456" }, + }); + + // Since 400 can also be returned for other reasons, check message + expect(response.json()).toEqual({ + message: "Current password is incorrect.", + }); + expect(response.statusCode).toBe(400); + }); + + it("changes password when current password matches", async () => { + const accessToken = fastify.jwt.sign({ + id: 999, + email: "test@example.com", + type: "access", + }); + + const checkPassword = vi.fn().mockResolvedValue(true); + vi.spyOn(fastify.db.userRepository, "findOne").mockResolvedValue({ + id: 999, + checkPassword, + } as any); + const updateSpy = vi + .spyOn(fastify.db.userRepository, "update") + .mockResolvedValue({} as any); + + const currentPass = "currentpass123"; + const response = await fastify.inject({ + method: "POST", + url: "/auth/password-change", + cookies: { access: accessToken }, + payload: { password: currentPass, newPassword: "newpass123456" }, + }); + + expect(checkPassword).toHaveBeenCalledWith(currentPass); + expect(updateSpy).toHaveBeenCalledWith( + { id: 999 }, + { password: "hashed-password-newpass123456" }, + ); + expect(response.statusCode).toBe(200); + }); +}); + +describe("POST /auth/request-reset", () => { + let fastify: FastifyInstance; + + beforeAll(async () => { + fastify = await createServer(); + await fastify.ready(); + }); + + afterAll(async () => { + await fastify.close(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends reset email when user exists and is active", async () => { + const mockUser = { id: 1, email: "test@example.com", isActive: true }; + vi.spyOn(fastify.db.userRepository, "findOne").mockResolvedValue( + mockUser as any, + ); + + const passwordResetSpy = vi.fn().mockResolvedValue(undefined); + fastify.notify.passwordReset = passwordResetSpy; + + const response = await fastify.inject({ + method: "POST", + url: "/auth/request-reset", + payload: { email: "test@example.com" }, + }); + + expect(response.statusCode).toBe(200); + expect(passwordResetSpy).toHaveBeenCalledWith(mockUser); + }); + + it("does not send email when user is inactive", async () => { + const mockUser = { id: 1, email: "test@example.com", isActive: false }; + vi.spyOn(fastify.db.userRepository, "findOne").mockResolvedValue( + mockUser as any, + ); + + const passwordResetSpy = vi.fn().mockResolvedValue(undefined); + fastify.notify.passwordReset = passwordResetSpy; + + const response = await fastify.inject({ + method: "POST", + url: "/auth/request-reset", + payload: { email: "test@example.com" }, + }); + + expect(response.statusCode).toBe(200); + expect(passwordResetSpy).not.toHaveBeenCalled(); + }); + + it("does not send email when user does not exist", async () => { + vi.spyOn(fastify.db.userRepository, "findOne").mockResolvedValue(null); + + const passwordResetSpy = vi.fn().mockResolvedValue(undefined); + fastify.notify.passwordReset = passwordResetSpy; + + const response = await fastify.inject({ + method: "POST", + url: "/auth/request-reset", + payload: { email: "test@example.com" }, + }); + + expect(response.statusCode).toBe(200); + expect(passwordResetSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/server/utils/data/get-agent-by-postcode.test.ts b/src/test/server/utils/data/get-agent-by-postcode.test.ts index 39d511ed..572b788c 100644 --- a/src/test/server/utils/data/get-agent-by-postcode.test.ts +++ b/src/test/server/utils/data/get-agent-by-postcode.test.ts @@ -6,8 +6,14 @@ import { describe("getAgentByPostcode", () => { const agents = [ - { id: 1, address: { postcode: { value: "12345" }, street: "Müllerstraße 48" } }, - { id: 2, address: { postcode: { value: "55555" }, street: "Hauptstr. 10" } }, + { + id: 1, + address: { postcode: { value: "12345" }, street: "Müllerstraße 48" }, + }, + { + id: 2, + address: { postcode: { value: "55555" }, street: "Hauptstr. 10" }, + }, ] as any; it("returns agent for matching postcode", () => { @@ -45,12 +51,22 @@ describe("getAgentByAddress — street normalisation", () => { }); it("does not match wrong postcode", () => { - expect(getAgentByAddress(agents, "Müllerstraße 48", "00000")).toBeUndefined(); + expect( + getAgentByAddress(agents, "Müllerstraße 48", "00000"), + ).toBeUndefined(); }); it("does not match wrong street", () => { expect(getAgentByAddress(agents, "Hauptstraße 1", plz)).toBeUndefined(); }); + + it("matches by street alone when PLZ is omitted", () => { + expect(getAgentByAddress(agents, "Müllerstraße 48")).toEqual(agent); + }); + + it("does not match wrong street when PLZ is omitted", () => { + expect(getAgentByAddress(agents, "Hauptstraße 1")).toBeUndefined(); + }); }); describe("getAgentByAddress — fuzzy fallback for legacy agents", () => { @@ -64,11 +80,15 @@ describe("getAgentByAddress — fuzzy fallback for legacy agents", () => { const plz = "13353"; it("matches form address street name against agent title via agentPostcode PLZ", () => { - expect(getAgentByAddress(agents, "Hausvaterweg 21", plz)).toEqual(legacyAgent); + expect(getAgentByAddress(agents, "Hausvaterweg 21", plz)).toEqual( + legacyAgent, + ); }); it("does not match when PLZ differs", () => { - expect(getAgentByAddress(agents, "Hausvaterweg 21", "99999")).toBeUndefined(); + expect( + getAgentByAddress(agents, "Hausvaterweg 21", "99999"), + ).toBeUndefined(); }); it("does not match when street name not in title", () => { @@ -88,8 +108,12 @@ describe("getAgentByAddress — fuzzy fallback for legacy agents", () => { address: null, agentPostcode: [{ postcode: { value: "13353" } }], } as any; - expect(getAgentByAddress([agentAt21, agentAt45], "Hausvaterweg 21", plz)).toEqual(agentAt21); - expect(getAgentByAddress([agentAt21, agentAt45], "Hausvaterweg 45", plz)).toEqual(agentAt45); + expect( + getAgentByAddress([agentAt21, agentAt45], "Hausvaterweg 21", plz), + ).toEqual(agentAt21); + expect( + getAgentByAddress([agentAt21, agentAt45], "Hausvaterweg 45", plz), + ).toEqual(agentAt45); }); it("returns undefined when multiple agents share street and PLZ and none has the number in title", () => { @@ -99,7 +123,9 @@ describe("getAgentByAddress — fuzzy fallback for legacy agents", () => { address: null, agentPostcode: [{ postcode: { value: "13353" } }], } as any; - expect(getAgentByAddress([legacyAgent, second], "Hausvaterweg 21", plz)).toBeUndefined(); + expect( + getAgentByAddress([legacyAgent, second], "Hausvaterweg 21", plz), + ).toBeUndefined(); }); it("does not match 'Heerstr 10' when form submits 'Heerstr 110' (number prefix false positive)", () => { @@ -109,7 +135,9 @@ describe("getAgentByAddress — fuzzy fallback for legacy agents", () => { address: null, agentPostcode: [{ postcode: { value: "13353" } }], } as any; - expect(getAgentByAddress([heerstr10], "Heerstr 110", "13353")).toBeUndefined(); + expect( + getAgentByAddress([heerstr10], "Heerstr 110", "13353"), + ).toBeUndefined(); }); it("matches 'Heerstr 110' agent when form submits 'Heerstr 110'", () => { @@ -125,7 +153,9 @@ describe("getAgentByAddress — fuzzy fallback for legacy agents", () => { address: null, agentPostcode: [{ postcode: { value: "13353" } }], } as any; - expect(getAgentByAddress([heerstr10, heerstr110], "Heerstr 110", "13353")).toEqual(heerstr110); + expect( + getAgentByAddress([heerstr10, heerstr110], "Heerstr 110", "13353"), + ).toEqual(heerstr110); }); it("does not false-match when street name is a substring of a different street in title", () => { @@ -140,10 +170,14 @@ describe("getAgentByAddress — fuzzy fallback for legacy agents", () => { }); it("matches address with hyphenated house number range", () => { - expect(getAgentByAddress(agents, "Hausvaterweg 5-7", plz)).toEqual(legacyAgent); + expect(getAgentByAddress(agents, "Hausvaterweg 5-7", plz)).toEqual( + legacyAgent, + ); }); it("matches address with space-separated letter suffix (e.g. '21 A')", () => { - expect(getAgentByAddress(agents, "Hausvaterweg 21 A", plz)).toEqual(legacyAgent); + expect(getAgentByAddress(agents, "Hausvaterweg 21 A", plz)).toEqual( + legacyAgent, + ); }); }); diff --git a/src/test/server/utils/data/validate-relation-ids.test.ts b/src/test/server/utils/data/validate-relation-ids.test.ts new file mode 100644 index 00000000..f39560fb --- /dev/null +++ b/src/test/server/utils/data/validate-relation-ids.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { BadRequestError } from "../../../../config"; +import { validateRelationIds } from "../../../../server/utils/data/validate-relation-ids"; + +describe("validateRelationIds", () => { + it("does not throw when all requested ids are found", () => { + expect(() => + validateRelationIds( + [1, 2, 3], + [{ id: 1 }, { id: 2 }, { id: 3 }], + "person", + ), + ).not.toThrow(); + }); + + it("throws BadRequestError when any id is missing", () => { + expect(() => validateRelationIds([1, 2, 3], [{ id: 1 }], "person")).toThrow( + BadRequestError, + ); + }); + + it("includes the label and missing ids in the error message", () => { + expect(() => + validateRelationIds([1, 2, 3], [{ id: 1 }], "tagged person"), + ).toThrow("Invalid tagged person id(s): 2, 3"); + }); + + it("deduplicates requested ids before checking", () => { + expect(() => + validateRelationIds([1, 1, 2], [{ id: 1 }, { id: 2 }], "person"), + ).not.toThrow(); + }); + + it("does not throw when requestedIds is empty", () => { + expect(() => validateRelationIds([], [], "person")).not.toThrow(); + }); +}); diff --git a/src/test/server/utils/types.test.ts b/src/test/server/utils/types.test.ts index d62037c4..5228fb4f 100644 --- a/src/test/server/utils/types.test.ts +++ b/src/test/server/utils/types.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from "vitest"; -import type { EnumValuesMap, ExtendWithEnumValues, DeeplyNestedObject } from "../../../server/utils"; +import { describe, expect, it } from "vitest"; +import type { + DeeplyNestedObject, + EnumValuesMap, + ExtendWithEnumValues, +} from "../../../server/utils"; describe("server/utils/types", () => { it("EnumValuesMap: maps enum values to V and enforces keys/types at compile time", () => { @@ -10,13 +14,13 @@ describe("server/utils/types", () => { expect(ok.a).toBe(1); // @ts-expect-error - missing key 'b' - const missingKey: M = { a: 1 }; + const _missingKey: M = { a: 1 }; // @ts-expect-error - extra key 'c' not allowed - const extraKey: M = { a: 1, b: 2, c: 3 }; + const _extraKey: M = { a: 1, b: 2, c: 3 }; // @ts-expect-error - wrong value type for 'a' - const wrongValue: M = { a: "no", b: 2 }; + const _wrongValue: M = { a: "no", b: 2 }; }); it("ExtendWithEnumValues: merges base and enum-derived keys", () => { @@ -28,10 +32,10 @@ describe("server/utils/types", () => { expect(ok.id).toBe(10); // @ts-expect-error - missing enum key 'y' - const missingEnum: R = { id: 1, x: true }; + const _missingEnum: R = { id: 1, x: true }; // @ts-expect-error - wrong type for enum key 'x' - const wrongEnumType: R = { id: 1, x: 1, y: false }; + const _wrongEnumType: R = { id: 1, x: 1, y: false }; }); it("DeeplyNestedObject: accepts primitives, nested plain objects and arrays; rejects disallowed types", () => { @@ -48,9 +52,9 @@ describe("server/utils/types", () => { expect((good.obj as any).inner.leaf).toBe("v"); // @ts-expect-error - functions are not allowed - const badFn: DeeplyNestedObject = { f: () => {} }; + const _badFn: DeeplyNestedObject = { f: () => {} }; // @ts-expect-error - Date (class instance) is not permitted by the type - const badDate: DeeplyNestedObject = { d: new Date() }; + const _badDate: DeeplyNestedObject = { d: new Date() }; }); -}); \ No newline at end of file +}); diff --git a/src/test/services/dto/dto-agent.test.ts b/src/test/services/dto/dto-agent.test.ts index 10713ee6..b02676bf 100644 --- a/src/test/services/dto/dto-agent.test.ts +++ b/src/test/services/dto/dto-agent.test.ts @@ -1,4 +1,4 @@ -import { AgentVolunteerSearchType } from "need4deed-sdk"; +import { AgentTrustType, AgentVolunteerSearchType } from "need4deed-sdk"; import { describe, expect, it, vi } from "vitest"; import Agent from "../../../data/entity/opportunity/agent.entity"; import { @@ -143,24 +143,26 @@ describe("dtoAgentGetList", () => { title: "Helping Hands", type: "NGO", activeVolunteers: 10, + numOpportunities: 0, addressId: 101, searchStatus: AgentVolunteerSearchType.SEARCHING, districtId: 201, }; - it("should correctly map a complete agent object", () => { + it("maps all scalar fields from a fully-loaded agent", () => { const fullAgent = { ...mockAgentBase, + trustLevel: AgentTrustType.HIGH, address: { street: "Main St", city: "Berlin", postcodeId: 501, postcode: { value: "10115" }, }, - district: { - title: "Mitte", - }, - } as Agent & { activeVolunteers: number }; + district: { title: "Mitte" }, + representative: { person: { email: "contact@helping-hands.org" } }, + opportunity: [{} as any, {} as any], + } as Agent & { activeVolunteers: number; numOpportunities: number }; const result = dtoAgentGetList(fullAgent); @@ -168,9 +170,10 @@ describe("dtoAgentGetList", () => { id: 1, title: "Helping Hands", type: "NGO", + trustLevel: AgentTrustType.HIGH, activeVolunteers: 10, - numActiveVolunteers: 10, - email: "", + numOpportunities: 2, + email: "contact@helping-hands.org", district: { id: 201, title: { de: "Mitte" }, @@ -204,10 +207,19 @@ describe("dtoAgentOpportunity", () => { const baseOpportunity = { id: 42, title: "German tutoring", + type: "regular", status: "opp-active", statusMatch: "opp-vol-matched", numberVolunteers: 3, createdAt: new Date("2026-01-15"), + districtId: 7, + district: { id: 7 }, + deal: { + dealLanguage: [], + dealActivity: [], + dealDistrict: [], + dealTimeslot: [], + }, }; it("maps the opportunity scalars and its linked volunteers", () => { @@ -226,10 +238,16 @@ describe("dtoAgentOpportunity", () => { expect(dtoAgentOpportunity(opportunity as any)).toEqual({ id: 42, title: "German tutoring", + volunteerType: "regular", statusOpportunity: "opp-active", statusMatch: "opp-vol-matched", numberOfVolunteers: 3, createdAt: new Date("2026-01-15"), + district: { id: 7 }, + languages: [], + activities: [], + location: [], + availability: [], volunteers: [ { id: 5, diff --git a/src/test/services/dto/dto-comment.test.ts b/src/test/services/dto/dto-comment.test.ts index 19bc8188..7f7acd87 100644 --- a/src/test/services/dto/dto-comment.test.ts +++ b/src/test/services/dto/dto-comment.test.ts @@ -21,11 +21,12 @@ describe("commentSerializer", () => { entityType: "volunteer", authorName: "Coordinator Jane", timestamp: comment.updatedAt, - taggedPersonIds: [], + taggedPersons: [], }); }); - it("maps commentPerson rows to taggedPersonIds", () => { + it("maps commentPerson rows to taggedPersons with readAt", () => { + const readAt = new Date("2025-04-01"); const comment = { id: 10, text: "Hey <@5> and <@9>", @@ -33,14 +34,20 @@ describe("commentSerializer", () => { entityType: "opportunity", updatedAt: new Date(), user: { person: { name: "Author" } }, - commentPerson: [{ personId: 5 }, { personId: 9 }], + commentPerson: [ + { personId: 5, readAt }, + { personId: 9, readAt: null }, + ], }; const result = commentSerializer(comment as any); - expect(result.taggedPersonIds).toEqual([5, 9]); + expect(result.taggedPersons).toEqual([ + { id: 5, readAt }, + { id: 9, readAt: null }, + ]); }); - it("returns an empty taggedPersonIds when commentPerson is missing", () => { + it("returns an empty taggedPersons when commentPerson is missing", () => { const comment = { id: 11, text: "no tags", @@ -51,7 +58,7 @@ describe("commentSerializer", () => { }; const result = commentSerializer(comment as any); - expect(result.taggedPersonIds).toEqual([]); + expect(result.taggedPersons).toEqual([]); }); it("returns undefined authorName when user has no person", () => { diff --git a/src/test/services/dto/dto-user.test.ts b/src/test/services/dto/dto-user.test.ts index a4d88efa..f2ee8374 100644 --- a/src/test/services/dto/dto-user.test.ts +++ b/src/test/services/dto/dto-user.test.ts @@ -55,6 +55,38 @@ describe("serializeUserToMeDTO", () => { expect(result.personId).toBeUndefined(); }); + it("includes agentId in the result when provided", () => { + const user = { + id: 4, + personId: 10, + email: "agent@example.com", + isActive: true, + role: "agent", + language: "en", + timezone: "CET", + person: { firstName: "Bob", name: "Bob Agent", avatarUrl: "" }, + }; + + const result = serializeUserToMeDTO(user as any, 99); + expect(result.agentId).toBe(99); + }); + + it("omits agentId when not provided", () => { + const user = { + id: 5, + personId: 11, + email: "vol@example.com", + isActive: true, + role: "volunteer", + language: "en", + timezone: "CET", + person: { firstName: "Eve", name: "Eve Vol", avatarUrl: "" }, + }; + + const result = serializeUserToMeDTO(user as any); + expect(result.agentId).toBeUndefined(); + }); + it("falls back to default isoCode 'en' and timezone 'CET' when not set", () => { const user = { id: 3, diff --git a/src/test/services/jobs/german-holidays.test.ts b/src/test/services/jobs/german-holidays.test.ts new file mode 100644 index 00000000..8dd9324d --- /dev/null +++ b/src/test/services/jobs/german-holidays.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + addWorkingDays, + berlinToday, + isGermanPublicHoliday, +} from "../../../services/jobs/german-holidays"; + +// Known Easter dates for reference years +// 2024: Mar 31 2025: Apr 20 2026: Apr 5 2023: Apr 9 +const d = (y: number, m: number, day: number) => new Date(y, m - 1, day); + +describe("isGermanPublicHoliday", () => { + describe("fixed federal holidays", () => { + it.each([ + ["Neujahr", d(2026, 1, 1)], + ["Tag der Arbeit", d(2026, 5, 1)], + ["Tag der Deutschen Einheit", d(2026, 10, 3)], + ["1. Weihnachtstag", d(2026, 12, 25)], + ["2. Weihnachtstag", d(2026, 12, 26)], + ])("%s is a holiday", (_name, date) => { + expect(isGermanPublicHoliday(date)).toBe(true); + }); + }); + + describe("Berlin state holiday", () => { + it("Internationaler Frauentag (Mar 8) is a holiday", () => { + expect(isGermanPublicHoliday(d(2026, 3, 8))).toBe(true); + }); + }); + + describe("Easter-based holidays", () => { + // 2026: Easter Sunday = Apr 5 + it.each([ + ["Karfreitag 2026", d(2026, 4, 3)], + ["Ostermontag 2026", d(2026, 4, 6)], + ["Christi Himmelfahrt 2026", d(2026, 5, 14)], + ["Pfingstmontag 2026", d(2026, 5, 25)], + ])("%s is a holiday", (_name, date) => { + expect(isGermanPublicHoliday(date)).toBe(true); + }); + + // 2025: Easter Sunday = Apr 20 + it.each([ + ["Karfreitag 2025", d(2025, 4, 18)], + ["Ostermontag 2025", d(2025, 4, 21)], + ["Christi Himmelfahrt 2025", d(2025, 5, 29)], + ["Pfingstmontag 2025", d(2025, 6, 9)], + ])("%s is a holiday", (_name, date) => { + expect(isGermanPublicHoliday(date)).toBe(true); + }); + + // 2024: Easter Sunday = Mar 31 + it.each([ + ["Karfreitag 2024", d(2024, 3, 29)], + ["Ostermontag 2024", d(2024, 4, 1)], + ["Christi Himmelfahrt 2024", d(2024, 5, 9)], + ["Pfingstmontag 2024", d(2024, 5, 20)], + ])("%s is a holiday", (_name, date) => { + expect(isGermanPublicHoliday(date)).toBe(true); + }); + }); + + describe("regular working days", () => { + it.each([ + d(2026, 1, 2), // Friday after Neujahr + d(2026, 4, 7), // Tuesday after Ostermontag + d(2026, 7, 1), // mid-summer Wednesday + d(2026, 12, 24), // Heiligabend (NOT a public holiday) + ])("%s is not a holiday", (date) => { + expect(isGermanPublicHoliday(date)).toBe(false); + }); + + it("Heiligabend (Dec 24) is not a public holiday", () => { + expect(isGermanPublicHoliday(d(2026, 12, 24))).toBe(false); + }); + }); +}); + +describe("addWorkingDays", () => { + it("skips weekends", () => { + // Friday 2026-01-02 + 1 working day = Monday 2026-01-05 + const result = addWorkingDays(d(2026, 1, 2), 1); + expect(result).toEqual(d(2026, 1, 5)); + }); + + it("skips weekends when subtracting days", () => { + // Monday 2026-01-05 - 1 working day = Friday 2026-01-02 + const result = addWorkingDays(d(2026, 1, 5), -1); + expect(result).toEqual(d(2026, 1, 2)); + }); + + it("skips Saturday and Sunday when spanning a weekend", () => { + // Thursday 2026-01-08 + 2 working days = Monday 2026-01-12 + const result = addWorkingDays(d(2026, 1, 8), 2); + expect(result).toEqual(d(2026, 1, 12)); + }); + + it("skips public holidays", () => { + // Thursday 2026-04-02 + 1 working day skips Karfreitag (Apr 3) → Monday Apr 6 + // (Ostermontag Apr 6 is also a holiday → Tuesday Apr 7) + const result = addWorkingDays(d(2026, 4, 2), 1); + expect(result).toEqual(d(2026, 4, 7)); + }); + + it("4 working days across Easter weekend", () => { + // Mon 2026-03-30: day1=Mar31, day2=Apr1, day3=Apr2, + // then skip Karfreitag Apr3, skip weekend Apr4-5, skip Ostermontag Apr6 + // day4=Apr7 + const result = addWorkingDays(d(2026, 3, 30), 4); + expect(result).toEqual(d(2026, 4, 7)); + }); + + it("adding 0 working days returns the same day", () => { + const result = addWorkingDays(d(2026, 6, 10), 0); + expect(result).toEqual(d(2026, 6, 10)); + }); + + it("does not count the start date itself", () => { + // Monday + 1 = Tuesday + const result = addWorkingDays(d(2026, 6, 1), 1); + expect(result).toEqual(d(2026, 6, 2)); + }); +}); + +describe("berlinToday", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the current Berlin calendar date as a local Date", () => { + // Freeze to a known UTC time: 2026-07-01T22:30:00Z = 2026-07-02 in Berlin (UTC+2) + vi.setSystemTime(new Date("2026-07-01T22:30:00Z")); + const result = berlinToday(); + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth()).toBe(6); // July = 6 (0-indexed) + expect(result.getDate()).toBe(2); + }); + + it("returns the same day when UTC and Berlin date match", () => { + // 2026-07-01T10:00:00Z = 2026-07-01 12:00 Berlin + vi.setSystemTime(new Date("2026-07-01T10:00:00Z")); + const result = berlinToday(); + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth()).toBe(6); + expect(result.getDate()).toBe(1); + }); +}); diff --git a/src/test/services/jobs/scan-expired-onetimers.test.ts b/src/test/services/jobs/scan-expired-onetimers.test.ts new file mode 100644 index 00000000..ad0b653b --- /dev/null +++ b/src/test/services/jobs/scan-expired-onetimers.test.ts @@ -0,0 +1,156 @@ +import { FastifyInstance } from "fastify"; +import { + OpportunityStatusType, + OpportunityVolunteerStatusType, +} from "need4deed-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import OpportunityVolunteer from "../../../data/entity/m2m/opportunity-volunteer"; +import Opportunity from "../../../data/entity/opportunity/opportunity.entity"; +import { scanExpiredOnetimers } from "../../../services/jobs/scan-expired-onetimers"; + +const loggerErrorMock = vi.fn(); +const loggerInfoMock = vi.fn(); +vi.mock("../../../logger", () => ({ + default: { + error: (...args: unknown[]) => loggerErrorMock(...args), + info: (...args: unknown[]) => loggerInfoMock(...args), + }, +})); + +const getMany = vi.fn(); +const opportunityRepositorySave = vi.fn(); +const opportunityVolunteerRepositorySave = vi.fn(); + +const qbMock = { + leftJoinAndSelect: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + getMany, +}; + +const fastify = { + db: { + opportunityRepository: { + createQueryBuilder: vi.fn(() => qbMock), + save: (...args: unknown[]) => opportunityRepositorySave(...args), + }, + opportunityVolunteerRepository: { + save: (...args: unknown[]) => opportunityVolunteerRepositorySave(...args), + }, + }, +} as unknown as FastifyInstance; + +function buildOpportunity( + id: number, + ovs: Partial[], +): Opportunity { + return new Opportunity({ + id, + status: OpportunityStatusType.ACTIVE, + opportunityVolunteer: ovs.map((ov) => new OpportunityVolunteer(ov)), + } as Partial); +} + +beforeEach(() => { + vi.clearAllMocks(); + opportunityRepositorySave.mockImplementation( + async (opportunity: Opportunity) => opportunity, + ); + opportunityVolunteerRepositorySave.mockImplementation( + async (ov: OpportunityVolunteer) => ov, + ); +}); + +describe("scanExpiredOnetimers", () => { + it("does nothing when there are no expired opportunities", async () => { + getMany.mockResolvedValue([]); + + await scanExpiredOnetimers(fastify); + + expect(opportunityRepositorySave).not.toHaveBeenCalled(); + expect(opportunityVolunteerRepositorySave).not.toHaveBeenCalled(); + expect(loggerInfoMock).not.toHaveBeenCalled(); + }); + + it("marks expired opportunities INACTIVE and flips matched volunteers to PAST", async () => { + const matched = { id: 1, status: OpportunityVolunteerStatusType.MATCHED }; + const pending = { id: 2, status: OpportunityVolunteerStatusType.PENDING }; + const opportunity = buildOpportunity(10, [matched, pending]); + getMany.mockResolvedValue([opportunity]); + + await scanExpiredOnetimers(fastify); + + expect(opportunity.status).toBe(OpportunityStatusType.INACTIVE); + expect(opportunityRepositorySave).toHaveBeenCalledWith(opportunity); + + expect(opportunityVolunteerRepositorySave).toHaveBeenCalledTimes(1); + expect(opportunity.opportunityVolunteer[0].status).toBe( + OpportunityVolunteerStatusType.PAST, + ); + expect(opportunity.opportunityVolunteer[1].status).toBe( + OpportunityVolunteerStatusType.PENDING, + ); + expect(loggerInfoMock).toHaveBeenCalledWith( + expect.stringContaining("marked 1 opportunities as INACTIVE"), + ); + }); + + it("keeps processing remaining opportunities when one fails to save", async () => { + const failing = buildOpportunity(1, [ + { id: 1, status: OpportunityVolunteerStatusType.MATCHED }, + ]); + const succeeding = buildOpportunity(2, [ + { id: 2, status: OpportunityVolunteerStatusType.MATCHED }, + ]); + getMany.mockResolvedValue([failing, succeeding]); + + opportunityRepositorySave.mockImplementationOnce(async () => { + throw new Error("db unavailable"); + }); + + await scanExpiredOnetimers(fastify); + + expect(loggerErrorMock).toHaveBeenCalledWith( + expect.objectContaining({ opportunityId: 1 }), + expect.stringContaining("failed to mark opportunity as INACTIVE"), + ); + + // The failing opportunity's volunteer must not have been touched. + expect(failing.opportunityVolunteer[0].status).toBe( + OpportunityVolunteerStatusType.MATCHED, + ); + + // The second opportunity is still processed despite the first one failing. + expect(succeeding.status).toBe(OpportunityStatusType.INACTIVE); + expect(succeeding.opportunityVolunteer[0].status).toBe( + OpportunityVolunteerStatusType.PAST, + ); + }); + + it("keeps processing remaining volunteers when one volunteer save fails", async () => { + const first = { id: 1, status: OpportunityVolunteerStatusType.MATCHED }; + const second = { id: 2, status: OpportunityVolunteerStatusType.MATCHED }; + const opportunity = buildOpportunity(1, [first, second]); + getMany.mockResolvedValue([opportunity]); + + opportunityVolunteerRepositorySave.mockImplementationOnce(async () => { + throw new Error("db unavailable"); + }); + + await scanExpiredOnetimers(fastify); + + expect(loggerErrorMock).toHaveBeenCalledWith( + expect.objectContaining({ + opportunityId: opportunity.id, + opportunityVolunteerId: 1, + }), + expect.stringContaining("failed to mark opportunity volunteer as PAST"), + ); + + // Second volunteer still gets updated despite the first one failing. + expect(opportunity.opportunityVolunteer[1].status).toBe( + OpportunityVolunteerStatusType.PAST, + ); + expect(opportunity.status).toBe(OpportunityStatusType.INACTIVE); + }); +}); diff --git a/src/test/services/notify/email-brevo.test.ts b/src/test/services/notify/email-brevo.test.ts index dedecebb..e2957c2e 100644 --- a/src/test/services/notify/email-brevo.test.ts +++ b/src/test/services/notify/email-brevo.test.ts @@ -49,7 +49,7 @@ describe("BrevoEmailTransport", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("throws with status and body on a non-2xx response", async () => { + it("throws with status on a non-2xx response (no body to avoid PII in logs)", async () => { fetchMock.mockResolvedValueOnce({ ok: false, status: 400, @@ -58,6 +58,6 @@ describe("BrevoEmailTransport", () => { await expect( new BrevoEmailTransport("secret-key").send(msg), - ).rejects.toThrow(/Brevo email failed \(400\): .*Invalid sender/); + ).rejects.toThrow(/Brevo email failed \(400\)$/); }); }); diff --git a/src/test/services/notify/email-password-reset.test.ts b/src/test/services/notify/email-password-reset.test.ts new file mode 100644 index 00000000..88182028 --- /dev/null +++ b/src/test/services/notify/email-password-reset.test.ts @@ -0,0 +1,93 @@ +import { Lang } from "need4deed-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { urlPasswordReset } from "../../../config/constants"; +import { fetchJsonFromUrl } from "../../../data/utils"; +import { + resetPasswordResetTemplateCache, + sendPasswordReset, +} from "../../../services/notify/events/email-password-reset"; + +vi.mock("../../../data/utils", () => ({ + fetchJsonFromUrl: vi.fn(), +})); + +const send = vi.fn(); +const deps = { email: { send }, jwt: { sign: () => "tok" } } as any; +const user = (over: any = {}) => ({ id: 1, email: "u@x.de", ...over }); +const expectedUrl = `${urlPasswordReset}?token=${encodeURIComponent("tok")}`; + +const manifest = { + en: { + subject: "Password Reset", + html: '{{resetUrl}}', + text: "reset: {{resetUrl}}", + }, + de: { + subject: "Passwort zurücksetzen", + html: '{{resetUrl}}', + text: "zurücksetzen: {{resetUrl}}", + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + resetPasswordResetTemplateCache(); +}); + +describe("sendPasswordReset", () => { + it("uses the manifest entry for the user's locale and substitutes the URL", async () => { + vi.mocked(fetchJsonFromUrl).mockResolvedValue(manifest); + + await sendPasswordReset(deps, user({ language: Lang.DE })); + + const msg = send.mock.calls[0][0]; + expect(msg.subject).toBe("Passwort zurücksetzen"); + expect(msg.text).toBe(`zurücksetzen: ${expectedUrl}`); + expect(msg.html).toBe(`${expectedUrl}`); + expect(msg.html).not.toContain("{{resetUrl}}"); + }); + + it("falls back to the default locale (en) for an unsupported language", async () => { + vi.mocked(fetchJsonFromUrl).mockResolvedValue(manifest); + + await sendPasswordReset(deps, user({ language: "fr" })); + + expect(send.mock.calls[0][0].subject).toBe("Password Reset"); + }); + + it("caches the manifest within TTL (single fetch across two sends)", async () => { + vi.mocked(fetchJsonFromUrl).mockResolvedValue(manifest); + + await sendPasswordReset(deps, user({ language: Lang.EN })); + await sendPasswordReset(deps, user({ language: Lang.DE })); + + expect(fetchJsonFromUrl).toHaveBeenCalledTimes(1); + }); + + it("falls back to built-in content when the manifest fetch fails, and still sends", async () => { + vi.mocked(fetchJsonFromUrl).mockRejectedValue(new Error("CDN down")); + + await sendPasswordReset(deps, user({ language: Lang.DE })); + + const msg = send.mock.calls[0][0]; + expect(msg.subject).toBe("Passwort zurücksetzen"); + expect(msg.text).toContain(expectedUrl); + expect(msg.text).not.toContain("{{resetUrl}}"); + }); + + it("falls back to built-in when the manifest entry is invalid (missing body)", async () => { + vi.mocked(fetchJsonFromUrl).mockResolvedValue({ + en: { subject: "no body here" }, + }); + + await sendPasswordReset(deps, user({ language: Lang.EN })); + + expect(send.mock.calls[0][0].subject).toBe("Password Reset"); + }); + + it("throws when the user has no email", async () => { + await expect( + sendPasswordReset(deps, user({ email: undefined })), + ).rejects.toThrow("User email is required"); + }); +}); diff --git a/src/test/services/notify/email-template.test.ts b/src/test/services/notify/email-template.test.ts new file mode 100644 index 00000000..4e5a18b3 --- /dev/null +++ b/src/test/services/notify/email-template.test.ts @@ -0,0 +1,211 @@ +import { Lang } from "need4deed-sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchJsonFromUrl } from "../../../data/utils"; +import { + createManifestLoader, + fillTemplate, + resolveContent, + resolveLocale, + type LocaleContent, +} from "../../../services/notify/email-template"; + +vi.mock("../../../data/utils", () => ({ + fetchJsonFromUrl: vi.fn(), +})); + +// ─── fillTemplate ──────────────────────────────────────────────────────────── + +describe("fillTemplate", () => { + it("substitutes a single placeholder in all fields", () => { + const result = fillTemplate( + { + subject: "Hello {{name}}", + text: "Hi {{name}}, welcome!", + html: "

Hi {{name}}

", + }, + { name: "Alice" }, + ); + expect(result.subject).toBe("Hello Alice"); + expect(result.text).toBe("Hi Alice, welcome!"); + expect(result.html).toBe("

Hi Alice

"); + }); + + it("substitutes multiple distinct placeholders", () => { + const result = fillTemplate( + { + subject: "{{event}} confirmation", + html: "

Dear {{name}}, your {{event}} is on {{date}}.

", + text: "Dear {{name}}, your {{event}} is on {{date}}.", + }, + { name: "Bob", event: "appointment", date: "2026-07-01" }, + ); + expect(result.subject).toBe("appointment confirmation"); + expect(result.html).toBe( + "

Dear Bob, your appointment is on 2026-07-01.

", + ); + expect(result.text).toBe("Dear Bob, your appointment is on 2026-07-01."); + }); + + it("replaces the same placeholder appearing multiple times", () => { + const result = fillTemplate( + { subject: "link", html: '{{url}}' }, + { url: "https://example.com/verify" }, + ); + expect(result.html).toBe( + 'https://example.com/verify', + ); + expect(result.html).not.toContain("{{url}}"); + }); + + it("accepts {{ key }} with surrounding whitespace", () => { + const result = fillTemplate( + { subject: "{{ name }} joined" }, + { name: "Eve" }, + ); + expect(result.subject).toBe("Eve joined"); + }); + + it("omits html and text keys when absent from content", () => { + const result = fillTemplate({ subject: "plain" }, {}); + expect(result).toEqual({ subject: "plain" }); + expect("html" in result).toBe(false); + expect("text" in result).toBe(false); + }); + + it("preserves unresolved placeholders in output", () => { + const result = fillTemplate( + { subject: "Hi {{name}}", text: "Code: {{code}}" }, + { name: "Alice" }, + ); + expect(result.subject).toBe("Hi Alice"); + expect(result.text).toBe("Code: {{code}}"); + }); + + it("ignores extra vars that have no matching placeholder", () => { + const result = fillTemplate( + { subject: "Hello {{name}}" }, + { name: "Dave", unused: "x" }, + ); + expect(result.subject).toBe("Hello Dave"); + }); + + it("substitutes an empty string value correctly", () => { + const result = fillTemplate({ subject: "Hi {{name}}" }, { name: "" }); + expect(result.subject).toBe("Hi "); + }); +}); + +// ─── resolveLocale ─────────────────────────────────────────────────────────── + +describe("resolveLocale", () => { + it("returns DE for 'de'", () => { + expect(resolveLocale("de")).toBe(Lang.DE); + }); + + it("returns EN for 'en'", () => { + expect(resolveLocale("en")).toBe(Lang.EN); + }); + + it("falls back to EN for unknown languages", () => { + expect(resolveLocale("fr")).toBe(Lang.EN); + expect(resolveLocale(undefined)).toBe(Lang.EN); + }); +}); + +// ─── resolveContent ────────────────────────────────────────────────────────── + +const builtin: Record = { + [Lang.EN]: { subject: "Builtin EN", text: "builtin en text" }, + [Lang.DE]: { subject: "Builtin DE", text: "builtin de text" }, +}; + +describe("resolveContent", () => { + it("returns the manifest entry for the requested locale", () => { + const manifest = { + [Lang.EN]: { subject: "Manifest EN", html: "

en

" }, + [Lang.DE]: { subject: "Manifest DE", html: "

de

" }, + }; + expect(resolveContent(manifest, Lang.DE, builtin).subject).toBe( + "Manifest DE", + ); + }); + + it("falls back to EN manifest when requested locale is missing", () => { + const manifest = { + [Lang.EN]: { subject: "Manifest EN", html: "

en

" }, + }; + expect(resolveContent(manifest, Lang.DE, builtin).subject).toBe( + "Manifest EN", + ); + }); + + it("falls back to builtin when manifest is null", () => { + expect(resolveContent(null, Lang.DE, builtin).subject).toBe("Builtin DE"); + }); + + it("falls back to builtin when manifest entry is invalid (no body)", () => { + const manifest = { [Lang.EN]: { subject: "no body" } }; + expect(resolveContent(manifest, Lang.EN, builtin).subject).toBe( + "Builtin EN", + ); + }); +}); + +// ─── createManifestLoader ──────────────────────────────────────────────────── + +describe("createManifestLoader", () => { + const url = "https://cdn.example.com/emails/test.json"; + const manifest = { + [Lang.EN]: { subject: "Test", html: "

test

" }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("fetches and returns the manifest", async () => { + vi.mocked(fetchJsonFromUrl).mockResolvedValue(manifest); + const loader = createManifestLoader(url); + expect(await loader.load()).toEqual(manifest); + }); + + it("caches within TTL (single fetch across multiple calls)", async () => { + vi.mocked(fetchJsonFromUrl).mockResolvedValue(manifest); + const loader = createManifestLoader(url); + await loader.load(); + await loader.load(); + expect(fetchJsonFromUrl).toHaveBeenCalledTimes(1); + }); + + it("re-fetches after resetCache()", async () => { + vi.mocked(fetchJsonFromUrl).mockResolvedValue(manifest); + const loader = createManifestLoader(url); + await loader.load(); + loader.resetCache(); + await loader.load(); + expect(fetchJsonFromUrl).toHaveBeenCalledTimes(2); + }); + + it("returns null on fetch failure when no stale cache exists", async () => { + vi.mocked(fetchJsonFromUrl).mockRejectedValue(new Error("CDN down")); + const loader = createManifestLoader(url); + expect(await loader.load()).toBeNull(); + }); + + it("serves stale cache when a subsequent fetch fails after TTL expires", async () => { + vi.useFakeTimers(); + vi.mocked(fetchJsonFromUrl) + .mockResolvedValueOnce(manifest) + .mockRejectedValueOnce(new Error("CDN down")); + const loader = createManifestLoader(url); + + await loader.load(); // populates cache + vi.advanceTimersByTime(11 * 60 * 1000); // past default 10-min TTL + const result = await loader.load(); // fetch fails → returns stale + expect(result).toEqual(manifest); + }); +}); diff --git a/src/test/services/notify/email-verification.test.ts b/src/test/services/notify/email-verification.test.ts index 1a616bed..8e1ebfb8 100644 --- a/src/test/services/notify/email-verification.test.ts +++ b/src/test/services/notify/email-verification.test.ts @@ -1,4 +1,4 @@ -import { Lang } from "need4deed-sdk"; +import { Lang, UserRole } from "need4deed-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { urlEmailVerification } from "../../../config/constants"; import { fetchJsonFromUrl } from "../../../data/utils"; @@ -90,4 +90,29 @@ describe("sendEmailVerification", () => { sendEmailVerification(deps, user({ email: undefined })), ).rejects.toThrow("User email is required"); }); + + it("appends ?role=agent to the URL for AGENT users", async () => { + vi.mocked(fetchJsonFromUrl).mockResolvedValue(manifest); + + await sendEmailVerification( + deps, + user({ role: UserRole.AGENT, language: Lang.EN }), + ); + + const msg = send.mock.calls[0][0]; + expect(msg.text).toContain(`${expectedUrl}?role=agent`); + }); + + it("does not append a role param for non-agent users", async () => { + vi.mocked(fetchJsonFromUrl).mockResolvedValue(manifest); + + await sendEmailVerification( + deps, + user({ role: UserRole.USER, language: Lang.EN }), + ); + + const msg = send.mock.calls[0][0]; + expect(msg.text).toContain(expectedUrl); + expect(msg.text).not.toContain("?role="); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 690bdbb8..86b0aab5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "skipLibCheck": true, "resolveJsonModule": true }, - "include": ["src/**/*.ts", "vitest.config.ts"], + "include": ["src/**/*.ts", "vitest.config.ts", "vitest.e2e.config.ts"], "exclude": ["node_modules"], "ts-node": { "files": true diff --git a/vitest.config.ts b/vitest.config.ts index 41f580e6..c00cb9b8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ environment: "node", setupFiles: ["./src/test/setup.ts"], // Optional: for DB connection logic include: ["**/*.{test,spec}.ts"], + exclude: ["**/node_modules/**", "src/test/e2e/**"], env: { JWT_SECRET: "test-secret-only-for-vitest", NODE_ENV: "test", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 00000000..91897eff --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,36 @@ +import swc from "unplugin-swc"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + globalSetup: ["./src/test/e2e/setup/global.ts"], + setupFiles: ["./src/test/setup.ts"], + include: ["src/test/e2e/**/*.test.ts"], + pool: "forks", + fileParallelism: false, + env: { + JWT_SECRET: "test-secret-only-for-e2e-vitest", + NODE_ENV: "test", + PORT: "5002", + }, + }, + plugins: [ + swc.vite({ + jsc: { + keepClassNames: true, + target: "es2022", + parser: { + syntax: "typescript", + decorators: true, + dynamicImport: true, + }, + transform: { + legacyDecorator: true, + decoratorMetadata: true, + }, + }, + }), + ], +}); diff --git a/yarn.lock b/yarn.lock index 81149dca..a364052a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -844,6 +844,18 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/node-cron@^3.0.11": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.11.tgz#70b7131f65038ae63cfe841354c8aba363632344" + integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg== + +"@types/node@*": + version "26.1.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.1.1.tgz#bad758d601e97d6cf457d204ee76a35fce7bd119" + integrity sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw== + dependencies: + undici-types "~8.3.0" + "@types/node@^24.9.1": version "24.10.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.0.tgz#6b79086b0dfc54e775a34ba8114dcc4e0221f31f" @@ -851,6 +863,13 @@ dependencies: undici-types "~7.16.0" +"@types/nodemailer@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-8.0.1.tgz#4ef2b4e62c819225a0b8a6384bf750c0c664d465" + integrity sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw== + dependencies: + "@types/node" "*" + "@types/validator@^13.11.8": version "13.15.4" resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.15.4.tgz#38a97ae54747416f745afdfc678f041713082635" @@ -2937,10 +2956,10 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -need4deed-sdk@0.0.110: - version "0.0.110" - resolved "https://registry.yarnpkg.com/need4deed-sdk/-/need4deed-sdk-0.0.110.tgz#4428170ca81fddd35ec80f7a72825c3e6b29d8e7" - integrity sha512-kHa6B13x3CcHMhexam+9CBfRX9AV9/8K1RrUTDSAVdRcJ0PecNFs3JSscwBkJ7UWqjSKV89xaqVkN6N3qGQnAA== +need4deed-sdk@0.0.125: + version "0.0.125" + resolved "https://registry.yarnpkg.com/need4deed-sdk/-/need4deed-sdk-0.0.125.tgz#adee2cbc342da82c1f6dedfeebafac2502648d46" + integrity sha512-g/7Bwk1zqcyLZYvmgnACeXhuem5TGfDcwexzQVsaxR2HfXQW3zcJ3NHFaKqaKT9p4hOoRqkJR1vtaFOvFOAgfg== no-case@^3.0.4: version "3.0.4" @@ -2955,11 +2974,21 @@ node-addon-api@^8.3.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.5.0.tgz#c91b2d7682fa457d2e1c388150f0dff9aafb8f3f" integrity sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A== +node-cron@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/node-cron/-/node-cron-4.5.0.tgz#284908d302415a32e0774b3a8c8184d423e262a8" + integrity sha512-4Trh+kjvbXokyJkwQumvD5YAgeJfgHLR/sKyu71uSmxfCR5QMO1hldpvmFZOICN5pLgNY+J5Y8+ar3XKo5/4tQ== + node-gyp-build@^4.8.4: version "4.8.4" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== +nodemailer@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-9.0.3.tgz#ce84f8046977b616847ad56130aa30bdb3c67c1f" + integrity sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw== + nodemon@^3.1.10: version "3.1.10" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.10.tgz#5015c5eb4fffcb24d98cf9454df14f4fecec9bc1" @@ -4171,6 +4200,11 @@ undici-types@~7.16.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== +undici-types@~8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" + integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== + unplugin-swc@^1.5.9: version "1.5.9" resolved "https://registry.yarnpkg.com/unplugin-swc/-/unplugin-swc-1.5.9.tgz#b842394cd783afeb01ff875513cb52c3b85e89ae"