diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 120000 index 0000000..949a29f --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1 @@ +../CLAUDE.md \ No newline at end of file diff --git a/.cursorrules b/.cursorrules new file mode 120000 index 0000000..681311e --- /dev/null +++ b/.cursorrules @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 209e1b5..5702acc 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,6 @@ ARCHITECTURE.md *.local *.local.* .claude/ -gemini.md .vscode/ # git worktrees diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f648522 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,171 @@ +> Last synced with codebase: `52ffd31` (2026-06-27) + +# Persevere + +Volunteer management platform for Persevere nonprofit (reducing recidivism through tech education). + +**Two portals:** `/staff/*` — staff/admin manage volunteers, events, communications | `/volunteer/*` — volunteers browse opportunities, RSVP, track hours. + +## Stack + +Next.js 15 App Router · TypeScript strict · Drizzle ORM + Neon PostgreSQL · NextAuth.js v4 JWT · Material UI v7 · pnpm · `@/*` → `src/*` + +## Before Writing Code + +All Claude-reference docs live in `planning/context/`: + +- **Domain vocab:** `planning/context/CONTEXT.md` — canonical domain terms (Event, RSVP, Hours, Announcement, etc.) +- **Patterns:** `planning/context/PATTERNS.md` — all recurring implementation patterns with code snippets +- **Architecture:** `planning/context/ARCHITECTURE.md` — source layout, layers, data flow, design decisions +- **UI:** `planning/context/UI.md` — design tokens, component patterns, loading/empty state rules, modal conventions; consult before writing or editing any frontend component + +## Verification + +`pnpm run check` (ESLint + TypeScript) must pass before every commit. + +```bash +pnpm run dev # Start dev server (Turbopack) at http://localhost:3000 +pnpm run build # Production build +pnpm lint # ESLint +pnpm lint:fix # ESLint with auto-fix +pnpm run check # Lint + TypeScript type check (must pass before task is done) +``` + +## Rules + +### Process + +- **Before writing any code, check `planning/context/PATTERNS.md` and the Rules below** — find the applicable pattern and follow it exactly. Do not invent a new approach when a pattern already covers the case. +- **Fix violations on sight** — whenever you encounter code that violates any rule in this file or any pattern in `planning/context/PATTERNS.md` (even while working on an unrelated task), fix it immediately in the same edit. If fixing a violation requires a non-trivial change that could affect behavior, note it to the user but still fix it. +- **Always load the relevant skill before starting any task** — use the `Skill` tool proactively, even without being asked: + - Frontend UI / components / MUI styling → `frontend-design` + - New feature or significant new functionality → `feature-dev` + - Bug, test failure, or unexpected behavior → `systematic-debugging` + - Committing work → `commit`; committing + pushing + opening PR → `commit-push-pr` + - Code review → `review-code` (which will in turn load domain skills) + - Multiple independent tasks → `dispatching-parallel-agents` + - If multiple skills apply, load all of them — they compose. +- **This project uses `CLAUDE.md` as the source of truth for developer instructions.** Tool-specific entry points like `.cursorrules` and `.agents/AGENTS.md` are symbolic links pointing to this file. +- **Before every commit:** update `planning/context/ARCHITECTURE.md`, `planning/context/PATTERNS.md`, and/or `CLAUDE.md` as needed to reflect the changes in the commit — add new entries AND remove or correct stale ones. Then update the `Last synced` line in each file you touched with the new commit hash and today's date. +- **After every commit:** if the work revealed a new pattern, an anti-pattern to avoid, or a rule violation that was fixed, record it in `planning/context/PATTERNS.md` and/or the Rules section of `CLAUDE.md`. +- **Never post comments on GitHub PRs or issues** — report findings directly in the conversation only. + +### Auth + +- **Never use `requireAuth("staff")`** — it uses strict role equality, so it blocks admins. Use the inline check: `const session = await requireAuth(); if (!["staff", "admin"].includes(session.user.role)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); }`. +- **In route handlers, call `requireAuth()` before parsing URL params or reading the request body** — prevents unauthenticated callers from probing resource existence via IDs. +- **Always import `getServerSession` from `@/utils/server/auth`** — never import it directly from `next-auth`. +- **Never match auth errors by string** — use `instanceof AuthError` (server) or `instanceof AuthenticationError` (client). + +### Data Access + +- **No DB logic in route handlers** — extract to `src/services/`. Routes are: auth → validate → service → response. +- **No data-fetching logic in components** — extract to `src/hooks/`. Hook files have no `"use client"` directive; consuming components add it. +- **Never use raw `fetch()` in client code** — use `apiClient` from `@/lib/api-client`. +- **Never call `apiClient` directly in components** — extract data-fetching and mutations to hooks in `src/hooks/`. +- **Always handle both `AuthenticationError` and `AuthorizationError` in hooks** — `AuthenticationError` (401) → redirect to login. `AuthorizationError` (403) → set inline error state (no redirect). +- **Never write inline `z.enum([...])` for status fields** — import the canonical Zod schemas from `@/lib/status-enums`: `rsvpStatusSchema`, `hoursStatusSchema`, `backgroundCheckStatusSchema`, `opportunityStatusSchema`, `proficiencyLevelSchema` (or `assignableProficiencyLevelSchema` to exclude `"no_selection"`), `notificationPreferenceSchema`, `recipientTypeSchema`. They derive their values from the Drizzle enums, so adding a new enum value updates validators automatically. +- **For many-to-many junction tables, use the helpers in `@/services/shared/entity-checks`** — `assertJunctionAbsent(table, where, message)` before inserts (throws `ConflictError` on duplicate) and `deleteJunctionRow(table, where, message)` for deletes (throws `NotFoundError` on zero rows). Don't hand-roll select-then-conditional-insert. Existence checks: `requireVolunteer`, `requireSkill`, `requireInterest`, `requireOpportunity`. +- **For volunteer profile/detail updates that touch both the `users` and `volunteers` tables, delegate to `applyVolunteerUpdate(volunteerId, fields)` in `@/services/shared/volunteer-update`** — it owns the email-uniqueness check, the sequential users-then-volunteers writes, and the partial-write logging. Don't hand-roll a third copy of the diff/update logic. The route-layer Zod schema is the permission seam — restrict each portal's writable fields there, not inside the helper. + +### Error Handling + +- **Never throw generic `Error` in services for domain errors** — use `NotFoundError`, `ConflictError`, or `ValidationError` from `@/utils/errors`; route handlers catch with `instanceof`. Infrastructure errors (unexpected DB failures) may still use generic `Error` → 500. +- **Always use `handleRouteError(error)` as the sole catch-block statement in route handlers** — import from `@/utils/server/route-helpers`; it maps all typed domain errors to the correct HTTP responses. Routes with custom service errors (e.g. `RsvpError`) handle those first, then call `handleRouteError(error)` as the fallback. + +### Code Organization + +- **Never access `process.env.X` directly in app code** — add to `src/utils/env.ts` and use `env.X`. +- **Never hardcode `"10"` as a page size** — use `DEFAULT_PAGE_SIZE` from `@/lib/constants`. +- **Always use `validateAndParseId()` for `[id]` URL params** — from `@/utils/validate-id`; returns `null` if invalid. +- **Never use `Math.random()` for security-sensitive operations** — use `crypto.randomInt()`. +- **Use `usePaginatedSearch()` from `@/hooks/use-paginated-search` for debounced search + pagination reset** — don't re-create the dual-`useEffect`/`useRef` pattern inline in components. + +### Mobile / Responsive + +- **Always use `` from `@/components/shared` instead of MUI ``** — it auto-applies `fullScreen` on viewports below `md`. The theme strips paper border-radius and adds safe-area padding when `fullScreen` is true. Existing `` usage should be migrated. +- **Use `` for any list view that uses a ``** — it renders the table on `md+` and a card stack on `< md`. Don't ship raw `
` to users on phones; horizontal scroll is not acceptable UX. +- **Use `` to collapse secondary filters on mobile** — search inputs stay inline, but selects / date ranges collapse behind a `[Filters (N)]` button that opens a bottom drawer. Pattern from `src/app/volunteer/opportunities/page.tsx`. +- **Use `useIsMobile()` from `@/hooks/use-is-mobile` for conditional rendering** — never re-create `useMediaQuery(theme.breakpoints.down("md"))` inline. For pure styling (no JSX branching), prefer `sx={{ xs: …, md: … }}`. +- **Mobile-specific patterns and anti-patterns are documented in `planning/context/UI.md` § 10 ("Mobile Patterns")** — consult before building any new page or shared component. + +## Critical Infrastructure + +| What | Where | +|------|-------| +| Server auth helper | `src/utils/server/auth.ts` — `requireAuth()`, `requireStaffAuth()`, `authErrorResponse()`, `AuthError` | +| Client API wrapper | `src/lib/api-client.ts` — `apiClient`, `AuthenticationError`, `AuthorizationError` | +| Error handling | `src/utils/handle-error.ts` — `handleError()` | +| Domain error types | `src/utils/errors.ts` — `NotFoundError`, `ConflictError`, `ValidationError` | +| ID validation | `src/utils/validate-id.ts` — `validateAndParseId()` | +| Env var access | `src/utils/env.ts` — `env.X` | +| Constants | `src/lib/constants.ts` — `DEFAULT_PAGE_SIZE`, `RSVP_STATUS_COLORS` | +| Status enum schemas | `src/lib/status-enums.ts` — `rsvpStatusSchema`, `hoursStatusSchema`, `backgroundCheckStatusSchema`, `opportunityStatusSchema`, `proficiencyLevelSchema`, `assignableProficiencyLevelSchema`, `notificationPreferenceSchema`, `recipientTypeSchema` (and matching inferred types: `RsvpStatus`, `HoursStatus`, etc.) — Zod schemas derived from Drizzle enums | +| Route helpers | `src/utils/server/route-helpers.ts` — `parseBodyOrError()`, `handleRouteError()` (maps `AuthError`, `NotFoundError`, `ConflictError`, `ValidationError` to 401/403/404/409/400; falls through to 500 with `handleError()`) | +| Entity check / junction helpers | `src/services/shared/entity-checks.ts` — `requireVolunteer`, `requireSkill`, `requireInterest`, `requireOpportunity` (existence-check, throw `NotFoundError`); `assertJunctionAbsent(table, where, message)` (pre-insert duplicate guard, throws `ConflictError`); `deleteJunctionRow(table, where, message)` (delete + zero-row guard, throws `NotFoundError`) | +| Volunteer update helper | `src/services/shared/volunteer-update.ts` — `applyVolunteerUpdate(volunteerId, fields)` (shared helper backing both `updateVolunteerDetail` and `updateVolunteerProfile`; always enforces email uniqueness via `ConflictError`, sequentially writes `users` then `volunteers`, logs partial-write failures); `VolunteerUpdateFields` type covers the union of writable user + volunteer fields — per-portal Zod schemas at the route layer remain the permission seam | +| Auth config | `src/app/api/auth/[...nextauth]/auth-options.ts` | +| Middleware | `middleware.ts` | +| DB schema | `src/db/schema/` | +| Shared UI | `src/components/shared/` — `TablePaginationFooter`, `ModalTitleBar`, `AsyncContent`, `DetailField`, `ConfirmDialog`, `ChangePasswordSection`, `PageHeader`, `ResponsiveTable`, `FilterDrawer`, `MobileDialog` | +| Mobile/responsive | `src/hooks/use-is-mobile.ts` — `useIsMobile()` (`< md`), `useIsCompact()` (`< sm`); `src/components/layout/role-layout.tsx` (responsive shell with drawer); `src/components/layout/mobile-top-bar.tsx` (hamburger + logo + profile on `< md`); `src/components/layout/profile-menu.tsx` (shared profile popover for both sidebar and top-bar) | +| UI primitives | `src/components/ui/` — `StatusBadge` (+ `getRsvpStatusColor`, `getBackgroundCheckColor/Label`, `getHoursStatusColor`, `getHoursStatusLabel`), `EmptyState`, `LoadingSkeleton`, `HomeCard` | +| API error handler hook | `src/hooks/use-api-error-handler.ts` — `useApiErrorHandler()` | +| Paginated search hook | `src/hooks/use-paginated-search.ts` — `usePaginatedSearch(load, searchText, pageDeps, skip?)` (debounces search 300ms, resets pagination via second effect; consumes `useRef` to keep `load` fresh) | +| Portal label hook | `src/hooks/use-portal-label.ts` — `usePortalLabel()` (returns `"Admin Portal"` for admin role, `"Staff Portal"` otherwise; use as `PageHeader` eyebrow on staff pages) | +| Volunteer types hook | `src/hooks/use-volunteer-types.ts` — `useVolunteerTypes()` | +| Change password hook | `src/hooks/use-change-password.ts` — `useChangePassword(role)` | +| Staff self-profile hook | `src/hooks/use-staff-self-profile.ts` — `useStaffSelfProfile()` | +| Volunteer types service | `src/services/volunteer-types.service.ts` — `listActiveVolunteerTypes`, `listAllVolunteerTypes`, `createVolunteerType`, `updateVolunteerType`, `deleteVolunteerType` | +| Email templates service | `src/services/email-templates.service.ts` — `listTemplates`, `listActiveTemplates`, `getTemplateById`, `createTemplate`, `updateTemplate`, `deleteTemplate` | +| Email templates hook | `src/hooks/use-email-templates.ts` — `useEmailTemplates()` (active + all templates, CRUD mutations, 60s TTL cache) | +| Notifications service | `src/services/notifications.service.ts` — `sendUpcomingReminders()` | +| User service | `src/services/user.service.ts` — `changeUserPassword()` | +| Schema helpers | `src/db/schema/helpers.ts` — `timestamps` | +| Onboarding documents service | `src/services/onboarding-documents.service.ts` — `listDocuments`, `createDocument`, `updateDocument`, `deleteDocument`, `signDocument`, `listDocumentsWithSignatures` (`DocumentWithSignature`), `getVolunteerSignatures` | +| Volunteer detail service | `src/services/volunteer-detail.service.ts` — `getVolunteerDetail`, `updateVolunteerDetail`, `deleteVolunteer`, `deactivateVolunteer` | +| Volunteer skills service | `src/services/volunteer-skills.service.ts` — `assignSkill` (level defaults to `"no_selection"`), `removeSkill`, `getVolunteerSkills` | +| Volunteer detail hook | `src/hooks/use-volunteer-detail.ts` — `useVolunteerDetail()` (profile, updateVolunteer, deleteVolunteer, signDocumentForVolunteer) | +| Volunteer skills/interests hook | `src/hooks/use-volunteer-skills-interests.ts` — `useVolunteerSkillsInterests()` (addSkill no longer requires proficiencyLevel) | +| Volunteer profile hook | `src/hooks/use-volunteer-profile.ts` — `useVolunteerProfile()` (volunteer self-profile fetch + update, incl. firstName, lastName, email, phone, employer, jobTitle, city, state, referralSource, notificationPreference) | +| Volunteer account settings component | `src/components/volunteer/volunteer-account-settings.tsx` — `VolunteerAccountSettings` (name/email/phone/notification settings, change password, account deactivation with `DELETE /api/volunteer/profile`) | +| Volunteer client service | `src/services/volunteer-client.service.ts` — `fetchVolunteers` (accepts `VolunteerFilters`: `search`, `type`, `alumni`, `emailVerified`, `isActive`, `page`, `limit`), `fetchVolunteerById` | +| Volunteer import service | `src/services/volunteer-import.service.ts` — `importVolunteers` (bulk CSV import: validates, deduplicates, returns `ImportResult`) | +| Volunteer import hook | `src/hooks/use-volunteer-import.ts` — `useVolunteerImport()` (file upload via `apiClient.postForm`, returns `importing`, `result`, `error`, `importFile`, `reset`) | +| Volunteer export service | `src/services/volunteer-export.service.ts` — `getVolunteerExportData()` (returns `VolunteerExportData`: active docs + per-volunteer profile/paperwork/hours rows for CSV export) | +| Volunteer hours service | `src/services/volunteer-hours.service.ts` — `listAllHours`, `listVolunteerHours`, `logHours`, `updateHours`, `approveHours`, `rejectHours`, `volunteerLogHours`, `volunteerEditHoursRequest`, `listVolunteerOwnHours`, `volunteerDeleteHours`, `deleteHours` | +| Calendar events service | `src/services/calendar-events.service.ts` — `listCalendarEvents`, `autoCompleteExpiredEvents` (also invoked by `/api/cron/close-expired-events`) | +| Volunteer hours hook (self-service) | `src/hooks/use-volunteer-hours.ts` — `useVolunteerHours()` (hours list, `logHours`, `editHours`, `deleteHours`; `VolunteerHourEntry`, `LogHoursInput`, `EditHoursInput`) | +| Staff hours hook | `src/hooks/use-hours.ts` — `useHours()` (staff approve/reject/delete hours; `VolunteerHour`) | + +## Code Style (ESLint enforced) + +- All functions must have explicit return types (`@typescript-eslint/explicit-function-return-type`) +- Use `type` not `interface` +- No floating promises — always `void` or `await` +- No `console.log` — only `console.error` allowed +- Imports auto-sorted by `simple-import-sort`: external → `@/` internal → relative + +## Planning Convention + +All planning and design docs live in `planning/`. Claude-reference docs (domain vocab, architecture, patterns, UI) live in `planning/context/`. Sprint docs live in `planning/sprints/`. After any PR that adds, moves, or removes significant structure, update `planning/context/ARCHITECTURE.md` under Key Design Decisions. + +Do NOT create a `docs/` directory. + +## Project Conventions + +- **Branches:** include your name — e.g., `kevin-rsvp-ui` +- **Commit messages:** one line, conventional commits (`feat:`, `fix:`, `chore:`, `refactor:`), no body, no `Co-Authored-By` +- **Shared dev database** — all developers share one DB; use unique `NEXTAUTH_SECRET` per dev + +## Test Credentials + +The production database has a single seeded admin (`utkpersevere@gmail.com`). The seed script was a one-shot tool that was removed after use; if the admin record is ever lost, restore it via SQL or use the forgot-password flow. Volunteers and staff are created through the running app. + +## Important Notes + +- `@/*` path alias maps to `src/*` +- NextAuth uses JWT strategy (stateless — no session table in DB) +- Admin is a superset of staff — admin record references staff record +- Settings portal (`/staff/settings/*`) is admin-only with its own nested layout +- Run migrations manually with `pnpm drizzle-kit migrate` after schema changes diff --git a/drizzle/0010_outgoing_joshua_kane.sql b/drizzle/0010_outgoing_joshua_kane.sql new file mode 100644 index 0000000..b3f4ee8 --- /dev/null +++ b/drizzle/0010_outgoing_joshua_kane.sql @@ -0,0 +1 @@ +ALTER TABLE "volunteer_hours" ALTER COLUMN "opportunity_id" DROP NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..0778b29 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1779 @@ +{ + "id": "3dde9e41-14c2-4499-9ca3-e8c7dbde02f9", + "prevId": "b6a70ee4-9952-4ef0-a31f-7fcb2e81ca0d", + "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": false + }, + "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 1ea28df..e269a94 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1778844594075, "tag": "0009_condemned_wendell_vaughn", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1782534865407, + "tag": "0010_outgoing_joshua_kane", + "breakpoints": true } ] } \ No newline at end of file diff --git a/netlify/functions/close-expired-events.ts b/netlify/functions/close-expired-events.mts similarity index 89% rename from netlify/functions/close-expired-events.ts rename to netlify/functions/close-expired-events.mts index 88b1d1b..7d5abbb 100644 --- a/netlify/functions/close-expired-events.ts +++ b/netlify/functions/close-expired-events.mts @@ -1,6 +1,6 @@ import type { Config } from "@netlify/functions"; -export default async (): Promise => { +const closeExpiredEvents = async (): Promise => { const baseUrl = process.env.URL; const secret = process.env.CRON_SECRET; @@ -25,6 +25,7 @@ export default async (): Promise => { return new Response("OK"); }; +export default closeExpiredEvents; export const config: Config = { schedule: "*/30 * * * *", diff --git a/netlify/functions/event-reminders.ts b/netlify/functions/event-reminders.mts similarity index 100% rename from netlify/functions/event-reminders.ts rename to netlify/functions/event-reminders.mts diff --git a/src/app/api/staff/hours/route.ts b/src/app/api/staff/hours/route.ts index c14e785..99127e9 100644 --- a/src/app/api/staff/hours/route.ts +++ b/src/app/api/staff/hours/route.ts @@ -11,7 +11,13 @@ import { const logHoursSchema = z.object({ volunteerId: z.number().int().positive(), - opportunityId: z.number().int().positive(), + opportunityId: z + .number() + .int() + .positive() + .nullable() + .optional() + .default(null), date: z.string().min(1, "Date is required"), hours: z.number().positive().max(24), notes: z.string().optional(), diff --git a/src/app/api/volunteer/hours/route.ts b/src/app/api/volunteer/hours/route.ts index 39e6aa4..72dd190 100644 --- a/src/app/api/volunteer/hours/route.ts +++ b/src/app/api/volunteer/hours/route.ts @@ -12,7 +12,13 @@ import { } from "@/utils/server/route-helpers"; const logHoursSchema = z.object({ - opportunityId: z.number().int().positive(), + opportunityId: z + .number() + .int() + .positive() + .nullable() + .optional() + .default(null), date: z.string().min(1), hours: z.number().positive().max(24), notes: z.string().optional(), diff --git a/src/app/volunteer/profile/page.tsx b/src/app/volunteer/profile/page.tsx index 17c001b..df6de6d 100644 --- a/src/app/volunteer/profile/page.tsx +++ b/src/app/volunteer/profile/page.tsx @@ -364,6 +364,7 @@ export default function VolunteerProfilePage(): JSX.Element { city?: string | null; state?: string | null; referralSource?: string | null; + isAlumni?: boolean | null; }): Promise => { setSaving(true); try { @@ -763,6 +764,9 @@ export default function VolunteerProfilePage(): JSX.Element { fontWeight: 600, }} /> + {vol.isAlumni && ( + + )} } label={`${totalHours.toFixed(2)} hrs`} @@ -811,6 +815,7 @@ export default function VolunteerProfilePage(): JSX.Element { city: vol.city, state: vol.state, referralSource: vol.referralSource, + isAlumni: vol.isAlumni, }} onSave={handleSave} onCancel={() => setEditMode(false)} diff --git a/src/components/shared/onboarding-module-card.tsx b/src/components/shared/onboarding-module-card.tsx deleted file mode 100644 index cbcf2f3..0000000 --- a/src/components/shared/onboarding-module-card.tsx +++ /dev/null @@ -1,292 +0,0 @@ -"use client"; - -import MoreVertIcon from "@mui/icons-material/MoreVert"; -import Box from "@mui/material/Box"; -import Card from "@mui/material/Card"; -import CardActionArea from "@mui/material/CardActionArea"; -import CardContent from "@mui/material/CardContent"; -import CircularProgress from "@mui/material/CircularProgress"; -import IconButton from "@mui/material/IconButton"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import Typography from "@mui/material/Typography"; -import type { JSX, ReactNode } from "react"; -import { useState } from "react"; - -export type OnboardingModuleCardProps = { - title: string; - description?: string; - completionRate?: number; // 0-100 - statusNode?: ReactNode; // e.g. Chip or secondary text - icon?: ReactNode; // Replaced image - onClick?: () => void; - onEdit?: () => void; - onDelete?: () => void; - // For volunteer view where we might have an explicit completion state rather than a rate - isCompleted?: boolean; -}; - -export default function OnboardingModuleCard({ - title, - description, - completionRate, - statusNode, - icon, - onClick, - onEdit, - onDelete, - isCompleted, -}: OnboardingModuleCardProps): JSX.Element { - const [anchorEl, setAnchorEl] = useState(null); - const openMenu = Boolean(anchorEl); - - const handleMenuClick = (event: React.MouseEvent): void => { - event.stopPropagation(); - setAnchorEl(event.currentTarget); - }; - - const handleCloseMenu = (): void => { - setAnchorEl(null); - }; - - const handleEdit = (event: React.MouseEvent): void => { - event.stopPropagation(); - handleCloseMenu(); - onEdit?.(); - }; - - const handleDelete = (event: React.MouseEvent): void => { - event.stopPropagation(); - handleCloseMenu(); - onDelete?.(); - }; - - const showMenu = onEdit || onDelete; - - return ( - - - - {icon && ( - svg": { - fontSize: 64, - filter: "drop-shadow(0px 4px 8px rgba(0,0,0,0.1))", - }, - }} - > - {icon} - - )} - - {showMenu && ( - e.stopPropagation()} // Prevent card click - > - - - )} - - - - - {title} - - - {description && ( - - {description} - - )} - - - {completionRate !== undefined && ( - - - - - - - - Completion Rate - - - {completionRate}% - - - - )} - - {isCompleted !== undefined && completionRate === undefined && ( - - - - - - - - Completion Status - - - {isCompleted ? "Complete" : "Pending"} - - - - )} - - {statusNode && {statusNode}} - - - - - e.stopPropagation()} - slotProps={{ - paper: { - elevation: 3, - sx: { minWidth: 120, mt: 0.5 }, - }, - }} - > - {onEdit && Edit} - {onDelete && ( - - Delete - - )} - - - ); -} diff --git a/src/components/staff/approvals/log-hours-modal.tsx b/src/components/staff/approvals/log-hours-modal.tsx index 7196b2d..e8ff41a 100644 --- a/src/components/staff/approvals/log-hours-modal.tsx +++ b/src/components/staff/approvals/log-hours-modal.tsx @@ -99,10 +99,10 @@ export default function LogHoursModal({ }, [onClose, reset]); const handleSubmit = useCallback(async () => { - if (!selectedVolunteer || !selectedEventId || !date || !hours) return; + if (!selectedVolunteer || !date || !hours) return; const ok = await onSubmit({ volunteerId: selectedVolunteer.id, - opportunityId: Number(selectedEventId), + opportunityId: selectedEventId ? Number(selectedEventId) : null, date, hours: Number(hours), notes: notes || undefined, @@ -120,7 +120,6 @@ export default function LogHoursModal({ const isValid = selectedVolunteer !== null && - selectedEventId !== "" && date !== "" && Number(hours) > 0 && Number(hours) <= 24; @@ -162,9 +161,12 @@ export default function LogHoursModal({ label="Event / Opportunity" value={selectedEventId} onChange={(e) => setSelectedEventId(e.target.value)} - required disabled={loadingOptions} + helperText="Optional. Leave blank for one-on-one activities (e.g. mentoring)." > + + None + {events.map((evt) => ( {evt.title} diff --git a/src/components/staff/onboarding/document-manager.tsx b/src/components/staff/onboarding/document-manager.tsx index 24777b8..c4358a3 100644 --- a/src/components/staff/onboarding/document-manager.tsx +++ b/src/components/staff/onboarding/document-manager.tsx @@ -5,16 +5,19 @@ import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import SearchIcon from "@mui/icons-material/Search"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; import VisibilityIcon from "@mui/icons-material/Visibility"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; +import Checkbox from "@mui/material/Checkbox"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import FormControl from "@mui/material/FormControl"; +import FormControlLabel from "@mui/material/FormControlLabel"; import IconButton from "@mui/material/IconButton"; import InputAdornment from "@mui/material/InputAdornment"; import InputLabel from "@mui/material/InputLabel"; @@ -28,6 +31,8 @@ import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Typography from "@mui/material/Typography"; import { useSnackbar } from "notistack"; import { @@ -35,6 +40,7 @@ import { type JSX, useCallback, useEffect, + useRef, useState, } from "react"; @@ -63,6 +69,7 @@ type FormState = { url: string; file: File | null; description: string; + required: boolean; }; const DEFAULT_FORM: FormState = { @@ -73,6 +80,7 @@ const DEFAULT_FORM: FormState = { url: "", file: null, description: "", + required: true, }; const TYPE_COLOR: Record = { @@ -107,6 +115,7 @@ export default function DocumentManager(): JSX.Element { refetch, } = useOnboardingDocuments(); const { enqueueSnackbar } = useSnackbar(); + const fileInputRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); @@ -138,6 +147,7 @@ export default function DocumentManager(): JSX.Element { url: doc.url, file: null, description: doc.description ?? "", + required: doc.required, }); setModalOpen(true); }, []); @@ -191,7 +201,7 @@ export default function DocumentManager(): JSX.Element { actionType: form.actionType, url: resolvedUrl, description: form.description.trim() || undefined, - required: form.actionType !== "informational", + required: form.actionType === "informational" ? false : form.required, }; if (editTarget) { @@ -487,67 +497,120 @@ export default function DocumentManager(): JSX.Element { title={editTarget ? "Edit Document" : "Add Document"} onClose={closeModal} /> - - - setField("title", e.target.value)} - fullWidth - size="small" - required - /> + + + + setField("title", e.target.value)} + fullWidth + size="small" + required + /> + + Type + + + - - Type - - - - - Action Type - { + const val = e.target.value as ActionType; + setForm((prev) => ({ + ...prev, + actionType: val, + required: + val === "informational" ? false : prev.required, + })); + }} + > + Sign (formal agreement) + Consent (give/deny) + + Acknowledge (confirm reviewed) + + + Informational (view only) + + + + + setField("required", e.target.checked)} + disabled={form.actionType === "informational"} + sx={{ "& .MuiSvgIcon-root": { fontSize: 20 } }} + /> + } + label={ + + Required document (volunteers must complete this to + complete onboarding) + + } + /> + + + + + - Sign (formal agreement) - Consent (give/deny) - - Acknowledge (confirm reviewed) - - - Informational (view only) - - - - - - Source - - + Upload File + External Link + + {form.sourceMode === "url" ? ( ) : ( - - - PDF or video file (mp4, webm) - + fileInputRef.current?.click()} + sx={{ + border: "2px dashed", + borderColor: form.file ? "success.main" : "primary.main", + borderRadius: 2, + p: 3, + textAlign: "center", + cursor: "pointer", + bgcolor: form.file ? "success.50" : "transparent", + "&:hover": { + backgroundColor: form.file ? "success.50" : "action.hover", + opacity: 0.9, + }, + }} + > + + {form.file ? ( + + + File selected: {form.file.name} + + + {(form.file.size / (1024 * 1024)).toFixed(2)} MB • Click + to change file + + + ) : ( + + + Drag & drop your file here, or click to browse + + + Accepts PDF or video file (mp4, webm) + + + )} ) => setField("file", e.target.files?.[0] ?? null) } @@ -585,7 +688,7 @@ export default function DocumentManager(): JSX.Element { fullWidth size="small" multiline - rows={2} + rows={3} /> diff --git a/src/components/volunteer/volunteer-hours-detail-modal.tsx b/src/components/volunteer/volunteer-hours-detail-modal.tsx index 5accec6..9bf89f2 100644 --- a/src/components/volunteer/volunteer-hours-detail-modal.tsx +++ b/src/components/volunteer/volunteer-hours-detail-modal.tsx @@ -39,7 +39,7 @@ export default function VolunteerHoursDetailModal({ - {entry.opportunityTitle ?? "Unknown Opportunity"} + {entry.opportunityTitle ?? "None"} {formatDate(entry.date)} - {entry.opportunityTitle ?? "Unknown Opportunity"} + {entry.opportunityTitle ?? "None"} => { setFormError(null); - const parsedOpportunityId = Number.parseInt(opportunityId, 10); + const parsedOpportunityId = opportunityId + ? Number.parseInt(opportunityId, 10) + : null; const parsedHours = Number.parseFloat(hours); - if (!opportunityId || Number.isNaN(parsedOpportunityId)) { - setFormError("Please select an opportunity."); + if (opportunityId && Number.isNaN(parsedOpportunityId)) { + setFormError("Invalid opportunity selected."); return; } if (!date) { @@ -119,17 +121,24 @@ export default function VolunteerLogHoursModal({ onChange={(e) => setOpportunityId(e.target.value)} fullWidth disabled={optionsLoading || isMutating} + helperText="Optional. Leave blank for one-on-one activities (e.g. mentoring)." > {optionsLoading ? ( Loading… - ) : pastOptions.length === 0 ? ( - No eligible past events found ) : ( - pastOptions.map((r) => ( - - {r.opportunityTitle ?? `Opportunity #${r.opportunityId}`} - - )) + [ + + None + , + ...pastOptions.map((r) => ( + + {r.opportunityTitle ?? `Opportunity #${r.opportunityId}`} + + )), + ] )} diff --git a/src/db/schema/opportunities.ts b/src/db/schema/opportunities.ts index d364c3f..d2d6728 100644 --- a/src/db/schema/opportunities.ts +++ b/src/db/schema/opportunities.ts @@ -106,9 +106,9 @@ export const volunteerHours = pgTable("volunteer_hours", { volunteerId: integer("volunteer_id") .notNull() .references(() => volunteers.id, { onDelete: "cascade" }), - opportunityId: integer("opportunity_id") - .notNull() - .references(() => opportunities.id, { onDelete: "cascade" }), + opportunityId: integer("opportunity_id").references(() => opportunities.id, { + onDelete: "cascade", + }), date: timestamp("date").notNull(), hours: real("hours").notNull(), previousHours: real("previous_hours"), diff --git a/src/hooks/use-approvals-hours.ts b/src/hooks/use-approvals-hours.ts index 348988e..0914a60 100644 --- a/src/hooks/use-approvals-hours.ts +++ b/src/hooks/use-approvals-hours.ts @@ -8,7 +8,7 @@ export type ApprovalsHoursRecord = { id: number; volunteerId: number; volunteerName: string; - opportunityId: number; + opportunityId: number | null; opportunityTitle: string | null; date: string; hours: number; @@ -20,7 +20,7 @@ export type ApprovalsHoursRecord = { export type LogHoursInput = { volunteerId: number; - opportunityId: number; + opportunityId: number | null; date: string; hours: number; notes?: string; diff --git a/src/hooks/use-hours.ts b/src/hooks/use-hours.ts index 37c4567..068bf51 100644 --- a/src/hooks/use-hours.ts +++ b/src/hooks/use-hours.ts @@ -7,7 +7,7 @@ import type { HoursStatus } from "@/lib/status-enums"; export type VolunteerHour = { id: number; volunteerId: number; - opportunityId: number; + opportunityId: number | null; opportunityTitle?: string; date: string; hours: number; diff --git a/src/hooks/use-volunteer-hours.ts b/src/hooks/use-volunteer-hours.ts index 1baa266..fac3543 100644 --- a/src/hooks/use-volunteer-hours.ts +++ b/src/hooks/use-volunteer-hours.ts @@ -6,7 +6,7 @@ import type { HoursStatus } from "@/lib/status-enums"; export type VolunteerHourEntry = { id: number; - opportunityId: number; + opportunityId: number | null; opportunityTitle?: string | null; date: string; hours: number; @@ -16,7 +16,7 @@ export type VolunteerHourEntry = { }; export type LogHoursInput = { - opportunityId: number; + opportunityId: number | null; date: string; hours: number; notes?: string; diff --git a/src/services/shared/volunteer-data.ts b/src/services/shared/volunteer-data.ts index 05ebaed..fb8e0a5 100644 --- a/src/services/shared/volunteer-data.ts +++ b/src/services/shared/volunteer-data.ts @@ -55,7 +55,7 @@ export type VolunteerDetailData = { }[]; hoursBreakdown: { id: number; - opportunityId: number; + opportunityId: number | null; opportunityTitle: string | null; date: Date; hours: number; diff --git a/src/services/volunteer-client.service.ts b/src/services/volunteer-client.service.ts index 801f7eb..b807832 100644 --- a/src/services/volunteer-client.service.ts +++ b/src/services/volunteer-client.service.ts @@ -182,7 +182,7 @@ export type FetchVolunteerByIdResult = { }[]; hoursBreakdown?: { id: number; - opportunityId: number; + opportunityId: number | null; opportunityTitle: string | null; date: Date; hours: number; diff --git a/src/services/volunteer-detail.service.ts b/src/services/volunteer-detail.service.ts index 42a9ee5..93d5290 100644 --- a/src/services/volunteer-detail.service.ts +++ b/src/services/volunteer-detail.service.ts @@ -53,7 +53,7 @@ export type VolunteerDetail = { }[]; hoursBreakdown: { id: number; - opportunityId: number; + opportunityId: number | null; opportunityTitle: string | null; date: Date; hours: number; diff --git a/src/services/volunteer-hours.service.ts b/src/services/volunteer-hours.service.ts index 99a72d5..2b40e51 100644 --- a/src/services/volunteer-hours.service.ts +++ b/src/services/volunteer-hours.service.ts @@ -15,7 +15,7 @@ export type AllHoursRecord = { id: number; volunteerId: number; volunteerName: string; - opportunityId: number; + opportunityId: number | null; opportunityTitle: string | null; date: Date; hours: number; @@ -65,14 +65,14 @@ export type HoursFilters = { export type LogHoursInput = { volunteerId: number; - opportunityId: number; + opportunityId: number | null; date: string; hours: number; notes?: string; }; export type VolunteerLogHoursInput = { - opportunityId: number; + opportunityId: number | null; date: string; hours: number; notes?: string; @@ -147,7 +147,7 @@ export async function listVolunteerHours(filters: HoursFilters): Promise<{ export async function logHours(input: LogHoursInput): Promise<{ id: number; volunteerId: number; - opportunityId: number; + opportunityId: number | null; date: Date; hours: number; notes: string | null; @@ -162,17 +162,19 @@ export async function logHours(input: LogHoursInput): Promise<{ .from(volunteers) .where(eq(volunteers.id, input.volunteerId)) .limit(1), - db - .select() - .from(opportunities) - .where(eq(opportunities.id, input.opportunityId)) - .limit(1), + input.opportunityId + ? db + .select() + .from(opportunities) + .where(eq(opportunities.id, input.opportunityId)) + .limit(1) + : Promise.resolve([]), ]); if (volunteerExists.length === 0) { throw new NotFoundError("Volunteer not found"); } - if (opportunityExists.length === 0) { + if (input.opportunityId && opportunityExists.length === 0) { throw new NotFoundError("Opportunity not found"); } @@ -326,42 +328,48 @@ export async function volunteerLogHours( if (hours <= 0 || hours > 24) { throw new ValidationError("Hours must be between 0 and 24"); } - const [[rsvp], [existing]] = await Promise.all([ - db - .select() - .from(volunteerRsvps) - .where( - and( - eq(volunteerRsvps.volunteerId, volunteerId), - eq(volunteerRsvps.opportunityId, opportunityId), + + if (opportunityId) { + const [[rsvp], [existing]] = await Promise.all([ + db + .select() + .from(volunteerRsvps) + .where( + and( + eq(volunteerRsvps.volunteerId, volunteerId), + eq(volunteerRsvps.opportunityId, opportunityId), + ), ), - ), - db - .select({ id: volunteerHours.id }) - .from(volunteerHours) - .where( - and( - eq(volunteerHours.volunteerId, volunteerId), - eq(volunteerHours.opportunityId, opportunityId), + db + .select({ id: volunteerHours.id }) + .from(volunteerHours) + .where( + and( + eq(volunteerHours.volunteerId, volunteerId), + eq(volunteerHours.opportunityId, opportunityId), + ), ), - ), - ]); - if (!rsvp) { - throw new ValidationError( - "You can only log hours for events you RSVPed to", - ); - } - if (rsvp.status !== "attended") { - throw new ValidationError("You can only log hours for events you attended"); - } - if (existing) { - throw new ConflictError("You have already logged hours for this event"); + ]); + if (!rsvp) { + throw new ValidationError( + "You can only log hours for events you RSVPed to", + ); + } + if (rsvp.status !== "attended") { + throw new ValidationError( + "You can only log hours for events you attended", + ); + } + if (existing) { + throw new ConflictError("You have already logged hours for this event"); + } } + const [created] = await db .insert(volunteerHours) .values({ volunteerId, - opportunityId, + opportunityId: opportunityId ?? null, date: new Date(date), hours, notes: notes ?? null, @@ -442,7 +450,7 @@ export async function listVolunteerOwnHours(volunteerId: number): Promise< { id: number; volunteerId: number; - opportunityId: number; + opportunityId: number | null; opportunityTitle: string | null; date: Date; hours: number; diff --git a/src/services/volunteer.service.ts b/src/services/volunteer.service.ts index b939a43..bcb884c 100644 --- a/src/services/volunteer.service.ts +++ b/src/services/volunteer.service.ts @@ -117,7 +117,7 @@ export type GetVolunteerProfileResult = { }[]; hoursBreakdown: { id: number; - opportunityId: number; + opportunityId: number | null; opportunityTitle: string | null; date: Date; hours: number; diff --git a/tsconfig.json b/tsconfig.json index bb9b0af..0e5125d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", + "**/*.mts", ".next/types/**/*.ts", "eslint.config.mjs" ],