From 4494093ffafc9b6865c1c689d06faeadd451864a Mon Sep 17 00:00:00 2001 From: Kevin Guo Date: Fri, 15 May 2026 12:09:15 -0500 Subject: [PATCH] refactor: add reminder idempotency, partial cohort indexes, async bulk-email dispatch, and misc admin fixes --- .gitignore | 3 + README.md | 17 +- deno.lock | 80 + drizzle/0008_odd_network.sql | 1 + drizzle/0009_condemned_wendell_vaughn.sql | 3 + drizzle/meta/0008_snapshot.json | 1728 ++++++++++++++++ drizzle/meta/0009_snapshot.json | 1779 +++++++++++++++++ drizzle/meta/_journal.json | 14 + netlify/functions/event-reminders.ts | 2 +- next.config.ts | 4 +- src/app/api/auth/forgot-password/route.ts | 29 +- src/app/api/staff/communications/route.ts | 31 +- .../settings/event-categories/[id]/route.ts | 3 + .../staff/settings/event-categories/route.ts | 2 + src/app/favicon.ico | Bin 25931 -> 0 bytes src/app/icon.png | Bin 0 -> 1946 bytes .../staff/communications/compose-modal.tsx | 140 +- src/components/staff/communications/types.ts | 8 - src/db/schema/onboarding.ts | 2 +- src/db/schema/opportunities.ts | 1 + src/db/schema/users.ts | 125 +- src/services/communications.service.ts | 48 +- src/services/event-rsvps.service.ts | 8 +- src/services/notifications.service.ts | 98 +- src/services/onboarding-documents.service.ts | 17 +- src/services/opportunities.service.ts | 19 +- src/utils/server/email.ts | 8 +- 27 files changed, 3875 insertions(+), 295 deletions(-) create mode 100644 deno.lock create mode 100644 drizzle/0008_odd_network.sql create mode 100644 drizzle/0009_condemned_wendell_vaughn.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 drizzle/meta/0009_snapshot.json delete mode 100644 src/app/favicon.ico create mode 100644 src/app/icon.png diff --git a/.gitignore b/.gitignore index b62c1ec..209e1b5 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ gemini.md # git worktrees .worktrees/ + +# Local Netlify folder +.netlify diff --git a/README.md b/README.md index d1ab765..65af77b 100644 --- a/README.md +++ b/README.md @@ -28,21 +28,24 @@ Please have the following VSCode extensions installed: ### Environment Variables -Create a `.env` file in the root directory with the following variables: +Copy `.env.example` to `.env` and fill in the values. The full set: ```text -DATABASE_URL= +DATABASE_URL= NEXTAUTH_SECRET= NEXTAUTH_URL=http://localhost:3000 -RESEND_API_KEY= -RESEND_FROM_EMAIL= +GMAIL_USER= +GMAIL_APP_PASSWORD= +CRON_SECRET= +APP_TIMEZONE=America/Chicago # optional; defaults to America/Chicago ``` **Setup steps:** -1. Ask your team lead for `DATABASE_URL`, `RESEND_API_KEY`, and `RESEND_FROM_EMAIL` -2. Generate your own NextAuth secret: `openssl rand -base64 32` -3. Each developer must use their own unique `NEXTAUTH_SECRET` +1. Ask your team lead for `DATABASE_URL`, `GMAIL_USER`, and `GMAIL_APP_PASSWORD`. +2. Generate your own NextAuth secret: `openssl rand -base64 32`. +3. Generate your own cron secret: `openssl rand -base64 32`. This is the bearer token the Netlify scheduled functions in `netlify/functions/` use to call the `/api/cron/*` routes. +4. Each developer must use their own unique `NEXTAUTH_SECRET`. **Note:** All developers share the same database for development, but each needs their own `NEXTAUTH_SECRET` to prevent session conflicts. diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..e2765ee --- /dev/null +++ b/deno.lock @@ -0,0 +1,80 @@ +{ + "version": "5", + "remote": { + "https://edge.netlify.com/": "fd941d61d88673d5f28aab283fb86fcc50f08a3bc80ee5470498fcfa88c65cfb", + "https://edge.netlify.com/bootstrap/config.ts": "6a2ce0e544e15e8f8883a5c18da5948e37fd0f2619f68cb31f3af53c51817025", + "https://edge.netlify.com/bootstrap/context.ts": "e97240232121e2f369f6546ce961490f34d961ea1ea54be3ff09633e3f08373f", + "https://edge.netlify.com/bootstrap/cookie.ts": "8b0baae708989ca183c6f3b4ab3d029e6abcbc2e43f93edeb0ff447b3bbc3a05", + "https://edge.netlify.com/bootstrap/edge_function.ts": "b8253e86aa83c67341f5cfedeba5049d77fbf84dcab7eceff7566b7728ae9b39", + "https://edge.netlify.com/bootstrap/globals/types.ts": "eaa6148ded3121d8dee62dd91c86e7fe76601df0f3ca8d7962243a30f4c8935f" + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@emotion/cache@^11.14.0", + "npm:@emotion/react@^11.14.0", + "npm:@emotion/styled@^11.14.1", + "npm:@eslint/eslintrc@^3.3.1", + "npm:@eslint/js@^9.33.0", + "npm:@fullcalendar/core@^6.1.19", + "npm:@fullcalendar/daygrid@^6.1.19", + "npm:@fullcalendar/interaction@^6.1.19", + "npm:@fullcalendar/list@^6.1.20", + "npm:@fullcalendar/react@^6.1.19", + "npm:@fullcalendar/timegrid@^6.1.19", + "npm:@hookform/resolvers@^5.2.1", + "npm:@mui/icons-material@^7.3.1", + "npm:@mui/material-nextjs@^7.3.0", + "npm:@mui/material@^7.3.1", + "npm:@mui/x-charts@^8.27.5", + "npm:@mui/x-data-grid@^8.10.0", + "npm:@neondatabase/serverless@^1.0.1", + "npm:@netlify/blobs@^10.7.6", + "npm:@netlify/functions@^5.2.1", + "npm:@next/eslint-plugin-next@^15.4.7", + "npm:@tiptap/extension-color@^3.16.0", + "npm:@tiptap/extension-link@^3.16.0", + "npm:@tiptap/extension-text-style@^3.16.0", + "npm:@tiptap/extension-underline@^3.16.0", + "npm:@tiptap/pm@^3.16.0", + "npm:@tiptap/react@^3.16.0", + "npm:@tiptap/starter-kit@^3.16.0", + "npm:@types/bcrypt@6", + "npm:@types/node@20", + "npm:@types/nodemailer@8", + "npm:@types/papaparse@^5.5.2", + "npm:@types/react-dom@19", + "npm:@types/react@19", + "npm:@types/validator@^13.15.10", + "npm:bcrypt@6", + "npm:concurrently@^9.2.0", + "npm:dotenv@^17.2.1", + "npm:drizzle-kit@~0.31.7", + "npm:drizzle-orm@~0.44.4", + "npm:eslint-config-next@^15.4.7", + "npm:eslint-config-prettier@^10.1.8", + "npm:eslint-plugin-import@^2.32.0", + "npm:eslint-plugin-prettier@^5.5.4", + "npm:eslint-plugin-simple-import-sort@^12.1.1", + "npm:eslint-plugin-unicorn@60", + "npm:eslint@^9.39.1", + "npm:knip@^5.62.0", + "npm:netlify-cli@^26.0.1", + "npm:next-auth@^4.24.12", + "npm:next@15.5.9", + "npm:nodemailer@^8.0.7", + "npm:notistack@^3.0.2", + "npm:papaparse@^5.5.3", + "npm:prettier@^3.6.2", + "npm:react-dom@19.1.0", + "npm:react-hook-form@^7.62.0", + "npm:react@19.1.0", + "npm:tsx@^4.20.3", + "npm:typescript-eslint@^8.39.0", + "npm:typescript@latest", + "npm:validator@^13.15.23", + "npm:zod@^4.0.17" + ] + } + } +} diff --git a/drizzle/0008_odd_network.sql b/drizzle/0008_odd_network.sql new file mode 100644 index 0000000..e54e4bd --- /dev/null +++ b/drizzle/0008_odd_network.sql @@ -0,0 +1 @@ +ALTER TABLE "volunteer_rsvps" ADD COLUMN "reminder_sent_at" timestamp; \ No newline at end of file diff --git a/drizzle/0009_condemned_wendell_vaughn.sql b/drizzle/0009_condemned_wendell_vaughn.sql new file mode 100644 index 0000000..f1f2df9 --- /dev/null +++ b/drizzle/0009_condemned_wendell_vaughn.sql @@ -0,0 +1,3 @@ +CREATE INDEX "staff_notif_pref_idx" ON "staff" USING btree ("user_id") WHERE notification_preference != 'none';--> statement-breakpoint +CREATE INDEX "users_is_active_idx" ON "users" USING btree ("id") WHERE is_active = true;--> statement-breakpoint +CREATE INDEX "volunteers_notif_pref_idx" ON "volunteers" USING btree ("user_id") WHERE notification_preference != 'none'; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..aa31f21 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1728 @@ +{ + "id": "f1e80166-f9f2-42cf-bfd1-e83c900f3154", + "prevId": "18baafb3-f096-4da9-a405-7d7d26c2283d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_dashboard_actions": { + "name": "admin_dashboard_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_dashboard_actions_admin_id_users_id_fk": { + "name": "admin_dashboard_actions_admin_id_users_id_fk", + "tableFrom": "admin_dashboard_actions", + "tableTo": "users", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_settings_updated_by_id_users_id_fk": { + "name": "system_settings_updated_by_id_users_id_fk", + "tableFrom": "system_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "system_settings_key_unique": { + "name": "system_settings_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bulk_communication_logs": { + "name": "bulk_communication_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_type": { + "name": "recipient_type", + "type": "recipient_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + } + }, + "indexes": {}, + "foreignKeys": { + "bulk_communication_logs_sender_id_users_id_fk": { + "name": "bulk_communication_logs_sender_id_users_id_fk", + "tableFrom": "bulk_communication_logs", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.communication_templates": { + "name": "communication_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_documents": { + "name": "onboarding_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sign'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_document_signatures": { + "name": "volunteer_document_signatures", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "signed_at": { + "name": "signed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "vds_document_id_idx": { + "name": "vds_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteer_document_signatures_volunteer_id_volunteers_id_fk": { + "name": "volunteer_document_signatures_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_document_signatures", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_document_signatures_document_id_onboarding_documents_id_fk": { + "name": "volunteer_document_signatures_document_id_onboarding_documents_id_fk", + "tableFrom": "volunteer_document_signatures", + "tableTo": "onboarding_documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_document_signatures_volunteer_id_document_id_pk": { + "name": "volunteer_document_signatures_volunteer_id_document_id_pk", + "columns": [ + "volunteer_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_categories": { + "name": "event_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_categories_name_unique": { + "name": "event_categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opportunities": { + "name": "opportunities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "opportunity_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "max_volunteers": { + "name": "max_volunteers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recurrence_pattern": { + "name": "recurrence_pattern", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_recurring": { + "name": "is_recurring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "opportunities_created_by_id_users_id_fk": { + "name": "opportunities_created_by_id_users_id_fk", + "tableFrom": "opportunities", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "opportunities_category_id_event_categories_id_fk": { + "name": "opportunities_category_id_event_categories_id_fk", + "tableFrom": "opportunities", + "tableTo": "event_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opportunity_interests": { + "name": "opportunity_interests", + "schema": "", + "columns": { + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "opportunity_interests_opportunity_id_opportunities_id_fk": { + "name": "opportunity_interests_opportunity_id_opportunities_id_fk", + "tableFrom": "opportunity_interests", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opportunity_interests_interest_id_interests_id_fk": { + "name": "opportunity_interests_interest_id_interests_id_fk", + "tableFrom": "opportunity_interests", + "tableTo": "interests", + "columnsFrom": [ + "interest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opportunity_interests_opportunity_id_interest_id_pk": { + "name": "opportunity_interests_opportunity_id_interest_id_pk", + "columns": [ + "opportunity_id", + "interest_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opportunity_required_skills": { + "name": "opportunity_required_skills", + "schema": "", + "columns": { + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "opportunity_required_skills_opportunity_id_opportunities_id_fk": { + "name": "opportunity_required_skills_opportunity_id_opportunities_id_fk", + "tableFrom": "opportunity_required_skills", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opportunity_required_skills_skill_id_skills_id_fk": { + "name": "opportunity_required_skills_skill_id_skills_id_fk", + "tableFrom": "opportunity_required_skills", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opportunity_required_skills_opportunity_id_skill_id_pk": { + "name": "opportunity_required_skills_opportunity_id_skill_id_pk", + "columns": [ + "opportunity_id", + "skill_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_hours": { + "name": "volunteer_hours", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "hours": { + "name": "hours", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "previous_hours": { + "name": "previous_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "previous_status": { + "name": "previous_status", + "type": "hours_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "hours_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_hours_volunteer_id_volunteers_id_fk": { + "name": "volunteer_hours_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_hours", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_hours_opportunity_id_opportunities_id_fk": { + "name": "volunteer_hours_opportunity_id_opportunities_id_fk", + "tableFrom": "volunteer_hours", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_hours_verified_by_users_id_fk": { + "name": "volunteer_hours_verified_by_users_id_fk", + "tableFrom": "volunteer_hours", + "tableTo": "users", + "columnsFrom": [ + "verified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_rsvps": { + "name": "volunteer_rsvps", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "rsvp_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rsvp_at": { + "name": "rsvp_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reminder_sent_at": { + "name": "reminder_sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_rsvps_volunteer_id_volunteers_id_fk": { + "name": "volunteer_rsvps_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_rsvps", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_rsvps_opportunity_id_opportunities_id_fk": { + "name": "volunteer_rsvps_opportunity_id_opportunities_id_fk", + "tableFrom": "volunteer_rsvps", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_rsvps_volunteer_id_opportunity_id_pk": { + "name": "volunteer_rsvps_volunteer_id_opportunity_id_pk", + "columns": [ + "volunteer_id", + "opportunity_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin": { + "name": "admin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_staff_id_staff_id_fk": { + "name": "admin_staff_id_staff_id_fk", + "tableFrom": "admin", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interests": { + "name": "interests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "interests_name_unique": { + "name": "interests_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notification_preference": { + "name": "notification_preference", + "type": "notification_preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "staff_user_id_users_id_fk": { + "name": "staff_user_id_users_id_fk", + "tableFrom": "staff", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_user_id_unique": { + "name": "staff_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_picture": { + "name": "profile_picture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_email_verified": { + "name": "is_email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_interests": { + "name": "volunteer_interests", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_interests_volunteer_id_volunteers_id_fk": { + "name": "volunteer_interests_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_interests", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_interests_interest_id_interests_id_fk": { + "name": "volunteer_interests_interest_id_interests_id_fk", + "tableFrom": "volunteer_interests", + "tableTo": "interests", + "columnsFrom": [ + "interest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_interests_volunteer_id_interest_id_pk": { + "name": "volunteer_interests_volunteer_id_interest_id_pk", + "columns": [ + "volunteer_id", + "interest_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_skills": { + "name": "volunteer_skills", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_skills_volunteer_id_volunteers_id_fk": { + "name": "volunteer_skills_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_skills", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_skills_skill_id_skills_id_fk": { + "name": "volunteer_skills_skill_id_skills_id_fk", + "tableFrom": "volunteer_skills", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_skills_volunteer_id_skill_id_pk": { + "name": "volunteer_skills_volunteer_id_skill_id_pk", + "columns": [ + "volunteer_id", + "skill_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_types": { + "name": "volunteer_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "volunteer_types_name_unique": { + "name": "volunteer_types_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteers": { + "name": "volunteers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "volunteer_type": { + "name": "volunteer_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_alumni": { + "name": "is_alumni", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "background_check_status": { + "name": "background_check_status", + "type": "background_check_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "availability": { + "name": "availability", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_preference": { + "name": "notification_preference", + "type": "notification_preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "employer": { + "name": "employer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "volunteers_user_id_users_id_fk": { + "name": "volunteers_user_id_users_id_fk", + "tableFrom": "volunteers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "volunteers_user_id_unique": { + "name": "volunteers_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.background_check_status": { + "name": "background_check_status", + "schema": "public", + "values": [ + "not_required", + "pending", + "approved", + "rejected" + ] + }, + "public.hours_status": { + "name": "hours_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected", + "edit_requested" + ] + }, + "public.notification_preference": { + "name": "notification_preference", + "schema": "public", + "values": [ + "email", + "sms", + "both", + "none" + ] + }, + "public.opportunity_status": { + "name": "opportunity_status", + "schema": "public", + "values": [ + "open", + "full", + "completed", + "canceled" + ] + }, + "public.proficiency_level": { + "name": "proficiency_level", + "schema": "public", + "values": [ + "no_selection", + "beginner", + "intermediate", + "advanced" + ] + }, + "public.recipient_type": { + "name": "recipient_type", + "schema": "public", + "values": [ + "volunteers", + "staff", + "both" + ] + }, + "public.rsvp_status": { + "name": "rsvp_status", + "schema": "public", + "values": [ + "pending", + "confirmed", + "declined", + "attended", + "no_show", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..a426f5a --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1779 @@ +{ + "id": "b6a70ee4-9952-4ef0-a31f-7fcb2e81ca0d", + "prevId": "f1e80166-f9f2-42cf-bfd1-e83c900f3154", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_dashboard_actions": { + "name": "admin_dashboard_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_dashboard_actions_admin_id_users_id_fk": { + "name": "admin_dashboard_actions_admin_id_users_id_fk", + "tableFrom": "admin_dashboard_actions", + "tableTo": "users", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_settings_updated_by_id_users_id_fk": { + "name": "system_settings_updated_by_id_users_id_fk", + "tableFrom": "system_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "system_settings_key_unique": { + "name": "system_settings_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bulk_communication_logs": { + "name": "bulk_communication_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_type": { + "name": "recipient_type", + "type": "recipient_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + } + }, + "indexes": {}, + "foreignKeys": { + "bulk_communication_logs_sender_id_users_id_fk": { + "name": "bulk_communication_logs_sender_id_users_id_fk", + "tableFrom": "bulk_communication_logs", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.communication_templates": { + "name": "communication_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_documents": { + "name": "onboarding_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sign'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_document_signatures": { + "name": "volunteer_document_signatures", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "signed_at": { + "name": "signed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "vds_document_id_idx": { + "name": "vds_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteer_document_signatures_volunteer_id_volunteers_id_fk": { + "name": "volunteer_document_signatures_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_document_signatures", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_document_signatures_document_id_onboarding_documents_id_fk": { + "name": "volunteer_document_signatures_document_id_onboarding_documents_id_fk", + "tableFrom": "volunteer_document_signatures", + "tableTo": "onboarding_documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_document_signatures_volunteer_id_document_id_pk": { + "name": "volunteer_document_signatures_volunteer_id_document_id_pk", + "columns": [ + "volunteer_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_categories": { + "name": "event_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_categories_name_unique": { + "name": "event_categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opportunities": { + "name": "opportunities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "opportunity_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "max_volunteers": { + "name": "max_volunteers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recurrence_pattern": { + "name": "recurrence_pattern", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_recurring": { + "name": "is_recurring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "opportunities_created_by_id_users_id_fk": { + "name": "opportunities_created_by_id_users_id_fk", + "tableFrom": "opportunities", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "opportunities_category_id_event_categories_id_fk": { + "name": "opportunities_category_id_event_categories_id_fk", + "tableFrom": "opportunities", + "tableTo": "event_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opportunity_interests": { + "name": "opportunity_interests", + "schema": "", + "columns": { + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "opportunity_interests_opportunity_id_opportunities_id_fk": { + "name": "opportunity_interests_opportunity_id_opportunities_id_fk", + "tableFrom": "opportunity_interests", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opportunity_interests_interest_id_interests_id_fk": { + "name": "opportunity_interests_interest_id_interests_id_fk", + "tableFrom": "opportunity_interests", + "tableTo": "interests", + "columnsFrom": [ + "interest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opportunity_interests_opportunity_id_interest_id_pk": { + "name": "opportunity_interests_opportunity_id_interest_id_pk", + "columns": [ + "opportunity_id", + "interest_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opportunity_required_skills": { + "name": "opportunity_required_skills", + "schema": "", + "columns": { + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "opportunity_required_skills_opportunity_id_opportunities_id_fk": { + "name": "opportunity_required_skills_opportunity_id_opportunities_id_fk", + "tableFrom": "opportunity_required_skills", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opportunity_required_skills_skill_id_skills_id_fk": { + "name": "opportunity_required_skills_skill_id_skills_id_fk", + "tableFrom": "opportunity_required_skills", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opportunity_required_skills_opportunity_id_skill_id_pk": { + "name": "opportunity_required_skills_opportunity_id_skill_id_pk", + "columns": [ + "opportunity_id", + "skill_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_hours": { + "name": "volunteer_hours", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "hours": { + "name": "hours", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "previous_hours": { + "name": "previous_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "previous_status": { + "name": "previous_status", + "type": "hours_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "hours_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_hours_volunteer_id_volunteers_id_fk": { + "name": "volunteer_hours_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_hours", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_hours_opportunity_id_opportunities_id_fk": { + "name": "volunteer_hours_opportunity_id_opportunities_id_fk", + "tableFrom": "volunteer_hours", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_hours_verified_by_users_id_fk": { + "name": "volunteer_hours_verified_by_users_id_fk", + "tableFrom": "volunteer_hours", + "tableTo": "users", + "columnsFrom": [ + "verified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_rsvps": { + "name": "volunteer_rsvps", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opportunity_id": { + "name": "opportunity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "rsvp_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rsvp_at": { + "name": "rsvp_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reminder_sent_at": { + "name": "reminder_sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_rsvps_volunteer_id_volunteers_id_fk": { + "name": "volunteer_rsvps_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_rsvps", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_rsvps_opportunity_id_opportunities_id_fk": { + "name": "volunteer_rsvps_opportunity_id_opportunities_id_fk", + "tableFrom": "volunteer_rsvps", + "tableTo": "opportunities", + "columnsFrom": [ + "opportunity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_rsvps_volunteer_id_opportunity_id_pk": { + "name": "volunteer_rsvps_volunteer_id_opportunity_id_pk", + "columns": [ + "volunteer_id", + "opportunity_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin": { + "name": "admin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_staff_id_staff_id_fk": { + "name": "admin_staff_id_staff_id_fk", + "tableFrom": "admin", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interests": { + "name": "interests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "interests_name_unique": { + "name": "interests_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notification_preference": { + "name": "notification_preference", + "type": "notification_preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "staff_notif_pref_idx": { + "name": "staff_notif_pref_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "notification_preference != 'none'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_user_id_users_id_fk": { + "name": "staff_user_id_users_id_fk", + "tableFrom": "staff", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_user_id_unique": { + "name": "staff_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_picture": { + "name": "profile_picture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_email_verified": { + "name": "is_email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_is_active_idx": { + "name": "users_is_active_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "is_active = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_interests": { + "name": "volunteer_interests", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_interests_volunteer_id_volunteers_id_fk": { + "name": "volunteer_interests_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_interests", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_interests_interest_id_interests_id_fk": { + "name": "volunteer_interests_interest_id_interests_id_fk", + "tableFrom": "volunteer_interests", + "tableTo": "interests", + "columnsFrom": [ + "interest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_interests_volunteer_id_interest_id_pk": { + "name": "volunteer_interests_volunteer_id_interest_id_pk", + "columns": [ + "volunteer_id", + "interest_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_skills": { + "name": "volunteer_skills", + "schema": "", + "columns": { + "volunteer_id": { + "name": "volunteer_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "volunteer_skills_volunteer_id_volunteers_id_fk": { + "name": "volunteer_skills_volunteer_id_volunteers_id_fk", + "tableFrom": "volunteer_skills", + "tableTo": "volunteers", + "columnsFrom": [ + "volunteer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "volunteer_skills_skill_id_skills_id_fk": { + "name": "volunteer_skills_skill_id_skills_id_fk", + "tableFrom": "volunteer_skills", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "volunteer_skills_volunteer_id_skill_id_pk": { + "name": "volunteer_skills_volunteer_id_skill_id_pk", + "columns": [ + "volunteer_id", + "skill_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteer_types": { + "name": "volunteer_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "volunteer_types_name_unique": { + "name": "volunteer_types_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volunteers": { + "name": "volunteers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "volunteer_type": { + "name": "volunteer_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_alumni": { + "name": "is_alumni", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "background_check_status": { + "name": "background_check_status", + "type": "background_check_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "availability": { + "name": "availability", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_preference": { + "name": "notification_preference", + "type": "notification_preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "employer": { + "name": "employer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "volunteers_notif_pref_idx": { + "name": "volunteers_notif_pref_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "notification_preference != 'none'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volunteers_user_id_users_id_fk": { + "name": "volunteers_user_id_users_id_fk", + "tableFrom": "volunteers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "volunteers_user_id_unique": { + "name": "volunteers_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.background_check_status": { + "name": "background_check_status", + "schema": "public", + "values": [ + "not_required", + "pending", + "approved", + "rejected" + ] + }, + "public.hours_status": { + "name": "hours_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected", + "edit_requested" + ] + }, + "public.notification_preference": { + "name": "notification_preference", + "schema": "public", + "values": [ + "email", + "sms", + "both", + "none" + ] + }, + "public.opportunity_status": { + "name": "opportunity_status", + "schema": "public", + "values": [ + "open", + "full", + "completed", + "canceled" + ] + }, + "public.proficiency_level": { + "name": "proficiency_level", + "schema": "public", + "values": [ + "no_selection", + "beginner", + "intermediate", + "advanced" + ] + }, + "public.recipient_type": { + "name": "recipient_type", + "schema": "public", + "values": [ + "volunteers", + "staff", + "both" + ] + }, + "public.rsvp_status": { + "name": "rsvp_status", + "schema": "public", + "values": [ + "pending", + "confirmed", + "declined", + "attended", + "no_show", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ecf5151..1ea28df 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1776633054058, "tag": "0007_goofy_stardust", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1778836781536, + "tag": "0008_odd_network", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1778844594075, + "tag": "0009_condemned_wendell_vaughn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/netlify/functions/event-reminders.ts b/netlify/functions/event-reminders.ts index 8d41939..0fbaf64 100644 --- a/netlify/functions/event-reminders.ts +++ b/netlify/functions/event-reminders.ts @@ -27,5 +27,5 @@ export default async (): Promise => { }; export const config: Config = { - schedule: "0 8 * * *", + schedule: "*/30 * * * *", }; diff --git a/next.config.ts b/next.config.ts index e9ffa30..62295f8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + experimental: { + optimizePackageImports: ["@mui/material", "@mui/icons-material"], + }, }; export default nextConfig; diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts index 3f9f660..dc70fac 100644 --- a/src/app/api/auth/forgot-password/route.ts +++ b/src/app/api/auth/forgot-password/route.ts @@ -23,23 +23,22 @@ export async function POST(request: Request): Promise { // Always return success to prevent email enumeration return NextResponse.json({ message: SUCCESS_MESSAGE }); } - - const result = await initiatePasswordReset(email); + const emailStr = email as string; + const result = await initiatePasswordReset(emailStr); if (result) { - try { - await sendPasswordResetEmail(email.toLowerCase().trim(), result.token); - } catch (error) { - console.error("Password reset email failed", { - email: email.toLowerCase().trim(), - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - timestamp: new Date().toISOString(), - errorType: - error instanceof Error ? error.constructor.name : typeof error, - }); - // Still return success to prevent email enumeration - } + sendPasswordResetEmail(emailStr.toLowerCase().trim(), result.token).catch( + (error: unknown) => { + console.error("Password reset email failed", { + email: emailStr.toLowerCase().trim(), + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + errorType: + error instanceof Error ? error.constructor.name : typeof error, + }); + }, + ); } return NextResponse.json({ message: SUCCESS_MESSAGE }); diff --git a/src/app/api/staff/communications/route.ts b/src/app/api/staff/communications/route.ts index b26eb3d..74f895d 100644 --- a/src/app/api/staff/communications/route.ts +++ b/src/app/api/staff/communications/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { after, NextResponse } from "next/server"; import { z } from "zod"; import { DEFAULT_PAGE_SIZE } from "@/lib/constants"; @@ -8,6 +8,7 @@ import { listCommunications, } from "@/services/communications.service"; import { requireStaffAuth } from "@/utils/server/auth"; +import { sendBulkEmail } from "@/utils/server/email"; import { handleRouteError, parseBodyOrError, @@ -69,7 +70,33 @@ export async function POST(request: Request): Promise { ); } - return NextResponse.json(output, { status: 201 }); + // Schedule email dispatch after the response is sent so the client + // isn't blocked by SMTP round-trips. + if (output.recipientEmails.length > 0) { + after(async () => { + try { + const result = await sendBulkEmail( + output.recipientEmails, + parsed.data.subject, + parsed.data.body, + ); + if (result.failures.length > 0) { + console.error("Some emails failed to send:", result.failures); + } + } catch (error) { + console.error("Failed to send bulk emails:", error); + } + }); + } + + return NextResponse.json( + { + communication: output.communication, + emailSent: output.recipientEmails.length > 0, + recipientCount: output.recipientEmails.length, + }, + { status: 201 }, + ); } catch (error) { return handleRouteError(error); } diff --git a/src/app/api/staff/settings/event-categories/[id]/route.ts b/src/app/api/staff/settings/event-categories/[id]/route.ts index 495fc0b..0d24dcb 100644 --- a/src/app/api/staff/settings/event-categories/[id]/route.ts +++ b/src/app/api/staff/settings/event-categories/[id]/route.ts @@ -1,3 +1,4 @@ +import { revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; import { z } from "zod"; @@ -40,6 +41,7 @@ export async function PUT( if ("response" in parsed) return parsed.response; const updated = await updateEventCategory(categoryId, parsed.data); + revalidateTag("event-categories"); return NextResponse.json({ message: "Event category updated successfully", @@ -70,6 +72,7 @@ export async function DELETE( } await deleteEventCategory(categoryId); + revalidateTag("event-categories"); return NextResponse.json({ message: "Event category deactivated successfully", diff --git a/src/app/api/staff/settings/event-categories/route.ts b/src/app/api/staff/settings/event-categories/route.ts index 71de3e6..5683ef7 100644 --- a/src/app/api/staff/settings/event-categories/route.ts +++ b/src/app/api/staff/settings/event-categories/route.ts @@ -1,3 +1,4 @@ +import { revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; import { z } from "zod"; @@ -41,6 +42,7 @@ export async function POST(request: Request): Promise { if ("response" in parsed) return parsed.response; const newCategory = await createEventCategory(parsed.data); + revalidateTag("event-categories"); return NextResponse.json( { message: "Event category created successfully", data: newCategory }, diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25931 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 LrJugUA?W`A8`#=m diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a9966a67508a40ad10b25fdbe346602fb5386ac1 GIT binary patch literal 1946 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_t&-PZ!6KiaBquZ=Bf7Ai#3) zxi{O#1s@KCRCTfYv??UdHM}dh#(eAPZS(6HzWnj$XGmycU~u7NaF{Ym(LM;SBv&!m zSMI;|lC$9a=N-$`KeVzl2&ghJ1TqaD3YfGmOEXA3U>r8dYBbh{VpNXC+E9!&+Lyr; c+Hi&a!eQab2Mx?O0qa}_Pgg&ebxsLQ0NhM6hX4Qo literal 0 HcmV?d00001 diff --git a/src/components/staff/communications/compose-modal.tsx b/src/components/staff/communications/compose-modal.tsx index 2c54816..fec9802 100644 --- a/src/components/staff/communications/compose-modal.tsx +++ b/src/components/staff/communications/compose-modal.tsx @@ -1,15 +1,11 @@ "use client"; -import AttachFileIcon from "@mui/icons-material/AttachFile"; import CloseIcon from "@mui/icons-material/Close"; import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; -import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; -import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import { Alert, Box, Button, - Chip, CircularProgress, Divider, IconButton, @@ -25,21 +21,14 @@ import { Typography, } from "@mui/material"; import { useSnackbar } from "notistack"; -import { - type ChangeEvent, - type ReactElement, - useCallback, - useMemo, - useRef, - useState, -} from "react"; +import { type ReactElement, useCallback, useMemo, useState } from "react"; import { MobileDialog } from "@/components/shared"; import { useCommunications } from "@/hooks/use-communications"; import { useEmailTemplates } from "@/hooks/use-email-templates"; import RichTextEditor from "./rich-text-editor"; -import type { Attachment, RecipientType } from "./types"; +import type { RecipientType } from "./types"; export type ComposeModalProps = { open: boolean; @@ -48,21 +37,6 @@ export type ComposeModalProps = { userRole: "staff" | "admin"; }; -// Helper to format file size -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -// Helper to get file icon based on type -function getFileIcon(type: string): ReactElement { - if (type === "application/pdf") { - return ; - } - return ; -} - /** * ComposeModal * @@ -81,7 +55,6 @@ export default function ComposeModal({ useState("volunteers"); const [subject, setSubject] = useState(""); const [body, setBody] = useState(""); - const [attachments, setAttachments] = useState([]); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [templateAnchorEl, setTemplateAnchorEl] = useState( null, @@ -94,9 +67,6 @@ export default function ComposeModal({ const [touched, setTouched] = useState>({}); const { enqueueSnackbar } = useSnackbar(); - // File input ref - const fileInputRef = useRef(null); - // Validations const subjectError = useMemo( () => touched.subject && subject.trim() === "", @@ -120,47 +90,6 @@ export default function ComposeModal({ setTouched((prev) => ({ ...prev, [field]: true })); }, []); - // File attachment handlers - const handleAttachClick = useCallback(() => { - fileInputRef.current?.click(); - }, []); - - const handleFileChange = useCallback( - (event: ChangeEvent) => { - const files = event.target.files; - if (!files) return; - - const newAttachments: Attachment[] = []; - for (const file of files) { - // Skip if file already attached - if ( - attachments.some((a) => a.name === file.name && a.size === file.size) - ) { - continue; - } - newAttachments.push({ - id: `${file.name}-${file.size}-${Date.now()}`, - file, - name: file.name, - size: file.size, - type: file.type, - }); - } - - setAttachments((prev) => [...prev, ...newAttachments]); - - // Reset input so same file can be selected again - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }, - [attachments], - ); - - const handleRemoveAttachment = useCallback((id: string) => { - setAttachments((prev) => prev.filter((a) => a.id !== id)); - }, []); - const { sendCommunication } = useCommunications({ skip: true }); const { activeTemplates } = useEmailTemplates(); @@ -246,7 +175,6 @@ export default function ComposeModal({ setSubject(""); setBody(""); setRecipientType("volunteers"); - setAttachments([]); setSelectedTemplateId(""); setTemplateAnchorEl(null); setTouched({}); @@ -283,7 +211,6 @@ export default function ComposeModal({ setSubject(""); setBody(""); setRecipientType("volunteers"); - setAttachments([]); setTouched({}); setSelectedTemplateId(""); setTemplateAnchorEl(null); @@ -460,51 +387,6 @@ export default function ComposeModal({ minHeight={250} /> - - {/* Attachments Display */} - {attachments.length > 0 && ( - - - {attachments.length} attachment - {attachments.length === 1 ? "" : "s"} - - - {attachments.map((attachment) => ( - - - {attachment.name.length > 20 - ? `${attachment.name.slice(0, 17)}...` - : attachment.name} - - - {formatFileSize(attachment.size)} - - - } - onDelete={() => handleRemoveAttachment(attachment.id)} - variant="outlined" - sx={{ - height: "auto", - py: 0.5, - "& .MuiChip-label": { - display: "flex", - flexDirection: "column", - alignItems: "flex-start", - }, - }} - /> - ))} - - - )} @@ -523,24 +405,6 @@ export default function ComposeModal({ > {/* Left side - Formatting and attachment icons */} - {/* Attachment Button */} - - - - - - - {/* Template Button */} ) or external URL description: text("description"), required: boolean("required").notNull().default(false), sortOrder: integer("sort_order").notNull().default(0), diff --git a/src/db/schema/opportunities.ts b/src/db/schema/opportunities.ts index 2456ba0..d364c3f 100644 --- a/src/db/schema/opportunities.ts +++ b/src/db/schema/opportunities.ts @@ -92,6 +92,7 @@ export const volunteerRsvps = pgTable( .references(() => opportunities.id, { onDelete: "cascade" }), status: rsvpStatusEnum("status").default("pending").notNull(), rsvpAt: timestamp("rsvp_at").defaultNow().notNull(), + reminderSentAt: timestamp("reminder_sent_at"), notes: text("notes"), }, (table) => ({ diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts index 493c6b5..d1be4f3 100644 --- a/src/db/schema/users.ts +++ b/src/db/schema/users.ts @@ -1,6 +1,7 @@ -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { boolean, + index, integer, jsonb, pgTable, @@ -18,57 +19,85 @@ import { import { timestamps } from "./helpers"; // Core user table - shared by all user types (volunteers, staff, admin) -export const users = pgTable("users", { - id: serial("id").primaryKey(), - firstName: text("first_name").notNull(), - lastName: text("last_name").notNull(), - email: text("email").unique().notNull(), - password: text("password").notNull(), - phone: text("phone"), - bio: text("bio"), - profilePicture: text("profile_picture"), - isActive: boolean("is_active").default(true).notNull(), - isEmailVerified: boolean("is_email_verified").default(false).notNull(), - emailVerifiedAt: timestamp("email_verified_at"), - ...timestamps, -}); +export const users = pgTable( + "users", + { + id: serial("id").primaryKey(), + firstName: text("first_name").notNull(), + lastName: text("last_name").notNull(), + email: text("email").unique().notNull(), + password: text("password").notNull(), + phone: text("phone"), + bio: text("bio"), + profilePicture: text("profile_picture"), + isActive: boolean("is_active").default(true).notNull(), + isEmailVerified: boolean("is_email_verified").default(false).notNull(), + emailVerifiedAt: timestamp("email_verified_at"), + ...timestamps, + }, + (table) => ({ + activeUsersIdx: index("users_is_active_idx") + .on(table.id) + .where(sql`is_active = true`), + }), +); // Volunteer-specific data - references users table -export const volunteers = pgTable("volunteers", { - id: serial("id").primaryKey(), - userId: integer("user_id") - .notNull() - .unique() - .references(() => users.id, { onDelete: "cascade" }), - volunteerType: text("volunteer_type"), - isAlumni: boolean("is_alumni").default(false).notNull(), - backgroundCheckStatus: backgroundCheckStatusEnum("background_check_status") - .default("not_required") - .notNull(), - availability: jsonb("availability"), - notificationPreference: notificationPreferenceEnum("notification_preference") - .default("email") - .notNull(), - employer: text("employer"), - jobTitle: text("job_title"), - city: text("city"), - state: text("state"), - referralSource: text("referral_source"), - ...timestamps, -}); +export const volunteers = pgTable( + "volunteers", + { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + volunteerType: text("volunteer_type"), + isAlumni: boolean("is_alumni").default(false).notNull(), + backgroundCheckStatus: backgroundCheckStatusEnum("background_check_status") + .default("not_required") + .notNull(), + availability: jsonb("availability"), + notificationPreference: notificationPreferenceEnum( + "notification_preference", + ) + .default("email") + .notNull(), + employer: text("employer"), + jobTitle: text("job_title"), + city: text("city"), + state: text("state"), + referralSource: text("referral_source"), + ...timestamps, + }, + (table) => ({ + emailEligibleIdx: index("volunteers_notif_pref_idx") + .on(table.userId) + .where(sql`notification_preference != 'none'`), + }), +); // Staff-specific data - references users table -export const staff = pgTable("staff", { - id: serial("id").primaryKey(), - userId: integer("user_id") - .notNull() - .unique() - .references(() => users.id, { onDelete: "cascade" }), - notificationPreference: notificationPreferenceEnum("notification_preference") - .default("email") - .notNull(), - ...timestamps, -}); +export const staff = pgTable( + "staff", + { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + notificationPreference: notificationPreferenceEnum( + "notification_preference", + ) + .default("email") + .notNull(), + ...timestamps, + }, + (table) => ({ + emailEligibleIdx: index("staff_notif_pref_idx") + .on(table.userId) + .where(sql`notification_preference != 'none'`), + }), +); // Admin data - extends staff with admin-specific fields export const admin = pgTable("admin", { diff --git a/src/services/communications.service.ts b/src/services/communications.service.ts index 6c4b10f..8e9bee4 100644 --- a/src/services/communications.service.ts +++ b/src/services/communications.service.ts @@ -9,7 +9,6 @@ import { volunteers, } from "@/db/schema"; import { NotFoundError } from "@/utils/errors"; -import { sendBulkEmail } from "@/utils/server/email"; export type CommunicationRecord = { id: number; @@ -104,21 +103,17 @@ export async function listCommunications( } /** - * Creates a bulk communication log and sends emails to recipients. - * Throws if the sender is not found or the staff role tries to send to non-volunteers. + * Creates a bulk communication log and resolves the recipient list. + * + * Email dispatch is **not** performed here — the caller (route handler) is + * responsible for scheduling the actual send (e.g. via `after()`) so that + * this service remains usable outside of a request context. */ export async function createCommunication( input: CreateCommunicationInput, ): Promise<{ communication: CommunicationRecord | undefined; - emailSent: boolean; - emailError: boolean; - recipientCount: number; - emailResult?: { - successCount: number; - failureCount: number; - failures: { email: string; error: string }[]; - }; + recipientEmails: string[]; }> { const senderUserResult = await db .select() @@ -199,32 +194,6 @@ export async function createCommunication( } } - let emailSent = false; - let emailError = false; - let emailResult: { - successCount: number; - failureCount: number; - failures: { email: string; error: string }[]; - } | null = null; - - if (recipientEmails.length > 0) { - try { - emailResult = await sendBulkEmail( - recipientEmails, - input.subject, - input.body, - ); - emailSent = emailResult.successCount > 0; - emailError = emailResult.failureCount > 0; - if (emailResult.failures.length > 0) { - console.error("Some emails failed to send:", emailResult.failures); - } - } catch (error) { - console.error("Failed to send bulk emails:", error); - emailError = true; - } - } - const createdResult = await db .select(communicationSelect) .from(bulkCommunicationLogs) @@ -234,10 +203,7 @@ export async function createCommunication( return { communication: createdResult[0], - emailSent, - emailError, - recipientCount: recipientEmails.length, - emailResult: emailResult ?? undefined, + recipientEmails, }; } diff --git a/src/services/event-rsvps.service.ts b/src/services/event-rsvps.service.ts index 9b1d0f8..b1f9a75 100644 --- a/src/services/event-rsvps.service.ts +++ b/src/services/event-rsvps.service.ts @@ -94,9 +94,15 @@ export async function updateRsvpStatus( ); } + // When an RSVP transitions to "confirmed" (e.g. cancelled → confirmed), + // clear reminder_sent_at so the volunteer is eligible for a fresh reminder + // from the event-reminders cron. await db .update(volunteerRsvps) - .set({ status: newStatus }) + .set({ + status: newStatus, + ...(newStatus === "confirmed" ? { reminderSentAt: null } : {}), + }) .where( and( eq(volunteerRsvps.volunteerId, volunteerId), diff --git a/src/services/notifications.service.ts b/src/services/notifications.service.ts index a19d750..0536f24 100644 --- a/src/services/notifications.service.ts +++ b/src/services/notifications.service.ts @@ -1,4 +1,4 @@ -import { and, between, eq, ne } from "drizzle-orm"; +import { and, between, eq, isNull, ne } from "drizzle-orm"; import db from "@/db"; import { opportunities, volunteerRsvps } from "@/db/schema/opportunities"; @@ -11,11 +11,26 @@ type ReminderResult = { failures: { email: string; error: string }[]; }; +type ReminderRow = { + volunteerId: number; + opportunityId: number; + email: string; + firstName: string; + eventTitle: string; + eventStart: Date; + eventEnd: Date; + eventLocation: string; +}; + /** - * Queries events starting in the next 24 hours with confirmed RSVPs, - * excluding volunteers who have opted out of notifications. Multiple events - * for the same volunteer are each sent as separate emails within the same - * cron run (batched invocation). + * Sends one reminder email per confirmed RSVP whose event starts within the + * next 24 hours and which has not been reminded yet. Designed to be safe to + * run frequently (every 30 min) — the reminder_sent_at filter ensures each + * RSVP is reminded exactly once unless cleared by a status transition back + * to "confirmed". + * + * Emails are dispatched in parallel chunks of 5 to keep total send time + * bounded while staying within Gmail SMTP concurrency limits. */ export async function sendUpcomingReminders(): Promise { const now = new Date(); @@ -23,6 +38,8 @@ export async function sendUpcomingReminders(): Promise { const rows = await db .select({ + volunteerId: volunteerRsvps.volunteerId, + opportunityId: volunteerRsvps.opportunityId, email: users.email, firstName: users.firstName, eventTitle: opportunities.title, @@ -42,6 +59,7 @@ export async function sendUpcomingReminders(): Promise { eq(volunteerRsvps.status, "confirmed"), between(opportunities.startDate, now, in24h), ne(volunteers.notificationPreference, "none"), + isNull(volunteerRsvps.reminderSentAt), ), ); @@ -49,23 +67,65 @@ export async function sendUpcomingReminders(): Promise { let failed = 0; const failures: { email: string; error: string }[] = []; - for (const row of rows) { - try { - await sendEventReminderEmail(row.email, row.firstName, { - title: row.eventTitle, - startDate: row.eventStart, - endDate: row.eventEnd, - location: row.eventLocation, - }); + const concurrency = 5; + for (let i = 0; i < rows.length; i += concurrency) { + const chunk = rows.slice(i, i + concurrency); + const results = await Promise.allSettled( + chunk.map((row) => + sendEventReminderEmail(row.email, row.firstName, { + title: row.eventTitle, + startDate: row.eventStart, + endDate: row.eventEnd, + location: row.eventLocation, + }), + ), + ); + + // Process results and mark reminded RSVPs + const markPromises: Promise[] = []; + for (const [j, result] of results.entries()) { + const row = chunk[j]; + if (result.status === "rejected") { + failed++; + const reason = result.reason; + failures.push({ + email: row.email, + error: reason instanceof Error ? reason.message : String(reason), + }); + continue; + } + sent++; - } catch (error) { - failed++; - failures.push({ - email: row.email, - error: error instanceof Error ? error.message : String(error), - }); + markPromises.push(markAsReminded(row)); } + + // Batch-persist reminder_sent_at for all successful sends in this chunk + await Promise.allSettled(markPromises); } return { sent, failed, failures }; } + +/** + * Mark a single RSVP as reminded so subsequent cron ticks skip it. + * If this write fails (rare DB blip) the volunteer may get a duplicate + * reminder on the next tick; we log and move on rather than failing the send. + */ +async function markAsReminded(row: ReminderRow): Promise { + try { + await db + .update(volunteerRsvps) + .set({ reminderSentAt: new Date() }) + .where( + and( + eq(volunteerRsvps.volunteerId, row.volunteerId), + eq(volunteerRsvps.opportunityId, row.opportunityId), + ), + ); + } catch (error) { + console.error( + `[notifications] Failed to persist reminder_sent_at for volunteer=${row.volunteerId} opportunity=${row.opportunityId}; volunteer may receive a duplicate reminder on the next cron tick`, + error, + ); + } +} diff --git a/src/services/onboarding-documents.service.ts b/src/services/onboarding-documents.service.ts index 87516df..293816e 100644 --- a/src/services/onboarding-documents.service.ts +++ b/src/services/onboarding-documents.service.ts @@ -45,6 +45,19 @@ export type DocumentSignature = { // Validation schemas // --------------------------------------------------------------------------- +const ONBOARDING_BLOB_PATH_REGEX = + /^\/api\/files\/onboarding-documents\/[a-f0-9-]+(?:\.[a-z0-9]+)?$/i; + +const documentUrlSchema = z.string().refine((value) => { + if (ONBOARDING_BLOB_PATH_REGEX.test(value)) return true; + try { + new URL(value); + return true; + } catch { + return false; + } +}, "URL must be a valid URL"); + export const createDocumentSchema = z .object({ title: z.string().min(1, "Title is required"), @@ -54,7 +67,7 @@ export const createDocumentSchema = z actionType: z .enum(["sign", "consent", "acknowledge", "informational"]) .default("sign"), - url: z.string().url("URL must be a valid URL"), + url: documentUrlSchema, description: z.string().optional(), required: z.boolean().default(true), sortOrder: z.number().int().default(0), @@ -75,7 +88,7 @@ export const updateDocumentSchema = z actionType: z .enum(["sign", "consent", "acknowledge", "informational"]) .optional(), - url: z.string().url("URL must be a valid URL").optional(), + url: documentUrlSchema.optional(), description: z.string().optional(), required: z.boolean().optional(), sortOrder: z.number().int().optional(), diff --git a/src/services/opportunities.service.ts b/src/services/opportunities.service.ts index 3598ad5..0085c5d 100644 --- a/src/services/opportunities.service.ts +++ b/src/services/opportunities.service.ts @@ -9,6 +9,7 @@ import { notInArray, sql, } from "drizzle-orm"; +import { unstable_cache } from "next/cache"; import db from "@/db"; import { @@ -56,14 +57,16 @@ export type OpportunityWithSpots = { // List open opportunities available to volunteers // --------------------------------------------------------------------------- -export async function listEventCategories(): Promise< - { id: number; name: string }[] -> { - return db - .select({ id: eventCategories.id, name: eventCategories.name }) - .from(eventCategories) - .orderBy(asc(eventCategories.name)); -} +export const listEventCategories = unstable_cache( + async (): Promise<{ id: number; name: string }[]> => { + return db + .select({ id: eventCategories.id, name: eventCategories.name }) + .from(eventCategories) + .orderBy(asc(eventCategories.name)); + }, + ["event-categories"], + { revalidate: 3600, tags: ["event-categories"] }, +); export async function listOpportunityLocations(): Promise { const now = new Date(); diff --git a/src/utils/server/email.ts b/src/utils/server/email.ts index 05a58c3..186113a 100644 --- a/src/utils/server/email.ts +++ b/src/utils/server/email.ts @@ -13,6 +13,8 @@ function escapeHtml(s: string): string { const transporter = nodemailer.createTransport({ service: "gmail", + pool: true, + maxConnections: 5, auth: { user: env.gmailUser, pass: env.gmailAppPassword, @@ -41,7 +43,7 @@ function wrapEmailHtml(bodyContent: string): string { * @param email - Volunteer's email address * @param firstName - Volunteer's first name * @param password - Generated password for the volunteer - * @returns Promise that resolves with Resend response if email is sent successfully + * @returns Promise that resolves when the email is sent */ export async function sendWelcomeEmail( email: string, @@ -118,7 +120,7 @@ This is an automated message. Please do not reply to this email. * Sends a password reset email with a reset link * @param email - User's email address * @param token - Password reset token (UUID) - * @returns Promise that resolves with Resend response if email is sent successfully + * @returns Promise that resolves when the email is sent */ export async function sendPasswordResetEmail( email: string, @@ -301,7 +303,7 @@ export async function sendBulkEmail( const results = await Promise.allSettled( chunk.map((email) => transporter.sendMail({ - from: env.gmailUser, + from: fromEmail, replyTo: env.gmailUser, to: email, subject,