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 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 0000000..a9966a6 Binary files /dev/null and b/src/app/icon.png differ 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,