Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# ─── Database ────────────────────────────────────────────────────────────────
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/hope_events"
|
||||
|
||||
# ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
JWT_SECRET=your_jwt_secret_here_minimum_32_characters
|
||||
|
||||
# ─── Server ───────────────────────────────────────────────────────────────────
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# ─── CORS ─────────────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed frontend origins
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# ─── Email (SMTP) — optional fallbacks ───────────────────────────────────────
|
||||
# Preferred: configure SMTP via Admin → Site Settings (stored AES-256 encrypted).
|
||||
# These env vars are used as fallbacks if no SMTP settings are saved in the DB.
|
||||
# EMAIL_HOST=smtp.example.com
|
||||
# EMAIL_PORT=587
|
||||
# EMAIL_USER=your_email@example.com
|
||||
# EMAIL_PASS=your_email_password
|
||||
# EMAIL_FROM=no-reply@example.com
|
||||
|
||||
# ─── Payments (Yoco) ──────────────────────────────────────────────────────────
|
||||
YOCO_SECRET_KEY=your_yoco_secret_key
|
||||
YOCO_WEBHOOK_SECRET=your_yoco_webhook_secret
|
||||
|
||||
# ─── Public URLs ──────────────────────────────────────────────────────────────
|
||||
# Used to build links in emails (password reset, activation, etc.)
|
||||
APP_BASE_URL=https://your-frontend-domain.com
|
||||
# Used to serve ticket PDFs to WhatsApp (must be publicly reachable)
|
||||
BACKEND_URL=https://your-backend-domain.com
|
||||
|
||||
# ─── WhatsApp (WAWP) — optional, preferred stored via Admin → Site Settings ───
|
||||
# These are env-var fallbacks. Use the admin panel to manage them at runtime.
|
||||
WAWP_ACCESS_TOKEN=your_wawp_access_token
|
||||
WAWP_INSTANCE_ID=your_wawp_instance_id
|
||||
|
||||
# ─── Background workers ───────────────────────────────────────────────────────
|
||||
DAILY_SUMMARY_ENABLED=true
|
||||
SCHEDULED_EMAILS_ENABLED=true
|
||||
SCHEDULED_EMAILS_INTERVAL_MS=30000
|
||||
|
||||
# ─── Note ─────────────────────────────────────────────────────────────────────
|
||||
# The following are managed via Admin → Site Settings and stored in the database:
|
||||
# - Organisation name, tagline, contact details, branding colour, logo
|
||||
# - Registration notification emails
|
||||
# - SMTP settings (username and password stored AES-256-GCM encrypted, key
|
||||
# derived from JWT_SECRET — do not change JWT_SECRET after saving SMTP creds)
|
||||
# - Legal page content (operator name, Information Officer, website URL, etc.)
|
||||
# The first-time setup wizard handles initial configuration on fresh deployments.
|
||||
# You no longer need ORG_NAME, ORG_TAGLINE, EMAIL_HEADER_COLOR, REGISTRATIONS_EMAIL,
|
||||
# or SMTP vars in this file (they still work as fallbacks if the DB entry is absent).
|
||||
@@ -0,0 +1,525 @@
|
||||
# Hope Events — API Documentation
|
||||
|
||||
Full interactive docs (with request/response examples) are available at `GET /docs?token=<admin-jwt>` on the running backend.
|
||||
|
||||
All authenticated requests require:
|
||||
```
|
||||
Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
Tokens are issued by `POST /api/users/login`.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Users](#users)
|
||||
- [Events](#events)
|
||||
- [Event Options & Variants](#event-options--variants)
|
||||
- [Event Attachments](#event-attachments)
|
||||
- [Registrations](#registrations)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
- [WhatsApp Broadcasts](#whatsapp-broadcasts)
|
||||
- [Scheduled Messages](#scheduled-messages)
|
||||
- [Automations](#automations)
|
||||
- [Reports](#reports)
|
||||
- [Uploads](#uploads)
|
||||
- [Sections](#sections)
|
||||
- [Banner](#banner)
|
||||
- [Forms](#forms)
|
||||
- [Yoco Transactions](#yoco-transactions)
|
||||
- [WhatsApp Admin](#whatsapp-admin)
|
||||
- [Webhooks](#webhooks)
|
||||
- [Settings](#settings)
|
||||
- [Setup](#setup)
|
||||
- [Static Files](#static-files)
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/users` | public | Register new account |
|
||||
| POST | `/api/users/login` | public | Login, returns JWT |
|
||||
| POST | `/api/users/forgot` | public | Request password reset email |
|
||||
| POST | `/api/users/reset` | public | Reset password with token |
|
||||
| POST | `/api/users/activate` | public | Activate account from email link |
|
||||
| GET | `/api/users/profile` | user+ | Own profile |
|
||||
| PUT | `/api/users/profile` | user+ | Update own profile |
|
||||
| POST | `/api/users/revoke-sessions` | user+ | Revoke own sessions |
|
||||
| POST | `/api/users/close-account` | user+ | Deactivate own account |
|
||||
| GET | `/api/users` | supervisor+ | List users — supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` |
|
||||
| GET | `/api/users/:id` | admin | Get user by ID |
|
||||
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber) |
|
||||
| DELETE | `/api/users/:id` | admin | Deactivate user (`isActive: false`) |
|
||||
| POST | `/api/users/:id/revoke-sessions` | admin | Revoke all sessions for a user |
|
||||
| POST | `/api/users/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account |
|
||||
|
||||
### POST /api/users
|
||||
```json
|
||||
{
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com",
|
||||
"password": "securepass123",
|
||||
"phoneNumber": "+27821234567"
|
||||
}
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
### POST /api/users/login
|
||||
```json
|
||||
{ "email": "jane@example.com", "password": "securepass123" }
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
### PUT /api/users/profile
|
||||
```json
|
||||
{ "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" }
|
||||
```
|
||||
`notificationPreference`: `"email"` | `"whatsapp"` | `"both"`
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/events` | public | Active, public, upcoming events. Includes `isSoldOut: boolean` per event. |
|
||||
| GET | `/api/events/all` | staff+ | All events including hidden/past (`?includePast=true`, `?includeInactive=true`) |
|
||||
| GET | `/api/events/admin/all` | admin | Admin view of all events |
|
||||
| GET | `/api/events/:id` | public | Full event detail — options, variants, earlyBirdTiers, attachments, form, availableCount per option/variant |
|
||||
| GET | `/api/events/by-alias/:redirectUrl` | public | Get event by URL alias |
|
||||
| POST | `/api/events` | supervisor+ | Create event |
|
||||
| PUT | `/api/events/:id` | supervisor+ | Update event |
|
||||
| DELETE | `/api/events/:id` | admin | Delete event |
|
||||
| POST | `/api/events/:id/email-attendees` | supervisor+ | Bulk email attendees (`dryRun: true` to preview) |
|
||||
| POST | `/api/events/:id/email-attendees/schedule` | supervisor+ | Schedule bulk email |
|
||||
| POST | `/api/events/:id/whatsapp-attendees` | supervisor+ | Bulk WhatsApp to attendees (`dryRun: true` to preview) |
|
||||
| POST | `/api/events/:id/whatsapp-attendees/schedule` | supervisor+ | Schedule bulk WhatsApp |
|
||||
| GET | `/api/events/attachments/status` | admin | Attachment manifest sync status |
|
||||
| POST | `/api/events/attachments/sync` | admin | Re-sync attachment manifests to DB |
|
||||
|
||||
### isSoldOut logic (`GET /api/events`)
|
||||
`isSoldOut` is `true` when every option on the event that has `stockLimit > 0` has sold out its full allocation (computed from non-cancelled `RegistrationOption` quantities). Events with no limited options are never marked sold out.
|
||||
|
||||
### POST /api/events
|
||||
```json
|
||||
{
|
||||
"title": "Family Camp 2025",
|
||||
"description": "Annual family camp at the farm.",
|
||||
"startDate": "2025-07-10T08:00:00.000Z",
|
||||
"endDate": "2025-07-14T17:00:00.000Z",
|
||||
"registrationDeadline": "2025-07-01T00:00:00.000Z",
|
||||
"goLiveAt": "2025-05-01T00:00:00.000Z",
|
||||
"price": 450,
|
||||
"picture": "/uploads/events/camp.jpg",
|
||||
"redirectUrl": "camp2025",
|
||||
"isHidden": false,
|
||||
"requiresAuth": true,
|
||||
"form": {
|
||||
"isRequired": true,
|
||||
"fields": [
|
||||
{ "type": "text", "label": "Dietary requirements", "isRequired": false, "order": 0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Options & Variants
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/events/:id/options` | supervisor+ | Add option to event |
|
||||
| PUT | `/api/events/options/:id` | supervisor+ | Update option (name, price, stockLimit, variants, earlyBirdTiers) |
|
||||
| DELETE | `/api/events/options/:id` | admin | Delete option (fails if registrations exist) |
|
||||
| POST | `/api/events/options/:id/variants` | supervisor+ | Add variant to option |
|
||||
| PUT | `/api/events/variants/:id` | supervisor+ | Update variant |
|
||||
| DELETE | `/api/events/variants/:id` | admin | Delete variant |
|
||||
|
||||
### POST /api/events/:id/options
|
||||
```json
|
||||
{
|
||||
"name": "Adult",
|
||||
"price": 450,
|
||||
"isMainTicket": false,
|
||||
"stockLimit": 100
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /api/events/options/:id — with variants and early-bird tiers
|
||||
```json
|
||||
{
|
||||
"name": "Adult",
|
||||
"price": 450,
|
||||
"stockLimit": 100,
|
||||
"variants": [
|
||||
{ "name": "Standard", "price": null, "stockLimit": 60, "order": 0 },
|
||||
{ "name": "VIP", "price": 600, "stockLimit": 40, "order": 1 }
|
||||
],
|
||||
"earlyBirdTiers": [
|
||||
{ "deadline": "2025-06-01T00:00:00.000Z", "price": 350, "stockLimit": 30, "order": 0, "variantId": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`stockLimit: 0` means unlimited. Early-bird tiers can be option-level (`variantId: null`) or variant-level (`variantId: "<uuid>"`).
|
||||
|
||||
---
|
||||
|
||||
## Event Attachments
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/events/:id/attachments` | public | List attachments for event |
|
||||
| POST | `/api/events/:id/attachments` | supervisor+ | Upload attachment (`multipart/form-data`, field `file`) |
|
||||
| DELETE | `/api/events/:eventId/attachments/:id` | supervisor+ | Delete attachment |
|
||||
|
||||
Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, `.txt`, `.csv`, `.zip`. Max 10 MB.
|
||||
|
||||
---
|
||||
|
||||
## Registrations
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/registrations` | optional | Create registration |
|
||||
| GET | `/api/registrations/myregistrations` | user+ | Own registrations |
|
||||
| GET | `/api/registrations/:id` | optional | Get registration by ID |
|
||||
| PUT | `/api/registrations/:id/options` | user+ | Update selected options |
|
||||
| DELETE | `/api/registrations/:id` | user+ | Cancel registration (blocked if positive payments exist unless admin) |
|
||||
| GET | `/api/registrations/:id/forms/draft` | optional | Get form draft |
|
||||
| PUT | `/api/registrations/:id/forms/draft` | optional | Save form draft |
|
||||
| POST | `/api/registrations/:id/forms/responses` | optional | Submit form responses |
|
||||
| PUT | `/api/registrations/:id/forms/responses` | supervisor+ | Replace form responses |
|
||||
| GET | `/api/registrations` | staff+ | All registrations |
|
||||
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
|
||||
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
|
||||
| POST | `/api/registrations/manual` | supervisor+ | Manual registration |
|
||||
|
||||
### POST /api/registrations
|
||||
```json
|
||||
{
|
||||
"eventId": "ev-uuid-...",
|
||||
"options": [
|
||||
{ "eventOptionId": "opt-uuid-...", "quantity": 2 },
|
||||
{ "eventOptionId": "opt-uuid-...", "quantity": 1, "variantId": "variant-uuid-..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
```json
|
||||
{
|
||||
"eventId": "ev-uuid-...",
|
||||
"options": [...],
|
||||
"guestName": "Jane Smith",
|
||||
"guestEmail": "jane@example.com",
|
||||
"guestPhone": "+27821234567"
|
||||
}
|
||||
```
|
||||
|
||||
### Cancellation rules
|
||||
|
||||
| Actor | Condition | Result |
|
||||
|-------|-----------|--------|
|
||||
| Owner | No positive payments | 200 — status set to `cancelled` |
|
||||
| Owner | Any positive payment exists | 403 — must contact organisation |
|
||||
| Admin | Any state | 200 — always allowed |
|
||||
| Anyone | Already cancelled | 400 |
|
||||
|
||||
---
|
||||
|
||||
## Payments
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/payments` | supervisor+ | Record manual payment (cash/EFT) |
|
||||
| POST | `/api/payments/yoco-checkout` | user+ | Initiate Yoco checkout; re-evaluates early-bird pricing first — returns `{ priceUpdated: true, newTotal }` if prices changed |
|
||||
| GET | `/api/payments/mypayments` | user+ | Own payments |
|
||||
| GET | `/api/payments/:id` | user+ | Payment by ID |
|
||||
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
|
||||
| GET | `/api/payments` | supervisor+ | All payments |
|
||||
| GET | `/api/payments/event/:eventId` | staff+ | Payments for an event |
|
||||
| PUT | `/api/payments/assign-donation` | supervisor+ | Assign a donation payment to a registration |
|
||||
| POST | `/api/payments/refund` | supervisor+ | Create refund record |
|
||||
| GET | `/api/payments/admin/stats` | admin | Payment statistics |
|
||||
|
||||
### POST /api/payments (manual)
|
||||
```json
|
||||
{
|
||||
"registrationId": "reg-uuid-...",
|
||||
"amount": 450,
|
||||
"method": "cash",
|
||||
"note": "Paid at the door"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/payments/yoco-checkout
|
||||
```json
|
||||
{
|
||||
"registrationId": "reg-uuid-...",
|
||||
"successUrl": "https://events.yourchurch.org/payment/success",
|
||||
"cancelUrl": "https://events.yourchurch.org/payment/cancel",
|
||||
"failureUrl": "https://events.yourchurch.org/payment/failure"
|
||||
}
|
||||
```
|
||||
Returns: `{ redirectUrl }` or `{ priceUpdated: true, newTotal, message }` if early-bird prices changed.
|
||||
|
||||
---
|
||||
|
||||
## Tickets
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/tickets/mytickets` | user+ | Own tickets (excludes cancelled registrations) |
|
||||
| GET | `/api/tickets/:id` | user+ | Ticket by ID |
|
||||
| POST | `/api/tickets/email` | user+ | Email own tickets as PDF |
|
||||
| POST | `/api/tickets/send-to` | staff+ | Send tickets to a specific user |
|
||||
| GET | `/api/tickets/scan-preview/:qrCode` | staff+ | Preview scan (no mark) |
|
||||
| GET | `/api/tickets/qr/:qrCode` | staff+ | Ticket by QR code |
|
||||
| POST | `/api/tickets/scan/:qrCode` | staff+ | Scan/redeem ticket |
|
||||
| GET | `/api/tickets/event/:eventId` | staff+ | All tickets for an event |
|
||||
| GET | `/api/tickets/scans/recent` | staff+ | Recent scan activity |
|
||||
| GET | `/api/tickets/scans/stats` | staff+ | Scan statistics |
|
||||
| POST | `/api/tickets/generate` | supervisor+ | Generate tickets for a registration |
|
||||
| GET | `/api/tickets` | supervisor+ | All tickets |
|
||||
| PUT | `/api/tickets/email-sent` | admin | Bulk mark tickets as emailed |
|
||||
|
||||
---
|
||||
|
||||
## Broadcasts (Email)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/broadcasts/preview` | supervisor+ | Dry-run — returns recipient count and sample |
|
||||
| POST | `/api/broadcasts/send` | supervisor+ | Send email broadcast immediately |
|
||||
| POST | `/api/broadcasts/schedule` | supervisor+ | Schedule email broadcast |
|
||||
|
||||
### POST /api/broadcasts/send
|
||||
```json
|
||||
{
|
||||
"subject": "Camp 2025 — Final reminder",
|
||||
"body": "Hi {{name}}, just a reminder that camp starts tomorrow at 08:00.",
|
||||
"userIds": ["user-uuid-1", "user-uuid-2"],
|
||||
"extraEmails": ["extra@example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
Dynamic placeholders: `{{name}}`, `{{event.title}}`, `{{event.start}}`, `{{event.link}}`, `{{balance}}`, `{{promo.title}}`, `{{promo.link}}`
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp Broadcasts
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/whatsapp-broadcasts/preview` | supervisor+ | Dry-run |
|
||||
| POST | `/api/whatsapp-broadcasts/send` | supervisor+ | Send WhatsApp broadcast immediately |
|
||||
| POST | `/api/whatsapp-broadcasts/schedule` | supervisor+ | Schedule WhatsApp broadcast |
|
||||
|
||||
---
|
||||
|
||||
## Scheduled Messages
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/scheduled-emails` | supervisor+ | List all queued/scheduled jobs |
|
||||
| PATCH | `/api/scheduled-emails/:id` | supervisor+ | Edit scheduled job (reschedule, update content) |
|
||||
| DELETE | `/api/scheduled-emails/:id` | supervisor+ | Cancel a queued job |
|
||||
|
||||
---
|
||||
|
||||
## Automations
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/automations/schedule` | supervisor+ | Schedule event lifecycle automations (pre-event reminder, thank-you, promo, etc.) |
|
||||
|
||||
---
|
||||
|
||||
## Reports
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/reports/pdf` | user+ | Generate and download PDF attendance/payment report |
|
||||
| POST | `/api/reports/email` | user+ | Email PDF report to current user |
|
||||
|
||||
---
|
||||
|
||||
## Uploads
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/uploads/event-image` | supervisor+ | Upload event cover image (`multipart/form-data`, field `image`). Returns `{ url }`. Max 5 MB. Formats: jpg, jpeg, png, webp, gif. |
|
||||
| POST | `/api/uploads/logo` | admin (or unauthenticated during setup) | Upload site logo (`multipart/form-data`, field `image`). Returns `{ url }`. Max 2 MB. Formats: jpg, jpeg, png, webp, svg. |
|
||||
|
||||
---
|
||||
|
||||
## Sections
|
||||
|
||||
Groups of event options shown as labelled sections on the registration page (e.g. "Tickets", "Add-ons", "Accommodation").
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/sections` | staff+ | List sections |
|
||||
| POST | `/api/sections` | supervisor+ | Create section |
|
||||
| PUT | `/api/sections/:id` | supervisor+ | Update section |
|
||||
| DELETE | `/api/sections/:id` | admin | Delete section |
|
||||
|
||||
### POST /api/sections
|
||||
```json
|
||||
{
|
||||
"eventId": "ev-uuid-...",
|
||||
"name": "Accommodation",
|
||||
"allowedOptionIds": ["opt-uuid-1", "opt-uuid-2"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Banner
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/banner` | public | Get current site-wide banner (text, colour, active flag) |
|
||||
| POST | `/api/banner` | supervisor+ | Set site banner |
|
||||
|
||||
### POST /api/banner
|
||||
```json
|
||||
{
|
||||
"text": "Camp registrations close Friday!",
|
||||
"color": "#f59e0b",
|
||||
"isActive": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Forms
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/forms/responses` | staff+ | List all submitted form responses across all events; supports filtering |
|
||||
|
||||
---
|
||||
|
||||
## Yoco Transactions
|
||||
|
||||
Raw webhook events from Yoco. Used for reconciling payments that weren't matched automatically.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/yoco-transactions` | supervisor+ | All Yoco webhook transactions |
|
||||
| GET | `/api/yoco-transactions/unreconciled` | supervisor+ | Transactions not yet matched to a payment |
|
||||
| POST | `/api/yoco-transactions/:id/reconcile` | supervisor+ | Manually link transaction to a registration |
|
||||
| POST | `/api/yoco-transactions/:id/ignore` | supervisor+ | Mark transaction as intentionally ignored |
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp Admin
|
||||
|
||||
Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (preferred) or `.env` (fallback).
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials |
|
||||
| POST | `/api/whatsapp/config` | admin | Save WAWP credentials to DB |
|
||||
| GET | `/api/whatsapp/status` | admin | WAWP session status |
|
||||
| GET | `/api/whatsapp/qr` | admin | QR code for WhatsApp device linking |
|
||||
| POST | `/api/whatsapp/create-instance` | admin | Create a new WAWP instance |
|
||||
| POST | `/api/whatsapp/delete-instance` | admin | Delete WAWP instance |
|
||||
| POST | `/api/whatsapp/request-code` | admin | Request pairing code (alternative to QR) |
|
||||
| POST | `/api/whatsapp/logout` | admin | Logout current WAWP session |
|
||||
| POST | `/api/whatsapp/start` | admin | Start WAWP instance |
|
||||
| POST | `/api/whatsapp/restart` | admin | Restart WAWP instance |
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies `X-Yoco-Signature`, creates `Payment` + `YocoTransaction`, updates registration status, generates tickets |
|
||||
| POST | `/api/webhooks/whatsapp` | — | WAWP inbound event webhook |
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/settings` | public | Public settings (org name, tagline, colour, logo, legal keys) |
|
||||
| GET | `/api/settings/all` | admin | All settings — `smtp_pass` masked as `••••••••`, `smtp_user` decrypted |
|
||||
| GET | `/api/settings/needs-setup` | public | `{ needsSetup: bool }` — true until setup wizard completes |
|
||||
| PUT | `/api/settings` | admin | Upsert settings; `smtp_user`/`smtp_pass` encrypted before storage; sending `••••••••` for `smtp_pass` keeps existing value |
|
||||
| POST | `/api/settings/test-smtp` | admin | Test SMTP with provided credentials; sends real test email to admin on success. The admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
|
||||
|
||||
### POST /api/settings/test-smtp
|
||||
```json
|
||||
{
|
||||
"host": "smtp.office365.com",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"user": "noreply@yourchurch.org",
|
||||
"pass": "app-password-here",
|
||||
"from": "noreply@yourchurch.org"
|
||||
}
|
||||
```
|
||||
|
||||
**Success (200):**
|
||||
```json
|
||||
{ "message": "SMTP connection verified — a test email has been sent to admin@yourchurch.org" }
|
||||
```
|
||||
|
||||
**Failure (400):**
|
||||
```json
|
||||
{
|
||||
"message": "Authentication failed — check your SMTP username and password.",
|
||||
"raw": "530 5.7.57 Client not authenticated to send mail. [JN3P275CA0121...]"
|
||||
}
|
||||
```
|
||||
|
||||
The `message` field is always a human-readable sentence. The `raw` field is included on errors only and contains the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
One-time first-run wizard. Both endpoints are blocked once any user exists and setup is complete.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/setup/register` | public (one-time) | Create the first admin account; returns a short-lived setup JWT |
|
||||
| POST | `/api/setup` | setup token or admin | Save initial site settings (org name, branding, SMTP) |
|
||||
|
||||
---
|
||||
|
||||
## Static Files
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/uploads/events/*` | Event cover images |
|
||||
| `/uploads/branding/*` | Site logo |
|
||||
| `/uploads/event-files/*` | Event attachments (PDFs, docs, etc.) |
|
||||
| `/uploads/tickets-temp/*` | Temporarily hosted ticket PDFs for WhatsApp delivery (auto-deleted after 5 min) |
|
||||
|
||||
---
|
||||
|
||||
## Auth error responses
|
||||
|
||||
| Status | Body | Meaning |
|
||||
|--------|------|---------|
|
||||
| 401 | `{ message: "Not authorised, no token" }` | Missing or malformed `Authorization` header |
|
||||
| 401 | `{ message: "Not authorised, token failed" }` | Invalid or expired JWT |
|
||||
| 401 | `{ message: "Session has been revoked" }` | Token version mismatch (sessions revoked) |
|
||||
| 403 | `{ message: "Not authorised as an admin" }` | Insufficient role |
|
||||
|
||||
---
|
||||
|
||||
*For full interactive docs with expandable examples visit `GET /docs?token=<admin-jwt>` on the running backend.*
|
||||
@@ -0,0 +1,528 @@
|
||||
# Hope Events — Backend API
|
||||
|
||||
Node.js + Express REST API for the Hope Events platform used by Hope Family Church (`events.hopehenley.co.za`).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Tech Stack](#tech-stack)
|
||||
3. [Prerequisites](#prerequisites)
|
||||
4. [Installation](#installation)
|
||||
5. [Environment Variables](#environment-variables)
|
||||
6. [Database](#database)
|
||||
7. [Running the Server](#running-the-server)
|
||||
8. [Authentication & Roles](#authentication--roles)
|
||||
9. [API Reference](#api-reference)
|
||||
10. [Background Workers](#background-workers)
|
||||
11. [Notification System](#notification-system)
|
||||
12. [WhatsApp Integration](#whatsapp-integration)
|
||||
13. [File Uploads](#file-uploads)
|
||||
14. [Payments (Yoco)](#payments-yoco)
|
||||
15. [Deployment](#deployment)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Full-stack event management backend handling:
|
||||
- User registration, authentication (JWT), and role-based access control
|
||||
- Event creation, management, and public listings
|
||||
- Attendee registration with optional payment tiers and early-bird pricing
|
||||
- Yoco checkout integration with webhook reconciliation
|
||||
- Ticket generation (PDF + QR code) and email/WhatsApp delivery
|
||||
- Scheduled bulk messaging (email + WhatsApp) for attendees and broadcast lists
|
||||
- Admin WhatsApp instance management via WAWP
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Runtime | Node.js 18+ |
|
||||
| Framework | Express 4 |
|
||||
| Database | PostgreSQL (via Prisma ORM 5) |
|
||||
| Auth | JWT (`jsonwebtoken`), `bcryptjs` |
|
||||
| Email | Nodemailer (any SMTP — configured via Admin → Site Settings, AES-256 encrypted in DB) |
|
||||
| WhatsApp | WAWP REST API (`api.wawp.net/v2`) |
|
||||
| PDF/QR | PDFKit, QRCode |
|
||||
| File Uploads | Multer |
|
||||
| Payments | Yoco |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- PostgreSQL 14+ (hosted at `database.crosscode.co.za` for production)
|
||||
- npm
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install # installs deps and runs `prisma generate`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `backend/.env` from `.env.example`. The only variables you must set are:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `DATABASE_URL` | Yes | PostgreSQL connection string |
|
||||
| `JWT_SECRET` | Yes | Secret key for JWTs (32+ random characters). Also used to derive the AES-256 encryption key for sensitive settings (SMTP credentials) stored in the DB — must be set before saving SMTP settings. |
|
||||
| `FRONTEND_URL` | Yes | Comma-separated allowed CORS origins |
|
||||
| `YOCO_SECRET_KEY` | Yes | Yoco secret API key |
|
||||
| `YOCO_WEBHOOK_SECRET` | Yes | Yoco webhook HMAC secret |
|
||||
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
|
||||
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
|
||||
| `PORT` | — | Port to listen on (default `3000`) |
|
||||
| `NODE_ENV` | — | `production` or `development` |
|
||||
| `WAWP_ACCESS_TOKEN` | — | WAWP fallback token (preferred: set via Admin → Site Settings) |
|
||||
| `WAWP_INSTANCE_ID` | — | WAWP fallback instance ID (preferred: set via Admin → Site Settings) |
|
||||
| `DAILY_SUMMARY_ENABLED` | — | `false` to disable daily summaries (default `true`) |
|
||||
| `SCHEDULED_EMAILS_ENABLED` | — | `false` to disable scheduled-send worker (default `true`) |
|
||||
| `SCHEDULED_EMAILS_INTERVAL_MS` | — | Polling interval in ms (default `30000`) |
|
||||
| `SMTP_HOST` / `EMAIL_HOST` | — | Fallback SMTP host (preferred: set via Admin → Site Settings) |
|
||||
| `SMTP_PORT` / `EMAIL_PORT` | — | Fallback SMTP port |
|
||||
| `SMTP_USER` / `EMAIL_USER` | — | Fallback SMTP username |
|
||||
| `SMTP_PASS` / `EMAIL_PASS` | — | Fallback SMTP password |
|
||||
| `MAIL_FROM` / `EMAIL_FROM` | — | Fallback from address |
|
||||
|
||||
> **Organisation name, branding colour, logo, registration notification email, SMTP settings, and legal page content** are now managed through the admin panel at **Admin → Site Settings** and stored in the database. You no longer need `ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, or SMTP vars in `.env` (they still work as fallbacks). On a fresh deployment these are set via the first-time setup wizard.
|
||||
>
|
||||
> **SMTP credentials** (`smtp_user`, `smtp_pass`) are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving SMTP credentials — the stored values will become unreadable.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Schema summary
|
||||
|
||||
| Model | Purpose |
|
||||
|-------|---------|
|
||||
| `User` | Accounts with roles (`admin`, `supervisor`, `staff`, `user`) |
|
||||
| `Event` | Events with dates, pricing, options, attachments |
|
||||
| `EventOption` | Ticket types / items per event; `stockLimit` (0 = unlimited), `isMainTicket` flag |
|
||||
| `EarlyBirdTier` | Time-limited discounted prices; linked to an `EventOption` (option-level) or an `OptionVariant` (variant-level via nullable `variantId`); `stockLimit` (0 = unlimited) |
|
||||
| `OptionVariant` | Sub-items per option (size, colour, ticket type); optional `price` override; own `stockLimit` |
|
||||
| `Registration` | A user's registration for an event; `status`: pending/confirmed/partial_paid/paid/cancelled |
|
||||
| `RegistrationOption` | Which options (and quantities) a registration includes; `priceSnapshot`, `appliedTierId`, `variantId` |
|
||||
| `Payment` | Payment records (Yoco, manual, donation, refund) |
|
||||
| `Ticket` | QR-code tickets linked to registration options |
|
||||
| `TicketUsage` | Scan log for each ticket redemption |
|
||||
| `PasswordReset` | One-time tokens for password reset flow |
|
||||
| `EventAttachment` | Files attached to events (PDFs, images) |
|
||||
| `EventForm` / `EventFormField` | Per-event custom forms |
|
||||
| `FormResponse` / `FormAnswer` | Submitted form data from attendees |
|
||||
| `FormDraft` | Auto-saved in-progress form data |
|
||||
| `YocoTransaction` | Raw webhook events from Yoco for reconciliation |
|
||||
| `Section` / `SectionOption` | Grouping of event options into sections |
|
||||
| `AppSetting` | Key-value store for runtime config — WAWP credentials, org name, branding, notification emails, SMTP settings (credentials stored AES-256-GCM encrypted), legal page content |
|
||||
|
||||
### Migrations
|
||||
|
||||
```bash
|
||||
# Apply pending migrations (production)
|
||||
npm run prisma:deploy
|
||||
|
||||
# Check migration status
|
||||
npm run prisma:status
|
||||
|
||||
# Regenerate Prisma client after schema changes
|
||||
npm run prisma:generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running the Server
|
||||
|
||||
```bash
|
||||
# Development (auto-reload)
|
||||
npm run dev
|
||||
|
||||
# Production
|
||||
npm start
|
||||
```
|
||||
|
||||
Visit `/` for the status page. Visit `/docs?token=<admin-jwt>` for full API documentation (admin only).
|
||||
|
||||
---
|
||||
|
||||
## Authentication & Roles
|
||||
|
||||
All protected routes require a `Bearer` token in the `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
Tokens are issued on `POST /api/users/login`. Token version is stored per-user; revoking sessions increments the version and invalidates all existing tokens for that user.
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| `admin` | Full access — user management, delete operations, WhatsApp admin, payment stats |
|
||||
| `supervisor` | Event/registration/payment management, bulk messaging, reports |
|
||||
| `staff` | Read access to registrations, payments, tickets; ticket scanning |
|
||||
| `user` | Own profile, own registrations, own tickets |
|
||||
|
||||
### Middleware
|
||||
|
||||
| Middleware | Applied when |
|
||||
|-----------|-------------|
|
||||
| `protect` | Any authenticated route |
|
||||
| `admin` | Admin-only actions |
|
||||
| `supervisor` | Supervisor + admin |
|
||||
| `staff` | Staff + supervisor + admin |
|
||||
| `optionalAuth` | Routes that work for guests but enrich responses if authenticated |
|
||||
| `loginLimiter` | Rate-limits login to 15 req/min per IP |
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
Full interactive API docs available at `GET /docs` (requires admin JWT — pass via `?token=<jwt>` query param or `Authorization: Bearer` header).
|
||||
|
||||
### Quick reference by domain
|
||||
|
||||
#### Users — `/api/users`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/` | public | Register a new account |
|
||||
| POST | `/login` | public | Login, returns JWT |
|
||||
| POST | `/forgot` | public | Request password reset email |
|
||||
| POST | `/reset` | public | Reset password with token |
|
||||
| POST | `/activate` | public | Activate account from email link |
|
||||
| GET | `/profile` | user+ | Get own profile |
|
||||
| PUT | `/profile` | user+ | Update own profile |
|
||||
| POST | `/revoke-sessions` | user+ | Revoke own sessions |
|
||||
| POST | `/close-account` | user+ | Deactivate own account |
|
||||
| GET | `/` | supervisor+ | List all users (paginated); supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` |
|
||||
| GET | `/:id` | admin | Get user by ID |
|
||||
| PUT | `/:id` | admin | Update user |
|
||||
| DELETE | `/:id` | admin | Deactivate user (sets `isActive: false`) |
|
||||
| POST | `/:id/revoke-sessions` | admin | Revoke all sessions for a user |
|
||||
| POST | `/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account, revokes all sessions |
|
||||
|
||||
#### Events — `/api/events`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/` | public | List public/active upcoming events; response includes `isSoldOut: boolean` per event |
|
||||
| GET | `/all` | staff+ | All events including hidden/past |
|
||||
| GET | `/admin/all` | admin | All events (admin view) |
|
||||
| GET | `/attachments/status` | admin | Attachment manifest sync status |
|
||||
| POST | `/attachments/sync` | admin | Re-sync attachment manifests to DB |
|
||||
| GET | `/:id` | public | Get event by ID |
|
||||
| GET | `/by-alias/:redirectUrl` | public | Get event by redirect alias |
|
||||
| POST | `/` | supervisor+ | Create event |
|
||||
| PUT | `/:id` | supervisor+ | Update event |
|
||||
| DELETE | `/:id` | admin | Delete event |
|
||||
| GET | `/:id/attachments` | public | List event file attachments |
|
||||
| POST | `/:id/attachments` | supervisor+ | Upload attachment |
|
||||
| DELETE | `/:eventId/attachments/:id` | supervisor+ | Delete attachment |
|
||||
| POST | `/:id/email-attendees` | supervisor+ | Bulk email attendees (supports `dryRun`) |
|
||||
| POST | `/:id/email-attendees/schedule` | supervisor+ | Schedule bulk email |
|
||||
| POST | `/:id/whatsapp-attendees` | supervisor+ | Bulk WhatsApp to attendees (supports `dryRun`) |
|
||||
| POST | `/:id/whatsapp-attendees/schedule` | supervisor+ | Schedule bulk WhatsApp |
|
||||
| POST | `/:id/options` | supervisor+ | Add event option |
|
||||
| PUT | `/options/:id` | supervisor+ | Update event option |
|
||||
| DELETE | `/options/:id` | admin | Delete event option |
|
||||
|
||||
#### Registrations — `/api/registrations`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/` | optional | Create registration |
|
||||
| GET | `/myregistrations` | user+ | Own registrations |
|
||||
| PUT | `/:id/options` | user+ | Update registration options |
|
||||
| DELETE | `/:id` | user+ | Cancel registration |
|
||||
| GET | `/:id` | optional | Get registration by ID |
|
||||
| GET | `/:id/forms/draft` | optional | Get form draft |
|
||||
| PUT | `/:id/forms/draft` | optional | Save form draft |
|
||||
| POST | `/:id/forms/responses` | optional | Submit form responses |
|
||||
| PUT | `/:id/forms/responses` | supervisor+ | Replace form responses |
|
||||
| GET | `/` | staff+ | List all registrations |
|
||||
| PUT | `/:id` | staff+ | Update registration status |
|
||||
| GET | `/event/:eventId` | staff+ | Registrations for an event |
|
||||
| POST | `/manual` | supervisor+ | Manual registration |
|
||||
|
||||
#### Payments — `/api/payments`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/` | supervisor+ | Record manual payment |
|
||||
| POST | `/yoco-checkout` | user+ | Initiate Yoco checkout |
|
||||
| GET | `/mypayments` | user+ | Own payments |
|
||||
| GET | `/:id` | user+ | Payment by ID |
|
||||
| GET | `/registration/:registrationId` | user+ | Payments for a registration |
|
||||
| GET | `/` | supervisor+ | All payments |
|
||||
| GET | `/event/:eventId` | staff+ | Payments for an event |
|
||||
| PUT | `/assign-donation` | supervisor+ | Assign donation to registration |
|
||||
| POST | `/refund` | supervisor+ | Create refund |
|
||||
| GET | `/admin/stats` | admin | Payment statistics |
|
||||
|
||||
#### Tickets — `/api/tickets`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/mytickets` | user+ | Own tickets |
|
||||
| GET | `/:id` | user+ | Ticket by ID |
|
||||
| POST | `/email` | user+ | Email own tickets |
|
||||
| POST | `/send-to` | staff+ | Send tickets to a specific user |
|
||||
| GET | `/scan-preview/:qrCode` | staff+ | Preview scan (no mark) |
|
||||
| GET | `/qr/:qrCode` | staff+ | Ticket by QR code |
|
||||
| POST | `/scan/:qrCode` | staff+ | Scan/redeem ticket |
|
||||
| GET | `/event/:eventId` | staff+ | All tickets for event |
|
||||
| GET | `/scans/recent` | staff+ | Recent scan activity |
|
||||
| GET | `/scans/stats` | staff+ | Scan statistics |
|
||||
| POST | `/generate` | supervisor+ | Generate tickets for registration |
|
||||
| GET | `/` | supervisor+ | All tickets |
|
||||
| PUT | `/email-sent` | admin | Bulk mark tickets as emailed |
|
||||
|
||||
#### Webhooks — `/api/webhooks`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/yoco` | HMAC | Yoco payment webhook |
|
||||
| POST | `/whatsapp` | — | WAWP inbound webhook |
|
||||
|
||||
#### Broadcasts — `/api/broadcasts` (email)
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/preview` | supervisor+ | Dry-run, returns recipient count/sample |
|
||||
| POST | `/send` | supervisor+ | Send email broadcast |
|
||||
| POST | `/schedule` | supervisor+ | Schedule email broadcast |
|
||||
|
||||
#### WhatsApp Broadcasts — `/api/whatsapp-broadcasts`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/preview` | supervisor+ | Dry-run |
|
||||
| POST | `/send` | supervisor+ | Send WA broadcast |
|
||||
| POST | `/schedule` | supervisor+ | Schedule WA broadcast |
|
||||
|
||||
#### Scheduled Messages — `/api/scheduled-emails`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/` | supervisor+ | List all scheduled jobs |
|
||||
| PATCH | `/:id` | supervisor+ | Edit a queued job |
|
||||
| DELETE | `/:id` | supervisor+ | Cancel a queued job |
|
||||
|
||||
#### Automations — `/api/automations`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/schedule` | supervisor+ | Schedule event lifecycle automations |
|
||||
|
||||
#### Reports — `/api/reports`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/pdf` | user+ | Generate and download PDF report |
|
||||
| POST | `/email` | user+ | Email PDF report to current user |
|
||||
|
||||
#### Settings — `/api/settings`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/` | public | Public settings (org name, colour, logo, legal keys, etc.) |
|
||||
| GET | `/all` | admin | All settings — `smtp_pass` returned masked (`••••••••`), `smtp_user` decrypted |
|
||||
| GET | `/needs-setup` | public | `{ needsSetup: bool }` — true until setup wizard completes |
|
||||
| PUT | `/` | admin | Upsert settings; `smtp_user` and `smtp_pass` are AES-256-GCM encrypted before storage; sending `••••••••` for `smtp_pass` is a no-op (keeps existing value) |
|
||||
| POST | `/test-smtp` | admin | Test SMTP connection with provided credentials; success returns `{ message }`, failure returns `{ message, raw }` where `message` is human-readable and `raw` is the original SMTP error. The short-lived admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
|
||||
|
||||
#### Setup — `/api/setup`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/register` | public (one-time) | Step 2 of setup wizard — creates the first admin account and returns a short-lived JWT setup token; blocked once any user exists |
|
||||
| POST | `/` | setup token or admin | Final step of setup wizard — saves initial site settings (SMTP, org name, branding); requires setup token from `/register` |
|
||||
|
||||
#### Uploads — `/api/uploads`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/event-image` | supervisor+ | Upload event cover image (`image` field) |
|
||||
| POST | `/logo` | admin (or no-auth during setup) | Upload site logo (`image` field) |
|
||||
|
||||
#### Sections — `/api/sections`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/` | staff+ | List sections |
|
||||
| POST | `/` | supervisor+ | Create section |
|
||||
| PUT | `/:id` | supervisor+ | Update section |
|
||||
| DELETE | `/:id` | admin | Delete section |
|
||||
|
||||
#### Banner — `/api/banner`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/` | public | Get current site banner |
|
||||
| POST | `/` | supervisor+ | Set site banner |
|
||||
|
||||
#### Forms — `/api/forms`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/responses` | staff+ | List all form responses |
|
||||
|
||||
#### Yoco Transactions — `/api/yoco-transactions`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/` | supervisor+ | All Yoco webhook transactions |
|
||||
| GET | `/unreconciled` | supervisor+ | Unmatched transactions |
|
||||
| POST | `/:id/reconcile` | supervisor+ | Manually reconcile transaction |
|
||||
| POST | `/:id/ignore` | supervisor+ | Mark transaction as ignored |
|
||||
|
||||
#### WhatsApp Admin — `/api/whatsapp`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/webhook` | public | WAWP inbound event |
|
||||
| GET | `/config` | admin | Get WAWP credentials |
|
||||
| POST | `/config` | admin | Save WAWP credentials to DB |
|
||||
| POST | `/create-instance` | admin | Create WAWP instance |
|
||||
| POST | `/delete-instance` | admin | Delete WAWP instance |
|
||||
| GET | `/status` | admin | WAWP session status |
|
||||
| GET | `/qr` | admin | QR code for WhatsApp linking |
|
||||
| POST | `/request-code` | admin | Request pairing code |
|
||||
| POST | `/logout` | admin | Logout WAWP session |
|
||||
| POST | `/start` | admin | Start WAWP instance |
|
||||
| POST | `/restart` | admin | Restart WAWP instance |
|
||||
|
||||
#### Static Files
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/uploads/*` | Served uploaded files (images, PDFs) |
|
||||
|
||||
---
|
||||
|
||||
## Background Workers
|
||||
|
||||
All workers start automatically with `npm start`.
|
||||
|
||||
### Scheduled Emails Worker
|
||||
Polls every 30 s (configurable via `SCHEDULED_EMAILS_INTERVAL_MS`) for due jobs. Handles attendee emails, attendee WhatsApp, email broadcasts, and WhatsApp broadcasts. Disable with `SCHEDULED_EMAILS_ENABLED=false`.
|
||||
|
||||
### Daily Event Summaries
|
||||
Runs at 07:00 local time. Sends summary emails to supervisors about that day's events. Disable with `DAILY_SUMMARY_ENABLED=false`.
|
||||
|
||||
### Temp File Cleanup
|
||||
Runs at 03:00 local time. Removes files older than 7 days from `public/uploads/tickets-temp/`.
|
||||
|
||||
### Attachment Manifest Sync
|
||||
Runs once on startup. Imports on-disk attachment manifests not yet in the database.
|
||||
|
||||
---
|
||||
|
||||
## Notification System
|
||||
|
||||
Outbound notifications are routed through `src/utils/notify.js`:
|
||||
|
||||
- `shouldEmail(user)` — true if preference is `email` or `both`
|
||||
- `canWhatsApp(user)` — true if preference is `whatsapp` or `both` and user has a phone number
|
||||
- `waText(user, message)` — sends a WhatsApp text
|
||||
- `waPdf(user, pdfPath, caption)` — copies PDF to temp, sends URL to WAWP, cleans up after 5 min
|
||||
|
||||
**Automated notifications**: login, account deletion, ticket delivery, payment confirmation, password reset, daily summaries.
|
||||
|
||||
**Dynamic placeholders** in bulk messages: `{{name}}`, `{{event.title}}`, `{{event.start}}`, `{{event.link}}`, `{{balance}}`, `{{promo.title}}`, `{{promo.link}}`.
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp Integration
|
||||
|
||||
WAWP API (`api.wawp.net/v2`). All calls go through `src/utils/whatsapp.js`.
|
||||
|
||||
Credentials are stored in `AppSetting` (`WAWP_ACCESS_TOKEN`, `WAWP_INSTANCE_ID`), with `.env` as fallback. Cached in memory for 30 s.
|
||||
|
||||
"Session not found" recovery: stale session is deleted and a new one is created automatically, saving the new `instance_id` to `AppSetting`.
|
||||
|
||||
Admin UI: `/dashboard/admin/whatsapp`.
|
||||
|
||||
---
|
||||
|
||||
## File Uploads
|
||||
|
||||
- Event images: `POST /api/uploads/event-image` (field `image`), saved to `public/uploads/`
|
||||
- Event attachments: `POST /api/events/:id/attachments` (field `file`)
|
||||
- Ticket PDFs: generated on demand, temporarily in `public/uploads/tickets-temp/`, auto-deleted after 5 min
|
||||
|
||||
Files are served from `/uploads/*`.
|
||||
|
||||
---
|
||||
|
||||
## Pricing & Cancellation Rules
|
||||
|
||||
### Cancellation
|
||||
|
||||
`DELETE /api/registrations/:id`
|
||||
|
||||
| Actor | Condition | Result |
|
||||
|-------|-----------|--------|
|
||||
| Owner (user) | No positive payments recorded | 200 — status set to `cancelled` |
|
||||
| Owner (user) | Any positive payment exists | 403 — must contact organisation |
|
||||
| Admin | Any state | 200 — always allowed |
|
||||
| Anyone | Already cancelled | 400 |
|
||||
|
||||
Cancelled registrations are excluded from all stock-limit counts (option, variant, and tier stock).
|
||||
|
||||
### Early-bird pricing — two-phase evaluation
|
||||
|
||||
Prices are evaluated in two phases:
|
||||
|
||||
**Phase 1 — Registration time** (`resolveOptionPrice` in `src/utils/pricing.js`)
|
||||
- Selects the cheapest applicable tier, checking **both** the deadline **and** the stock limit.
|
||||
- Result stored as `priceSnapshot` and `appliedTierId` on each `RegistrationOption`.
|
||||
- Tier sort order: cheapest first; earliest deadline breaks ties.
|
||||
|
||||
**Phase 2 — Payment time** (`refreshPricingForRegistration` in `src/utils/pricing.js`)
|
||||
- Called automatically before any Yoco checkout is created or any manual payment is recorded.
|
||||
- Re-runs `resolveOptionPrice` for every `RegistrationOption` that has an `appliedTierId`.
|
||||
- If a tier has since expired **or its stock has been exhausted**, `priceSnapshot` and `appliedTierId` are updated in the DB to reflect the next applicable tier or the base price.
|
||||
- If prices changed, `POST /api/payments/yoco-checkout` returns HTTP 200 with `{ priceUpdated: true, newTotal, message }` instead of proceeding to Yoco. The frontend must present the updated total before the user can retry.
|
||||
- For manual payments (`POST /api/payments`) prices are refreshed silently — no blocking response.
|
||||
|
||||
**`computeRegistrationTotalDue`** (also in `src/utils/pricing.js`)
|
||||
- Uses `priceSnapshot` as the authoritative price when it is set on a `RegistrationOption`.
|
||||
- Falls back to `getEffectiveUnitPrice` (deadline-only re-evaluation) for legacy rows without a snapshot.
|
||||
|
||||
### Variant + tier interaction
|
||||
|
||||
Early-bird tiers can be defined at either the **option level** (no `variantId`) or the **variant level** (`variantId` set). Both are stored as `EarlyBirdTier` records on the `EventOption`.
|
||||
|
||||
| Variant state | Effective price |
|
||||
|---------------|-----------------|
|
||||
| No variant selected | Option-level tiers are evaluated via `resolveOptionPrice` |
|
||||
| Variant selected, variant has its own tier(s) | Variant-level tiers are evaluated first via `resolveVariantTierPrice` |
|
||||
| Variant selected, no variant-level tier | Variant's own `price` is used (or option price if `variant.price` is null) |
|
||||
|
||||
The resolution priority at registration/payment time is: **variant tier → variant price → option tier → option base price**.
|
||||
|
||||
---
|
||||
|
||||
## Payments (Yoco)
|
||||
|
||||
1. Frontend calls `POST /api/payments/yoco-checkout`
|
||||
2. Backend calls `refreshPricingForRegistration` — if any early-bird price changed, returns `{ priceUpdated: true, newTotal }` (HTTP 200) and does **not** create a checkout. Frontend shows warning; user retries.
|
||||
3. On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns `{ redirectUrl }`.
|
||||
4. User pays on Yoco's hosted page.
|
||||
5. Yoco sends webhook to `POST /api/webhooks/yoco`.
|
||||
6. Backend verifies HMAC, creates `Payment` + `YocoTransaction`, updates registration status, generates tickets.
|
||||
7. Unmatched transactions visible at `/api/yoco-transactions/unreconciled`.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### PM2 (recommended)
|
||||
```bash
|
||||
npm install -g pm2
|
||||
pm2 start src/index.js --name hope-events-api
|
||||
pm2 save && pm2 startup
|
||||
```
|
||||
|
||||
### Checklist
|
||||
- [ ] `DATABASE_URL` points to production DB
|
||||
- [ ] `JWT_SECRET` is long (32+ chars) and secret
|
||||
- [ ] `FRONTEND_URL` lists only production origins
|
||||
- [ ] `NODE_ENV=production`
|
||||
- [ ] Yoco webhook registered and HTTPS
|
||||
- [ ] WAWP credentials saved via admin panel
|
||||
- [ ] SMTP credentials correct
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"message": "",
|
||||
"type": "info",
|
||||
"liveFrom": null,
|
||||
"liveTill": null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"jobs": []
|
||||
}
|
||||
Generated
+2260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"postinstall": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:deploy": "prisma migrate deploy && prisma generate",
|
||||
"prisma:status": "prisma migrate status",
|
||||
"prisma:reset": "prisma migrate reset --force",
|
||||
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
|
||||
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.5",
|
||||
"pdfkit": "^0.17.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"raw-body": "^3.0.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1",
|
||||
"prisma": "^5.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('admin', 'supervisor', 'staff', 'user');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RegistrationStatus" AS ENUM ('pending', 'confirmed', 'partial_paid', 'paid', 'cancelled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FormFieldType" AS ENUM ('yes_no', 'text', 'date', 'numeric', 'statement', 'paragraph');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"phoneNumber" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'user',
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Event" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3) NOT NULL,
|
||||
"registrationDeadline" TIMESTAMP(3),
|
||||
"goLiveAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"price" DOUBLE PRECISION NOT NULL,
|
||||
"picture" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"redirectUrl" TEXT,
|
||||
|
||||
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"price" DOUBLE PRECISION NOT NULL,
|
||||
"isMainTicket" BOOLEAN NOT NULL,
|
||||
|
||||
CONSTRAINT "EventOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EarlyBirdTier" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"deadline" TIMESTAMP(3) NOT NULL,
|
||||
"price" DOUBLE PRECISION NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "EarlyBirdTier_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Registration" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"status" "RegistrationStatus" NOT NULL DEFAULT 'pending',
|
||||
"checkoutId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Registration_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RegistrationOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
CONSTRAINT "RegistrationOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Payment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"registrationId" TEXT,
|
||||
"eventId" TEXT,
|
||||
"isDonation" BOOLEAN NOT NULL DEFAULT false,
|
||||
"externalId" TEXT,
|
||||
"status" TEXT,
|
||||
"originalPaymentId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Ticket" (
|
||||
"id" TEXT NOT NULL,
|
||||
"qrCode" TEXT NOT NULL,
|
||||
"registrationOptionId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"isUsed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"emailSent" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketUsage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"scannedById" TEXT NOT NULL,
|
||||
"scannedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TicketUsage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PasswordReset" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PasswordReset_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventAttachment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"originalName" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "EventAttachment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventForm" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"isRequired" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EventForm_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventFormField" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"type" "FormFieldType" NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"isRequired" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"helpText" TEXT,
|
||||
"options" JSONB,
|
||||
|
||||
CONSTRAINT "EventFormField_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FormResponse" (
|
||||
"id" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FormResponse_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FormAnswer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"responseId" TEXT NOT NULL,
|
||||
"fieldId" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FormAnswer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FormDraft" (
|
||||
"id" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FormDraft_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "YocoTransaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"amount" INTEGER,
|
||||
"currency" TEXT,
|
||||
"createdDate" TIMESTAMP(3),
|
||||
"checkoutId" TEXT,
|
||||
"methodType" TEXT,
|
||||
"reconciled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ignored" BOOLEAN NOT NULL DEFAULT false,
|
||||
"paymentId" TEXT,
|
||||
"raw" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "YocoTransaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Event_redirectUrl_key" ON "Event"("redirectUrl");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_createdById_idx" ON "Event"("createdById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EarlyBirdTier_eventOptionId_deadline_idx" ON "EarlyBirdTier"("eventOptionId", "deadline");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Registration_checkoutId_key" ON "Registration"("checkoutId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Payment_externalId_key" ON "Payment"("externalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Ticket_qrCode_key" ON "Ticket"("qrCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PasswordReset_token_key" ON "PasswordReset"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EventForm_eventId_key" ON "EventForm"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FormDraft_registrationId_userId_key" ON "FormDraft"("registrationId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "YocoTransaction_externalId_key" ON "YocoTransaction"("externalId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventOption" ADD CONSTRAINT "EventOption_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EarlyBirdTier" ADD CONSTRAINT "EarlyBirdTier_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Registration" ADD CONSTRAINT "Registration_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_originalPaymentId_fkey" FOREIGN KEY ("originalPaymentId") REFERENCES "Payment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_scannedById_fkey" FOREIGN KEY ("scannedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PasswordReset" ADD CONSTRAINT "PasswordReset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventAttachment" ADD CONSTRAINT "EventAttachment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventForm" ADD CONSTRAINT "EventForm_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventFormField" ADD CONSTRAINT "EventFormField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "EventForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormAnswer" ADD CONSTRAINT "FormAnswer_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormAnswer" ADD CONSTRAINT "FormAnswer_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "EventFormField"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormDraft" ADD CONSTRAINT "FormDraft_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormDraft" ADD CONSTRAINT "FormDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "YocoTransaction" ADD CONSTRAINT "YocoTransaction_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Section" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Section_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SectionOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sectionId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "SectionOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Section_eventId_idx" ON "Section"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SectionOption_sectionId_eventOptionId_key" ON "SectionOption"("sectionId", "eventOptionId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Section" ADD CONSTRAINT "Section_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SectionOption" ADD CONSTRAINT "SectionOption_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SectionOption" ADD CONSTRAINT "SectionOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "failedAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "lockUntil" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: add tokenVersion for JWT revocation
|
||||
ALTER TABLE "User" ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add isHidden and requiresAuth to Event
|
||||
ALTER TABLE "Event" ADD COLUMN "isHidden" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Event" ADD COLUMN "requiresAuth" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Add quantity to Ticket (1 ticket per option, not per qty unit)
|
||||
ALTER TABLE "Ticket" ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- Add quantityRedeemed to TicketUsage (for partial redemption)
|
||||
ALTER TABLE "TicketUsage" ADD COLUMN "quantityRedeemed" INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[phoneNumber]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_phoneNumber_key" ON "User"("phoneNumber");
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_eventId_idx" ON "Ticket"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_userId_idx" ON "Ticket"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketUsage_ticketId_idx" ON "TicketUsage"("ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketUsage_scannedAt_idx" ON "TicketUsage"("scannedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketUsage_scannedById_scannedAt_idx" ON "TicketUsage"("scannedById", "scannedAt");
|
||||
@@ -0,0 +1,56 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventAttachment_eventId_idx" ON "EventAttachment"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventFormField_formId_idx" ON "EventFormField"("formId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventOption_eventId_idx" ON "EventOption"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FormAnswer_responseId_idx" ON "FormAnswer"("responseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FormAnswer_fieldId_idx" ON "FormAnswer"("fieldId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FormResponse_registrationId_idx" ON "FormResponse"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PasswordReset_userId_idx" ON "PasswordReset"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_userId_idx" ON "Payment"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_registrationId_idx" ON "Payment"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_eventId_idx" ON "Payment"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_createdAt_idx" ON "Payment"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Registration_userId_idx" ON "Registration"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Registration_eventId_idx" ON "Registration"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Registration_userId_status_idx" ON "Registration"("userId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Registration_eventId_status_idx" ON "Registration"("eventId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationOption_registrationId_idx" ON "RegistrationOption"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationOption_eventOptionId_idx" ON "RegistrationOption"("eventOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_registrationOptionId_idx" ON "Ticket"("registrationOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "YocoTransaction_paymentId_idx" ON "YocoTransaction"("paymentId");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotificationPreference" AS ENUM ('email', 'whatsapp', 'both');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "notificationPreference" "NotificationPreference" NOT NULL DEFAULT 'email';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AppSetting" (
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Add stockLimit to EventOption
|
||||
ALTER TABLE "EventOption" ADD COLUMN "stockLimit" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Add stockLimit to EarlyBirdTier
|
||||
ALTER TABLE "EarlyBirdTier" ADD COLUMN "stockLimit" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable OptionVariant
|
||||
CREATE TABLE "OptionVariant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"price" DOUBLE PRECISION,
|
||||
"stockLimit" INTEGER NOT NULL DEFAULT 0,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "OptionVariant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Add new columns to RegistrationOption
|
||||
ALTER TABLE "RegistrationOption" ADD COLUMN "variantId" TEXT;
|
||||
ALTER TABLE "RegistrationOption" ADD COLUMN "appliedTierId" TEXT;
|
||||
ALTER TABLE "RegistrationOption" ADD COLUMN "priceSnapshot" DOUBLE PRECISION;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OptionVariant_eventOptionId_idx" ON "OptionVariant"("eventOptionId");
|
||||
CREATE INDEX "RegistrationOption_variantId_idx" ON "RegistrationOption"("variantId");
|
||||
CREATE INDEX "RegistrationOption_appliedTierId_idx" ON "RegistrationOption"("appliedTierId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OptionVariant" ADD CONSTRAINT "OptionVariant_eventOptionId_fkey"
|
||||
FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_variantId_fkey"
|
||||
FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_appliedTierId_fkey"
|
||||
FOREIGN KEY ("appliedTierId") REFERENCES "EarlyBirdTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EarlyBirdTier" ADD COLUMN "variantId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EarlyBirdTier_variantId_idx" ON "EarlyBirdTier"("variantId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EarlyBirdTier" ADD CONSTRAINT "EarlyBirdTier_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,213 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Ministry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"image" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"leaderId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Ministry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"bio" TEXT,
|
||||
"photo" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"email" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContentBlock" (
|
||||
"id" TEXT NOT NULL,
|
||||
"page" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL DEFAULT 'text',
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ContentBlock_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChurchDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"fileUrl" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL DEFAULT 'other',
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT true,
|
||||
"uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ChurchDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FellowshipGroup" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"leader" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FellowshipGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChurchMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"mainName" TEXT NOT NULL,
|
||||
"mainEmail" TEXT NOT NULL,
|
||||
"mainPhone" TEXT,
|
||||
"mainBirthday" TIMESTAMP(3),
|
||||
"mainOccupation" TEXT,
|
||||
"mainEmploymentStatus" TEXT,
|
||||
"mainDateOfSalvation" TIMESTAMP(3),
|
||||
"mainDateOfWaterBaptism" TIMESTAMP(3),
|
||||
"mainHolySpiritBaptism" BOOLEAN NOT NULL DEFAULT false,
|
||||
"spouseName" TEXT,
|
||||
"spouseEmail" TEXT,
|
||||
"spousePhone" TEXT,
|
||||
"spouseBirthday" TIMESTAMP(3),
|
||||
"spouseOccupation" TEXT,
|
||||
"spouseEmploymentStatus" TEXT,
|
||||
"spouseDateOfSalvation" TIMESTAMP(3),
|
||||
"spouseDateOfWaterBaptism" TIMESTAMP(3),
|
||||
"spouseHolySpiritBaptism" BOOLEAN,
|
||||
"homeAddress" TEXT,
|
||||
"inFellowshipGroup" BOOLEAN NOT NULL DEFAULT false,
|
||||
"fellowshipGroupId" TEXT,
|
||||
"membershipConfirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"notes" TEXT,
|
||||
"appliedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"approvedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ChurchMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChurchChild" (
|
||||
"id" TEXT NOT NULL,
|
||||
"memberId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"birthday" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ChurchChild_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MinistryInterest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"memberId" TEXT NOT NULL,
|
||||
"ministryId" TEXT NOT NULL,
|
||||
"forSpouse" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "MinistryInterest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Donation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"donorName" TEXT,
|
||||
"donorEmail" TEXT,
|
||||
"donorPhone" TEXT,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"reference" TEXT,
|
||||
"note" TEXT,
|
||||
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||
"linkedMemberId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Donation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Ministry_slug_key" ON "Ministry"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ministry_isActive_order_idx" ON "Ministry"("isActive", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ministry_leaderId_idx" ON "Ministry"("leaderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_isActive_order_idx" ON "TeamMember"("isActive", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContentBlock_page_idx" ON "ContentBlock"("page");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ContentBlock_page_key_key" ON "ContentBlock"("page", "key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FellowshipGroup_isActive_order_idx" ON "FellowshipGroup"("isActive", "order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChurchMember_status_idx" ON "ChurchMember"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChurchMember_mainEmail_idx" ON "ChurchMember"("mainEmail");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChurchMember_fellowshipGroupId_idx" ON "ChurchMember"("fellowshipGroupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChurchChild_memberId_idx" ON "ChurchChild"("memberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MinistryInterest_memberId_idx" ON "MinistryInterest"("memberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MinistryInterest_ministryId_idx" ON "MinistryInterest"("ministryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MinistryInterest_memberId_ministryId_forSpouse_key" ON "MinistryInterest"("memberId", "ministryId", "forSpouse");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_linkedMemberId_idx" ON "Donation"("linkedMemberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_createdAt_idx" ON "Donation"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Donation_method_idx" ON "Donation"("method");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ministry" ADD CONSTRAINT "Ministry_leaderId_fkey" FOREIGN KEY ("leaderId") REFERENCES "TeamMember"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChurchMember" ADD CONSTRAINT "ChurchMember_fellowshipGroupId_fkey" FOREIGN KEY ("fellowshipGroupId") REFERENCES "FellowshipGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChurchChild" ADD CONSTRAINT "ChurchChild_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "ChurchMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MinistryInterest" ADD CONSTRAINT "MinistryInterest_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "ChurchMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MinistryInterest" ADD CONSTRAINT "MinistryInterest_ministryId_fkey" FOREIGN KEY ("ministryId") REFERENCES "Ministry"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Donation" ADD CONSTRAINT "Donation_linkedMemberId_fkey" FOREIGN KEY ("linkedMemberId") REFERENCES "ChurchMember"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `ChurchChild` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `ChurchDocument` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `ChurchMember` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `ContentBlock` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Donation` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `FellowshipGroup` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Ministry` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `MinistryInterest` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `TeamMember` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CashupStatus" AS ENUM ('open', 'closed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CashupAction" AS ENUM ('closed', 'quick_closed', 'reopened');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EventCostType" AS ENUM ('once_off', 'per_item');
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ChurchChild" DROP CONSTRAINT "ChurchChild_memberId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ChurchMember" DROP CONSTRAINT "ChurchMember_fellowshipGroupId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Donation" DROP CONSTRAINT "Donation_linkedMemberId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Ministry" DROP CONSTRAINT "Ministry_leaderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MinistryInterest" DROP CONSTRAINT "MinistryInterest_memberId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MinistryInterest" DROP CONSTRAINT "MinistryInterest_ministryId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "cashupDraft" JSONB,
|
||||
ADD COLUMN "cashupStatus" "CashupStatus" NOT NULL DEFAULT 'open',
|
||||
ADD COLUMN "closedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "closedById" TEXT,
|
||||
ADD COLUMN "reopenedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "reopenedById" TEXT;
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "ChurchChild";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "ChurchDocument";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "ChurchMember";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "ContentBlock";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Donation";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "FellowshipGroup";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Ministry";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "MinistryInterest";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "TeamMember";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCost" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT,
|
||||
"label" TEXT NOT NULL,
|
||||
"costType" "EventCostType" NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EventCost_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCashup" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"action" "CashupAction" NOT NULL,
|
||||
"donationsWrittenOff" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"totalCosts" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"totalExpectedRevenue" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"totalActualRevenue" DOUBLE PRECISION,
|
||||
"notes" TEXT,
|
||||
"performedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "EventCashup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCashupLine" (
|
||||
"id" TEXT NOT NULL,
|
||||
"cashupId" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"expectedAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"actualAmount" DOUBLE PRECISION,
|
||||
"variance" DOUBLE PRECISION,
|
||||
"notes" TEXT,
|
||||
|
||||
CONSTRAINT "EventCashupLine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCost_eventId_idx" ON "EventCost"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCost_eventOptionId_idx" ON "EventCost"("eventOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashup_eventId_createdAt_idx" ON "EventCashup"("eventId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupLine_cashupId_idx" ON "EventCashupLine"("cashupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_closedById_idx" ON "Event"("closedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Event_reopenedById_idx" ON "Event"("reopenedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_reopenedById_fkey" FOREIGN KEY ("reopenedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCost" ADD CONSTRAINT "EventCost_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCost" ADD CONSTRAINT "EventCost_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashup" ADD CONSTRAINT "EventCashup_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashup" ADD CONSTRAINT "EventCashup_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupLine" ADD CONSTRAINT "EventCashupLine_cashupId_fkey" FOREIGN KEY ("cashupId") REFERENCES "EventCashup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-- AlterTable: rename donationsWrittenOff -> unallocatedDonationsTotal, preserving existing values
|
||||
ALTER TABLE "EventCashup" RENAME COLUMN "donationsWrittenOff" TO "unallocatedDonationsTotal";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventCost" ADD COLUMN "paidFromMethod" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCashupDenomination" (
|
||||
"id" TEXT NOT NULL,
|
||||
"lineId" TEXT NOT NULL,
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"count" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "EventCashupDenomination_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupDenomination_lineId_idx" ON "EventCashupDenomination"("lineId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupDenomination" ADD CONSTRAINT "EventCashupDenomination_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "_EventNotifyRecipients" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_EventNotifyRecipients_AB_unique" ON "_EventNotifyRecipients"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_EventNotifyRecipients_B_index" ON "_EventNotifyRecipients"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_EventNotifyRecipients" ADD CONSTRAINT "_EventNotifyRecipients_A_fkey" FOREIGN KEY ("A") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_EventNotifyRecipients" ADD CONSTRAINT "_EventNotifyRecipients_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,636 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
admin
|
||||
supervisor
|
||||
staff
|
||||
user
|
||||
}
|
||||
|
||||
enum NotificationPreference {
|
||||
email
|
||||
whatsapp
|
||||
both
|
||||
}
|
||||
|
||||
enum RegistrationStatus {
|
||||
pending
|
||||
confirmed
|
||||
partial_paid
|
||||
paid
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum CashupStatus {
|
||||
open
|
||||
closed
|
||||
}
|
||||
|
||||
enum CashupAction {
|
||||
closed
|
||||
quick_closed
|
||||
reopened
|
||||
}
|
||||
|
||||
enum EventCostType {
|
||||
once_off
|
||||
per_item
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
password String
|
||||
createdAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
phoneNumber String? @unique
|
||||
updatedAt DateTime @updatedAt
|
||||
role UserRole @default(user)
|
||||
failedAttempts Int @default(0)
|
||||
lockUntil DateTime?
|
||||
tokenVersion Int @default(0)
|
||||
notificationPreference NotificationPreference @default(email)
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
ticketScans TicketUsage[]
|
||||
passwordResets PasswordReset[]
|
||||
|
||||
Event Event[]
|
||||
FormDraft FormDraft[]
|
||||
|
||||
eventsClosed Event[] @relation("EventClosedBy")
|
||||
eventsReopened Event[] @relation("EventReopenedBy")
|
||||
cashupsPerformed EventCashup[]
|
||||
notifyForEvents Event[] @relation("EventNotifyRecipients")
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
registrationDeadline DateTime?
|
||||
goLiveAt DateTime @default(now())
|
||||
price Float
|
||||
picture String?
|
||||
isActive Boolean @default(true)
|
||||
isHidden Boolean @default(false)
|
||||
requiresAuth Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
redirectUrl String? @unique
|
||||
eventOptions EventOption[]
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
attachments EventAttachment[]
|
||||
form EventForm?
|
||||
Section Section[]
|
||||
|
||||
cashupStatus CashupStatus @default(open)
|
||||
cashupDraft Json?
|
||||
closedAt DateTime?
|
||||
closedById String?
|
||||
closedBy User? @relation("EventClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
|
||||
reopenedAt DateTime?
|
||||
reopenedById String?
|
||||
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
|
||||
costs EventCost[]
|
||||
cashups EventCashup[]
|
||||
|
||||
// Users who should receive registration/payment/daily-summary notifications for this
|
||||
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
|
||||
notifyRecipients User[] @relation("EventNotifyRecipients")
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@index([reopenedById])
|
||||
}
|
||||
|
||||
model EventOption {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
name String
|
||||
price Float
|
||||
isMainTicket Boolean
|
||||
stockLimit Int @default(0)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
registrationOptions RegistrationOption[]
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
SectionOption SectionOption[]
|
||||
variants OptionVariant[]
|
||||
costs EventCost[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model EarlyBirdTier {
|
||||
id String @id @default(uuid())
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
deadline DateTime
|
||||
price Float
|
||||
order Int @default(0)
|
||||
stockLimit Int @default(0)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
registrationOptions RegistrationOption[]
|
||||
|
||||
@@index([eventOptionId, deadline])
|
||||
@@index([variantId])
|
||||
}
|
||||
|
||||
model OptionVariant {
|
||||
id String @id @default(uuid())
|
||||
eventOptionId String
|
||||
name String
|
||||
price Float?
|
||||
stockLimit Int @default(0)
|
||||
order Int @default(0)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
registrationOptions RegistrationOption[]
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
|
||||
model Registration {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
eventId String
|
||||
status RegistrationStatus @default(pending)
|
||||
checkoutId String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
registrationOptions RegistrationOption[]
|
||||
payments Payment[]
|
||||
formResponses FormResponse[]
|
||||
formDrafts FormDraft[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([eventId])
|
||||
@@index([userId, status])
|
||||
@@index([eventId, status])
|
||||
}
|
||||
|
||||
model RegistrationOption {
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
eventOptionId String
|
||||
quantity Int @default(1)
|
||||
variantId String?
|
||||
appliedTierId String?
|
||||
priceSnapshot Float?
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
|
||||
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
|
||||
tickets Ticket[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([eventOptionId])
|
||||
@@index([variantId])
|
||||
@@index([appliedTierId])
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
amount Float
|
||||
method String
|
||||
userId String
|
||||
registrationId String?
|
||||
eventId String?
|
||||
isDonation Boolean @default(false)
|
||||
externalId String? @unique
|
||||
status String?
|
||||
originalPaymentId String?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
|
||||
splitPayments Payment[] @relation("SplitPayments")
|
||||
YocoTransaction YocoTransaction[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
@@index([eventId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(uuid())
|
||||
qrCode String @unique
|
||||
registrationOptionId String
|
||||
userId String
|
||||
eventId String
|
||||
quantity Int @default(1)
|
||||
isUsed Boolean @default(false)
|
||||
emailSent Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Restrict)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
usages TicketUsage[]
|
||||
|
||||
@@index([eventId])
|
||||
@@index([userId])
|
||||
@@index([registrationOptionId])
|
||||
}
|
||||
|
||||
model TicketUsage {
|
||||
id String @id @default(uuid())
|
||||
ticketId String
|
||||
scannedById String
|
||||
scannedAt DateTime @default(now())
|
||||
quantityRedeemed Int @default(1)
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
|
||||
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([ticketId])
|
||||
@@index([scannedAt])
|
||||
@@index([scannedById, scannedAt])
|
||||
}
|
||||
|
||||
model PasswordReset {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
used Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model EventAttachment {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
originalName String
|
||||
filename String
|
||||
mimeType String
|
||||
size Int
|
||||
url String
|
||||
createdAt DateTime @default(now())
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
// Event registration forms
|
||||
enum FormFieldType {
|
||||
yes_no
|
||||
text
|
||||
date
|
||||
numeric
|
||||
statement
|
||||
paragraph
|
||||
}
|
||||
|
||||
model EventForm {
|
||||
id String @id @default(uuid())
|
||||
eventId String @unique
|
||||
isRequired Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
fields EventFormField[]
|
||||
}
|
||||
|
||||
model EventFormField {
|
||||
id String @id @default(uuid())
|
||||
formId String
|
||||
type FormFieldType
|
||||
label String
|
||||
isRequired Boolean @default(false)
|
||||
order Int @default(0)
|
||||
helpText String?
|
||||
options Json?
|
||||
form EventForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
answers FormAnswer[]
|
||||
|
||||
@@index([formId])
|
||||
}
|
||||
|
||||
model FormResponse {
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
createdAt DateTime @default(now())
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
answers FormAnswer[]
|
||||
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
model FormAnswer {
|
||||
id String @id @default(uuid())
|
||||
responseId String
|
||||
fieldId String
|
||||
value String
|
||||
response FormResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)
|
||||
field EventFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([responseId])
|
||||
@@index([fieldId])
|
||||
}
|
||||
|
||||
// Draft storage for in-progress attendee forms
|
||||
model FormDraft {
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
userId String
|
||||
data Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([registrationId, userId])
|
||||
}
|
||||
|
||||
// Stores all webhook events from Yoco. If a Payment is created (registration or donation),
|
||||
// the record is marked reconciled and linked via paymentId. Otherwise, remains unreconciled.
|
||||
model YocoTransaction {
|
||||
id String @id @default(uuid())
|
||||
externalId String @unique // Yoco payment/event id
|
||||
amount Int?
|
||||
currency String?
|
||||
createdDate DateTime?
|
||||
checkoutId String?
|
||||
methodType String? // paymentMethodDetails.type (e.g., card)
|
||||
reconciled Boolean @default(false)
|
||||
ignored Boolean @default(false)
|
||||
paymentId String?
|
||||
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: SetNull)
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([paymentId])
|
||||
}
|
||||
|
||||
model Section {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
allowedOptions SectionOption[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model SectionOption {
|
||||
id String @id @default(uuid())
|
||||
sectionId String
|
||||
eventOptionId String
|
||||
|
||||
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sectionId, eventOptionId])
|
||||
}
|
||||
|
||||
model AppSetting {
|
||||
key String @id
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model EventCost {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
eventOptionId String?
|
||||
eventOption EventOption? @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
costType EventCostType
|
||||
amount Float
|
||||
paidFromMethod String? // 'cash' | 'card' | 'eft' | 'other' | null — null = not paid from event takings
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([eventId])
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
|
||||
// Append-only audit trail: one row per close / quick-close / reopen. Never updated or deleted.
|
||||
model EventCashup {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
action CashupAction
|
||||
unallocatedDonationsTotal Float @default(0)
|
||||
totalCosts Float @default(0)
|
||||
totalExpectedRevenue Float @default(0)
|
||||
totalActualRevenue Float?
|
||||
notes String?
|
||||
performedById String?
|
||||
performedBy User? @relation(fields: [performedById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
lines EventCashupLine[]
|
||||
|
||||
@@index([eventId, createdAt])
|
||||
}
|
||||
|
||||
model EventCashupLine {
|
||||
id String @id @default(uuid())
|
||||
cashupId String
|
||||
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
|
||||
method String
|
||||
expectedAmount Float @default(0)
|
||||
actualAmount Float?
|
||||
variance Float?
|
||||
notes String?
|
||||
denominations EventCashupDenomination[]
|
||||
|
||||
@@index([cashupId])
|
||||
}
|
||||
|
||||
model EventCashupDenomination {
|
||||
id String @id @default(uuid())
|
||||
lineId String
|
||||
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
|
||||
value Float
|
||||
count Int
|
||||
|
||||
@@index([lineId])
|
||||
}
|
||||
|
||||
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
|
||||
//
|
||||
// model Ministry {
|
||||
// id String @id @default(uuid())
|
||||
// slug String @unique
|
||||
// title String
|
||||
// description String?
|
||||
// image String?
|
||||
// isActive Boolean @default(true)
|
||||
// order Int @default(0)
|
||||
// leaderId String?
|
||||
// leader TeamMember? @relation(fields: [leaderId], references: [id], onDelete: SetNull)
|
||||
// interests MinistryInterest[]
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// @@index([leaderId])
|
||||
// }
|
||||
//
|
||||
// model TeamMember {
|
||||
// id String @id @default(uuid())
|
||||
// name String
|
||||
// title String
|
||||
// bio String?
|
||||
// photo String?
|
||||
// order Int @default(0)
|
||||
// isActive Boolean @default(true)
|
||||
// email String?
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// ministries Ministry[]
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// }
|
||||
//
|
||||
// model ContentBlock {
|
||||
// id String @id @default(uuid())
|
||||
// page String
|
||||
// key String
|
||||
// value String
|
||||
// type String @default("text")
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@unique([page, key])
|
||||
// @@index([page])
|
||||
// }
|
||||
//
|
||||
// model ChurchDocument {
|
||||
// id String @id @default(uuid())
|
||||
// title String
|
||||
// description String?
|
||||
// fileUrl String
|
||||
// category String @default("other")
|
||||
// isPublic Boolean @default(true)
|
||||
// uploadedAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// }
|
||||
//
|
||||
// model FellowshipGroup {
|
||||
// id String @id @default(uuid())
|
||||
// name String
|
||||
// description String?
|
||||
// leader String?
|
||||
// isActive Boolean @default(true)
|
||||
// order Int @default(0)
|
||||
// members ChurchMember[]
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// }
|
||||
//
|
||||
// model ChurchMember {
|
||||
// id String @id @default(uuid())
|
||||
// status String @default("pending")
|
||||
// mainName String
|
||||
// mainEmail String
|
||||
// mainPhone String?
|
||||
// mainBirthday DateTime?
|
||||
// mainOccupation String?
|
||||
// mainEmploymentStatus String?
|
||||
// mainDateOfSalvation DateTime?
|
||||
// mainDateOfWaterBaptism DateTime?
|
||||
// mainHolySpiritBaptism Boolean @default(false)
|
||||
// spouseName String?
|
||||
// spouseEmail String?
|
||||
// spousePhone String?
|
||||
// spouseBirthday DateTime?
|
||||
// spouseOccupation String?
|
||||
// spouseEmploymentStatus String?
|
||||
// spouseDateOfSalvation DateTime?
|
||||
// spouseDateOfWaterBaptism DateTime?
|
||||
// spouseHolySpiritBaptism Boolean?
|
||||
// homeAddress String?
|
||||
// inFellowshipGroup Boolean @default(false)
|
||||
// fellowshipGroupId String?
|
||||
// fellowshipGroup FellowshipGroup? @relation(fields: [fellowshipGroupId], references: [id], onDelete: SetNull)
|
||||
// membershipConfirmed Boolean @default(false)
|
||||
// notes String?
|
||||
// appliedAt DateTime @default(now())
|
||||
// approvedAt DateTime?
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// children ChurchChild[]
|
||||
// ministryInterests MinistryInterest[]
|
||||
// donations Donation[]
|
||||
//
|
||||
// @@index([status])
|
||||
// @@index([mainEmail])
|
||||
// @@index([fellowshipGroupId])
|
||||
// }
|
||||
//
|
||||
// model ChurchChild {
|
||||
// id String @id @default(uuid())
|
||||
// memberId String
|
||||
// name String
|
||||
// birthday DateTime?
|
||||
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
//
|
||||
// @@index([memberId])
|
||||
// }
|
||||
//
|
||||
// model MinistryInterest {
|
||||
// id String @id @default(uuid())
|
||||
// memberId String
|
||||
// ministryId String
|
||||
// forSpouse Boolean @default(false)
|
||||
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
// ministry Ministry @relation(fields: [ministryId], references: [id], onDelete: Cascade)
|
||||
//
|
||||
// @@unique([memberId, ministryId, forSpouse])
|
||||
// @@index([memberId])
|
||||
// @@index([ministryId])
|
||||
// }
|
||||
//
|
||||
// model Donation {
|
||||
// id String @id @default(uuid())
|
||||
// donorName String?
|
||||
// donorEmail String?
|
||||
// donorPhone String?
|
||||
// amount Float
|
||||
// method String
|
||||
// reference String?
|
||||
// note String?
|
||||
// isAnonymous Boolean @default(false)
|
||||
// linkedMemberId String?
|
||||
// linkedMember ChurchMember? @relation(fields: [linkedMemberId], references: [id], onDelete: SetNull)
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([linkedMemberId])
|
||||
// @@index([createdAt])
|
||||
// @@index([method])
|
||||
// }
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* migrate-env-to-db.js
|
||||
*
|
||||
* Reads settings from environment variables (.env) and writes them into the
|
||||
* AppSetting table — only for keys that are NOT already set in the database.
|
||||
*
|
||||
* Safe to run on a live site: it never overwrites an existing DB value.
|
||||
* Sensitive keys (smtp_user, smtp_pass) are AES-256-GCM encrypted before storage.
|
||||
*
|
||||
* Usage:
|
||||
* cd backend
|
||||
* node scripts/migrate-env-to-db.js
|
||||
*
|
||||
* Add --dry-run to preview what would be written without touching the DB.
|
||||
* Add --force to overwrite existing DB values (use with caution on live data).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
// Load .env from the backend root
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
const FORCE = process.argv.includes('--force');
|
||||
|
||||
// ─── Encryption (mirrors backend/src/utils/encryption.js) ────────────────────
|
||||
|
||||
const SENTINEL = 'enc:';
|
||||
|
||||
const ENCRYPTION_KEY = (() => {
|
||||
const secret = process.env.JWT_SECRET || 'default-unsafe-key-CHANGE-IN-PRODUCTION';
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.warn('[warn] JWT_SECRET is not set — encrypted values will use an insecure fallback key.');
|
||||
}
|
||||
return crypto.scryptSync(secret, 'hope-events-settings-aes256gcm-v1', 32);
|
||||
})();
|
||||
|
||||
function encrypt(plaintext) {
|
||||
if (!plaintext) return plaintext;
|
||||
if (plaintext.startsWith(SENTINEL)) return plaintext;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
|
||||
}
|
||||
|
||||
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
|
||||
|
||||
// ─── Mapping: env var(s) → DB key ────────────────────────────────────────────
|
||||
// Each entry: { key: DB key, envVars: [...candidates in priority order], encrypt?: bool }
|
||||
// The first non-empty env var found wins.
|
||||
|
||||
const MAPPINGS = [
|
||||
// Organisation
|
||||
{ key: 'org_name', envVars: ['ORG_NAME'] },
|
||||
{ key: 'org_tagline', envVars: ['ORG_TAGLINE'] },
|
||||
{ key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] },
|
||||
|
||||
// Branding
|
||||
{ key: 'accent_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
|
||||
|
||||
// Notifications
|
||||
{ key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] },
|
||||
|
||||
// SMTP
|
||||
{ key: 'smtp_host', envVars: ['SMTP_HOST', 'EMAIL_HOST'] },
|
||||
{ key: 'smtp_port', envVars: ['SMTP_PORT', 'EMAIL_PORT'] },
|
||||
{ key: 'smtp_secure', envVars: ['SMTP_SECURE', 'EMAIL_SECURE'] },
|
||||
{ key: 'smtp_from', envVars: ['MAIL_FROM', 'EMAIL_FROM'] },
|
||||
{ key: 'smtp_user', envVars: ['SMTP_USER', 'EMAIL_USER'], encrypt: true },
|
||||
{ key: 'smtp_pass', envVars: ['SMTP_PASS', 'EMAIL_PASS'], encrypt: true },
|
||||
|
||||
// App URLs (used in email templates)
|
||||
{ key: 'app_base_url', envVars: ['FRONTEND_URL', 'APP_BASE_URL'] },
|
||||
];
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(' Hope Events — .env → DB settings migration');
|
||||
if (DRY_RUN) console.log(' MODE: DRY RUN (no changes will be made)');
|
||||
if (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)');
|
||||
console.log(`${'─'.repeat(60)}\n`);
|
||||
|
||||
try {
|
||||
// Fetch all existing DB settings once
|
||||
const existing = await prisma.appSetting.findMany();
|
||||
const dbMap = new Map(existing.map(r => [r.key, r.value]));
|
||||
|
||||
console.log(`Found ${dbMap.size} existing setting(s) in the database.\n`);
|
||||
|
||||
const toWrite = [];
|
||||
const skipped = [];
|
||||
const noEnvVal = [];
|
||||
|
||||
for (const { key, envVars, encrypt: shouldEncrypt } of MAPPINGS) {
|
||||
// Find first non-empty env var value
|
||||
let rawValue = '';
|
||||
let sourceVar = '';
|
||||
for (const v of envVars) {
|
||||
if (process.env[v] && process.env[v].trim()) {
|
||||
rawValue = process.env[v].trim();
|
||||
sourceVar = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawValue) {
|
||||
noEnvVal.push({ key, envVars });
|
||||
continue;
|
||||
}
|
||||
|
||||
const dbHasValue = dbMap.has(key) && dbMap.get(key) !== '';
|
||||
|
||||
if (dbHasValue && !FORCE) {
|
||||
skipped.push({ key, reason: 'already set in DB (use --force to overwrite)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const storedValue = (shouldEncrypt || ENCRYPTED_KEYS.has(key))
|
||||
? encrypt(rawValue)
|
||||
: rawValue;
|
||||
|
||||
toWrite.push({ key, rawValue, storedValue, sourceVar, encrypted: !!(shouldEncrypt || ENCRYPTED_KEYS.has(key)), overwriting: dbHasValue });
|
||||
}
|
||||
|
||||
// ── Report what will be written ───────────────────────────────────────
|
||||
if (toWrite.length === 0) {
|
||||
console.log('Nothing to write.\n');
|
||||
} else {
|
||||
console.log(`Will write ${toWrite.length} setting(s):\n`);
|
||||
for (const { key, rawValue, sourceVar, encrypted, overwriting } of toWrite) {
|
||||
const display = encrypted ? '(encrypted)' : `"${rawValue}"`;
|
||||
const flag = overwriting ? ' [OVERWRITING]' : '';
|
||||
console.log(` ✓ ${key.padEnd(28)} ← ${sourceVar} = ${display}${flag}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
console.log(`Skipped ${skipped.length} setting(s) (already in DB):\n`);
|
||||
for (const { key, reason } of skipped) {
|
||||
console.log(` · ${key.padEnd(28)} ${reason}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (noEnvVal.length > 0) {
|
||||
console.log(`No env value found for ${noEnvVal.length} setting(s) (nothing to migrate):\n`);
|
||||
for (const { key, envVars: vars } of noEnvVal) {
|
||||
console.log(` - ${key.padEnd(28)} checked: ${vars.join(', ')}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ── Write ─────────────────────────────────────────────────────────────
|
||||
if (DRY_RUN) {
|
||||
console.log('Dry run complete — no changes made.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (toWrite.length === 0) {
|
||||
console.log('Nothing to do — all settings are already configured in the DB.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const ops = toWrite.map(({ key, storedValue }) =>
|
||||
prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value: storedValue },
|
||||
create: { key, value: storedValue },
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.$transaction(ops);
|
||||
|
||||
console.log(`\n✓ Successfully wrote ${toWrite.length} setting(s) to the database.\n`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('\n[error]', err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Seed script: inserts 75 demo users (mix of roles, ~25 inactive)
|
||||
* Run: node scripts/seed-users.js
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const firstNames = [
|
||||
'Liam', 'Olivia', 'Noah', 'Emma', 'James', 'Ava', 'Elijah', 'Sophia',
|
||||
'Lucas', 'Isabella', 'Mason', 'Mia', 'Logan', 'Charlotte', 'Ethan',
|
||||
'Amelia', 'Aiden', 'Harper', 'Jackson', 'Evelyn', 'Thabo', 'Zanele',
|
||||
'Sipho', 'Nomsa', 'Lebo', 'Thandeka', 'Kagiso', 'Palesa', 'Bongani',
|
||||
'Ayanda', 'Pieter', 'Anri', 'Francois', 'Elizma', 'Hendrik', 'Marius',
|
||||
'Chloé', 'Dylan', 'Amber', 'Caleb', 'Zoe', 'Hunter', 'Lily', 'Owen',
|
||||
'Grace', 'Ryan', 'Chloe', 'Nathan', 'Stella', 'Connor', 'Hazel',
|
||||
'Miles', 'Violet', 'Eli', 'Aurora', 'Isaiah', 'Luna', 'Nolan',
|
||||
'Scarlett', 'Wyatt', 'Penelope', 'Sebastian', 'Layla', 'Asher',
|
||||
'Riley', 'Oliver', 'Zoey', 'Ezra', 'Nora', 'Micah', 'Hannah',
|
||||
'Jonah', 'Leah', 'Aaron', 'Aubrey', 'Charles',
|
||||
];
|
||||
|
||||
const lastNames = [
|
||||
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller',
|
||||
'Davis', 'Wilson', 'Moore', 'Taylor', 'Anderson', 'Thomas', 'Jackson',
|
||||
'White', 'Harris', 'Martin', 'Thompson', 'Robinson', 'Lewis',
|
||||
'Dlamini', 'Nkosi', 'Mokoena', 'Ndlovu', 'Mthembu', 'Zulu', 'Nxumalo',
|
||||
'du Plessis', 'van der Berg', 'Botha', 'Steyn', 'Fourie', 'Pretorius',
|
||||
'Venter', 'Coetzee', 'van Niekerk', 'Lombard', 'Janse van Rensburg',
|
||||
'Walker', 'Hall', 'Allen', 'Young', 'Hernandez', 'King', 'Wright',
|
||||
'Scott', 'Green', 'Baker', 'Adams', 'Nelson', 'Carter', 'Mitchell',
|
||||
'Perez', 'Roberts', 'Turner', 'Phillips', 'Campbell', 'Parker', 'Evans',
|
||||
'Edwards', 'Collins', 'Stewart', 'Morris', 'Reed', 'Cook', 'Morgan',
|
||||
];
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function randomPhone() {
|
||||
// SA mobile format: 07x/08x xxxxxxx
|
||||
const prefixes = ['071', '072', '073', '074', '076', '078', '079', '081', '082', '083', '084'];
|
||||
return `${pick(prefixes)}${String(Math.floor(Math.random() * 9000000) + 1000000)}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('Demo@1234', 10);
|
||||
|
||||
// Role distribution: ~55 user, ~15 staff, ~5 supervisor
|
||||
const rolePool = [
|
||||
...Array(55).fill('user'),
|
||||
...Array(15).fill('staff'),
|
||||
...Array(5).fill('supervisor'),
|
||||
];
|
||||
|
||||
const notifPool = ['email', 'email', 'email', 'whatsapp', 'both'];
|
||||
|
||||
const usedEmails = new Set();
|
||||
const usedPhones = new Set();
|
||||
const users = [];
|
||||
|
||||
// Build 75 user records
|
||||
while (users.length < 75) {
|
||||
const first = pick(firstNames);
|
||||
const last = pick(lastNames);
|
||||
const tag = Math.floor(Math.random() * 999) + 1;
|
||||
const email = `${first.toLowerCase().replace(/ /g, '')}.${last.toLowerCase().replace(/ /g, '').replace(/'/g, '')}${tag}@demo.church`;
|
||||
|
||||
if (usedEmails.has(email)) continue;
|
||||
usedEmails.add(email);
|
||||
|
||||
// ~60% of users have a phone number
|
||||
let phone = null;
|
||||
if (Math.random() < 0.6) {
|
||||
let attempt = randomPhone();
|
||||
let tries = 0;
|
||||
while (usedPhones.has(attempt) && tries < 20) {
|
||||
attempt = randomPhone();
|
||||
tries++;
|
||||
}
|
||||
if (!usedPhones.has(attempt)) {
|
||||
usedPhones.add(attempt);
|
||||
phone = attempt;
|
||||
}
|
||||
}
|
||||
|
||||
users.push({
|
||||
name: `${first} ${last}`,
|
||||
email,
|
||||
password: passwordHash,
|
||||
role: pick(rolePool),
|
||||
isActive: true, // will override 25 below
|
||||
phoneNumber: phone,
|
||||
notificationPreference: pick(notifPool),
|
||||
});
|
||||
}
|
||||
|
||||
// Mark exactly 25 as inactive (spread across the list)
|
||||
const inactiveIndexes = new Set();
|
||||
while (inactiveIndexes.size < 25) {
|
||||
inactiveIndexes.add(Math.floor(Math.random() * 75));
|
||||
}
|
||||
inactiveIndexes.forEach(i => { users[i].isActive = false; });
|
||||
|
||||
console.log(`Inserting ${users.length} users…`);
|
||||
console.log(` Active: ${users.filter(u => u.isActive).length}`);
|
||||
console.log(` Inactive: ${users.filter(u => !u.isActive).length}`);
|
||||
console.log(` Roles: user=${users.filter(u => u.role === 'user').length}, staff=${users.filter(u => u.role === 'staff').length}, supervisor=${users.filter(u => u.role === 'supervisor').length}`);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
await prisma.user.create({ data: user });
|
||||
created++;
|
||||
} catch (err) {
|
||||
if (err.code === 'P2002') {
|
||||
skipped++;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Done. Created: ${created}, Skipped (duplicate): ${skipped}`);
|
||||
console.log('Default password for all demo users: Demo@1234');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(err => { console.error(err); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,25 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const generateToken = (userId, role, tokenVersion = 0) => {
|
||||
return jwt.sign(
|
||||
{ id: userId, role, tokenVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
};
|
||||
|
||||
const hashPassword = async (password) => {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return await bcrypt.hash(password, salt);
|
||||
};
|
||||
|
||||
const comparePassword = async (enteredPassword, storedPassword) => {
|
||||
return await bcrypt.compare(enteredPassword, storedPassword);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
generateToken,
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
@@ -0,0 +1,52 @@
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// @desc Schedule multiple automation emails for an event
|
||||
// @route POST /api/automations/schedule
|
||||
// @access Private/Supervisor or Admin
|
||||
async function scheduleAutomations(req, res) {
|
||||
try {
|
||||
const { eventId, jobs } = req.body || {};
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
return res.status(400).json({ message: 'eventId is required' });
|
||||
}
|
||||
if (!Array.isArray(jobs) || jobs.length === 0) {
|
||||
return res.status(400).json({ message: 'jobs must be a non-empty array' });
|
||||
}
|
||||
|
||||
const created = [];
|
||||
for (const j of jobs) {
|
||||
if (!j || !j.scheduledAt || !j.subject || !(j.html || j.text)) continue;
|
||||
const when = new Date(j.scheduledAt);
|
||||
if (isNaN(when.getTime())) continue;
|
||||
const payload = {
|
||||
subject: String(j.subject),
|
||||
html: j.html ? String(j.html) : undefined,
|
||||
text: (!j.html && j.text) ? String(j.text) : (j.text ? String(j.text) : undefined),
|
||||
template: 'custom',
|
||||
filter: { status: undefined, attendeeIds: undefined },
|
||||
};
|
||||
if (j.promoEventId && typeof j.promoEventId === 'string') {
|
||||
payload.promoEventId = j.promoEventId;
|
||||
}
|
||||
const rec = addJob({
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
createdById: req.user?.id || null,
|
||||
scheduledAt: when.toISOString(),
|
||||
payload,
|
||||
});
|
||||
created.push(rec);
|
||||
}
|
||||
|
||||
if (created.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid jobs to schedule' });
|
||||
}
|
||||
|
||||
return res.status(201).json({ message: `Scheduled ${created.length} automation job(s)`, jobs: created });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { scheduleAutomations };
|
||||
@@ -0,0 +1,44 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BANNER_FILE = path.join(__dirname, '../../data/banner.json');
|
||||
|
||||
function readBanner() {
|
||||
try {
|
||||
const raw = fs.readFileSync(BANNER_FILE, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { message: '', type: 'info', liveFrom: null, liveTill: null };
|
||||
}
|
||||
}
|
||||
|
||||
function writeBanner(data) {
|
||||
fs.writeFileSync(BANNER_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// @desc Get the current banner
|
||||
// @route GET /api/banner
|
||||
// @access Public
|
||||
const getBanner = (req, res) => {
|
||||
res.json(readBanner());
|
||||
};
|
||||
|
||||
// @desc Set the banner
|
||||
// @route POST /api/banner
|
||||
// @access Supervisor / Admin
|
||||
const setBanner = (req, res) => {
|
||||
const { message, type, liveFrom, liveTill } = req.body;
|
||||
|
||||
const allowed = ['info', 'warning', 'success', 'danger'];
|
||||
const banner = {
|
||||
message: typeof message === 'string' ? message.trim() : '',
|
||||
type: allowed.includes(type) ? type : 'info',
|
||||
liveFrom: liveFrom || null,
|
||||
liveTill: liveTill || null,
|
||||
};
|
||||
|
||||
writeBanner(banner);
|
||||
res.json(banner);
|
||||
};
|
||||
|
||||
module.exports = { getBanner, setBanner };
|
||||
@@ -0,0 +1,215 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// Escape user-controlled strings before inserting them into HTML
|
||||
function escapeHtml(str) {
|
||||
return String(str || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Utilities shared with attendees emailing
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
}
|
||||
|
||||
function getFrontendBaseUrl() {
|
||||
const base = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
return String(base).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function buildEventContext(event) {
|
||||
if (!event) return { eventTitle: '', eventStart: '', eventLink: '', eventLinkHtml: '' };
|
||||
const eventTitle = event.title || '';
|
||||
const eventStart = event.startDate ? fmtDate(event.startDate) : '';
|
||||
const eventLink = `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}`;
|
||||
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
|
||||
return { eventTitle, eventStart, eventLink, eventLinkHtml };
|
||||
}
|
||||
|
||||
function replacePlaceholders(str, ctx) {
|
||||
if (!str) return str;
|
||||
return String(str)
|
||||
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
|
||||
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
|
||||
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
|
||||
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''));
|
||||
}
|
||||
|
||||
function parseFreeformEmails(lines) {
|
||||
// Supports formats:
|
||||
// - email@example.com
|
||||
// - Name <email@example.com>
|
||||
// - "Name" <email@example.com>
|
||||
const recipients = [];
|
||||
const input = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/);
|
||||
for (const raw of input) {
|
||||
const s = String(raw || '').trim();
|
||||
if (!s) continue;
|
||||
let name = '';
|
||||
let email = '';
|
||||
const m = s.match(/^(.*?)<\s*([^>\s]+@[^>\s]+)\s*>\s*$/);
|
||||
if (m) {
|
||||
name = m[1].trim().replace(/^"|"$/g, '').trim();
|
||||
email = m[2].trim();
|
||||
} else {
|
||||
// If it just looks like an email, accept it
|
||||
const em = s.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) ? s : '';
|
||||
if (em) email = em; else continue;
|
||||
}
|
||||
recipients.push({ email, name });
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
// @desc Preview broadcast recipients and sample
|
||||
// @route POST /api/broadcasts/preview
|
||||
// @access Private/Supervisor or Admin
|
||||
const previewBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { userIds, emails, eventId } = req.body || {};
|
||||
|
||||
// Resolve users
|
||||
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||
let users = [];
|
||||
if (ids.length) {
|
||||
users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, email: true } });
|
||||
}
|
||||
|
||||
// Parse freeform emails
|
||||
const extra = parseFreeformEmails(emails);
|
||||
|
||||
// Merge and de-duplicate by email
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const email = String(u.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const email = String(r.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
|
||||
// Optionally resolve event info for link/title placeholder
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventCtx = buildEventContext(event);
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send broadcast now
|
||||
// @route POST /api/broadcasts/send
|
||||
// @access Private/Supervisor or Admin
|
||||
const sendBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { subject, html, text, userIds, emails, eventId } = req.body || {};
|
||||
if (!subject || !(html || text)) {
|
||||
return res.status(400).json({ message: 'Subject and message (html or text) are required' });
|
||||
}
|
||||
|
||||
// Resolve recipients similar to preview
|
||||
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||
let users = [];
|
||||
if (ids.length) {
|
||||
users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, email: true } });
|
||||
}
|
||||
const extra = parseFreeformEmails(emails);
|
||||
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const email = String(u.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const email = String(r.email || '').trim();
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients' });
|
||||
}
|
||||
|
||||
// Resolve event
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventCtx = buildEventContext(event);
|
||||
|
||||
const { sendMail } = require('../utils/email');
|
||||
|
||||
// Send all emails in parallel instead of sequentially — critical for large recipient lists
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const ctxBase = {
|
||||
name: rcpt.name || '',
|
||||
eventTitle: eventCtx.eventTitle,
|
||||
eventStart: eventCtx.eventStart,
|
||||
eventLink: eventCtx.eventLink,
|
||||
};
|
||||
const ctxForHtml = html ? {
|
||||
name: escapeHtml(rcpt.name || ''),
|
||||
eventTitle: escapeHtml(eventCtx.eventTitle),
|
||||
eventStart: escapeHtml(eventCtx.eventStart),
|
||||
eventLink: escapeHtml(eventCtx.eventLink),
|
||||
eventLinkHtml: eventCtx.eventLinkHtml,
|
||||
} : ctxBase;
|
||||
const finalSubject = replacePlaceholders(subject, ctxBase);
|
||||
const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined;
|
||||
const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined);
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[broadcast] failed to send to', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Schedule a broadcast
|
||||
// @route POST /api/broadcasts/schedule
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, subject, html, text, userIds, emails, eventId } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' });
|
||||
|
||||
const payload = { subject, html, text, userIds, emails, eventId };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
broadcast: true,
|
||||
scheduledAt: when.toISOString(),
|
||||
createdById: req.user?.id || null,
|
||||
payload,
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: 'Broadcast scheduled', job: created });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { previewBroadcast, sendBroadcast, scheduleBroadcast };
|
||||
@@ -0,0 +1,186 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { ALL_METHODS, assertEventOpen, computeEventFinancials } = require('../utils/cashupUtils');
|
||||
|
||||
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
|
||||
// @route GET /api/cashups/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const getEventCashup = async (req, res) => {
|
||||
try {
|
||||
const financials = await computeEventFinancials(req.params.eventId);
|
||||
res.json(financials);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save in-progress reconciliation entries without closing the event
|
||||
// @route PUT /api/cashups/event/:eventId/draft
|
||||
// @access Private/Admin
|
||||
const saveEventCashupDraft = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { lines, notes } = req.body;
|
||||
|
||||
await assertEventOpen(eventId, res);
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupDraft: { lines: Array.isArray(lines) ? lines : [], notes: notes || null, savedAt: new Date().toISOString() } }
|
||||
});
|
||||
|
||||
res.json({ message: 'Draft saved' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Close an event, optionally with a full per-method cashup
|
||||
// @route POST /api/cashups/event/:eventId/close
|
||||
// @access Private/Admin
|
||||
const closeEvent = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { lines, notes } = req.body;
|
||||
|
||||
await assertEventOpen(eventId, res);
|
||||
|
||||
const financials = await computeEventFinancials(eventId);
|
||||
const isFullCashup = Array.isArray(lines) && lines.length > 0;
|
||||
|
||||
const cashupLines = isFullCashup
|
||||
? lines
|
||||
.filter(l => l && ALL_METHODS.includes(l.method))
|
||||
.map(l => {
|
||||
const expected = financials.expectedCashByMethod[l.method] || 0;
|
||||
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
|
||||
? l.denominations
|
||||
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
|
||||
.filter(d => d.value > 0 && d.count > 0)
|
||||
: [];
|
||||
const actual = denominations.length > 0
|
||||
? denominations.reduce((sum, d) => sum + d.value * d.count, 0)
|
||||
: (l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
method: l.method,
|
||||
expectedAmount: expected,
|
||||
actualAmount: actual,
|
||||
variance: actual !== null ? actual - expected : null,
|
||||
notes: l.notes || null,
|
||||
denominations: denominations.length > 0 ? { create: denominations } : undefined
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const totalActualRevenue = isFullCashup
|
||||
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
|
||||
: null;
|
||||
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedCashByMethod[m], 0);
|
||||
|
||||
const cashup = await prisma.eventCashup.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
action: isFullCashup ? 'closed' : 'quick_closed',
|
||||
unallocatedDonationsTotal: financials.unallocatedDonationsTotal,
|
||||
totalCosts: financials.totalCosts,
|
||||
totalExpectedRevenue,
|
||||
totalActualRevenue,
|
||||
notes: notes || null,
|
||||
performedById: req.user.id,
|
||||
lines: { create: cashupLines }
|
||||
},
|
||||
include: { lines: { include: { denominations: true } }, performedBy: { select: { id: true, name: true, email: true } } }
|
||||
});
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupStatus: 'closed', cashupDraft: null, closedAt: new Date(), closedById: req.user.id }
|
||||
});
|
||||
|
||||
res.status(201).json(cashup);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reopen a closed event (admin only)
|
||||
// @route POST /api/cashups/event/:eventId/reopen
|
||||
// @access Private/Admin
|
||||
const reopenEvent = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { notes } = req.body;
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
if (event.cashupStatus !== 'closed') {
|
||||
res.status(400);
|
||||
throw new Error('Event is not closed');
|
||||
}
|
||||
|
||||
const auditRow = await prisma.eventCashup.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
action: 'reopened',
|
||||
notes: notes || null,
|
||||
performedById: req.user.id
|
||||
},
|
||||
include: { performedBy: { select: { id: true, name: true, email: true } } }
|
||||
});
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id }
|
||||
});
|
||||
|
||||
res.status(201).json(auditRow);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Flat audit log of every close/quick-close/reopen, optionally filtered
|
||||
// @route GET /api/cashups/audit
|
||||
// @access Private/Supervisor
|
||||
const getCashupAudit = async (req, res) => {
|
||||
try {
|
||||
const { eventId, from, to } = req.query;
|
||||
|
||||
const where = {};
|
||||
if (eventId) where.eventId = eventId;
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
if (to) where.createdAt.lte = new Date(to);
|
||||
}
|
||||
|
||||
const rows = await prisma.eventCashup.findMany({
|
||||
where,
|
||||
include: {
|
||||
event: { select: { id: true, title: true } },
|
||||
performedBy: { select: { id: true, name: true, email: true } },
|
||||
lines: { include: { denominations: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getEventCashup,
|
||||
saveEventCashupDraft,
|
||||
closeEvent,
|
||||
reopenEvent,
|
||||
getCashupAudit
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, ALL_METHODS } = require('../utils/cashupUtils');
|
||||
|
||||
function normalizePaidFromMethod(value) {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
if (!ALL_METHODS.includes(value)) {
|
||||
throw new Error("paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// @desc List costs for an event
|
||||
// @route GET /api/events/:eventId/costs
|
||||
// @access Private/Supervisor
|
||||
const getEventCosts = async (req, res) => {
|
||||
try {
|
||||
const costs = await prisma.eventCost.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: { eventOption: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
res.json(costs);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a cost for an event
|
||||
// @route POST /api/events/:eventId/costs
|
||||
// @access Private/Supervisor
|
||||
const createEventCost = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
|
||||
|
||||
await assertEventOpen(eventId, res);
|
||||
|
||||
if (!label || !String(label).trim()) {
|
||||
res.status(400);
|
||||
throw new Error('Label is required');
|
||||
}
|
||||
if (costType !== 'once_off' && costType !== 'per_item') {
|
||||
res.status(400);
|
||||
throw new Error("costType must be 'once_off' or 'per_item'");
|
||||
}
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt >= 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Amount must be a non-negative number');
|
||||
}
|
||||
if (costType === 'per_item' && !eventOptionId) {
|
||||
res.status(400);
|
||||
throw new Error('eventOptionId is required for per-item costs');
|
||||
}
|
||||
if (costType === 'per_item') {
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId } });
|
||||
if (!option || option.eventId !== eventId) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket type not found for this event');
|
||||
}
|
||||
}
|
||||
|
||||
const cost = await prisma.eventCost.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
label: String(label).trim(),
|
||||
costType,
|
||||
amount: amt,
|
||||
eventOptionId: costType === 'per_item' ? eventOptionId : null,
|
||||
paidFromMethod: normalizePaidFromMethod(paidFromMethod) ?? null,
|
||||
notes: notes || null
|
||||
},
|
||||
include: { eventOption: { select: { id: true, name: true } } }
|
||||
});
|
||||
|
||||
res.status(201).json(cost);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update a cost
|
||||
// @route PUT /api/costs/:id
|
||||
// @access Private/Supervisor
|
||||
const updateEventCost = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) {
|
||||
res.status(404);
|
||||
throw new Error('Cost not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(existing.eventId, res);
|
||||
|
||||
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
|
||||
const nextCostType = costType !== undefined ? costType : existing.costType;
|
||||
if (nextCostType !== 'once_off' && nextCostType !== 'per_item') {
|
||||
res.status(400);
|
||||
throw new Error("costType must be 'once_off' or 'per_item'");
|
||||
}
|
||||
|
||||
const nextEventOptionId = nextCostType === 'per_item'
|
||||
? (eventOptionId !== undefined ? eventOptionId : existing.eventOptionId)
|
||||
: null;
|
||||
if (nextCostType === 'per_item') {
|
||||
if (!nextEventOptionId) {
|
||||
res.status(400);
|
||||
throw new Error('eventOptionId is required for per-item costs');
|
||||
}
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: nextEventOptionId } });
|
||||
if (!option || option.eventId !== existing.eventId) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket type not found for this event');
|
||||
}
|
||||
}
|
||||
|
||||
let nextAmount = existing.amount;
|
||||
if (amount !== undefined) {
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt >= 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Amount must be a non-negative number');
|
||||
}
|
||||
nextAmount = amt;
|
||||
}
|
||||
|
||||
const nextPaidFromMethod = normalizePaidFromMethod(paidFromMethod);
|
||||
|
||||
const cost = await prisma.eventCost.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
label: label !== undefined ? String(label).trim() : existing.label,
|
||||
costType: nextCostType,
|
||||
amount: nextAmount,
|
||||
eventOptionId: nextEventOptionId,
|
||||
paidFromMethod: nextPaidFromMethod !== undefined ? nextPaidFromMethod : existing.paidFromMethod,
|
||||
notes: notes !== undefined ? notes : existing.notes
|
||||
},
|
||||
include: { eventOption: { select: { id: true, name: true } } }
|
||||
});
|
||||
|
||||
res.json(cost);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a cost
|
||||
// @route DELETE /api/costs/:id
|
||||
// @access Private/Supervisor
|
||||
const deleteEventCost = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) {
|
||||
res.status(404);
|
||||
throw new Error('Cost not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(existing.eventId, res);
|
||||
|
||||
await prisma.eventCost.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Cost deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getEventCosts,
|
||||
createEventCost,
|
||||
updateEventCost,
|
||||
deleteEventCost
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// @desc List submitted form responses with optional filters
|
||||
// @route GET /api/forms/responses
|
||||
// @access Private/Staff (admin, supervisor, staff)
|
||||
const listFormResponses = async (req, res) => {
|
||||
try {
|
||||
const { eventId, userId, registrationId, limit, cursor } = req.query;
|
||||
|
||||
// Build where clause
|
||||
const where = {};
|
||||
if (registrationId) {
|
||||
where.registrationId = String(registrationId);
|
||||
}
|
||||
// For eventId/userId we filter through the related registration
|
||||
const registrationFilter = {};
|
||||
if (eventId) registrationFilter.eventId = String(eventId);
|
||||
if (userId) registrationFilter.userId = String(userId);
|
||||
if (Object.keys(registrationFilter).length > 0) {
|
||||
// Prisma relation filter requires `is` wrapper
|
||||
where.registration = { is: registrationFilter };
|
||||
}
|
||||
|
||||
// Basic pagination (optional)
|
||||
const take = Math.min(Math.max(parseInt(limit || '50', 10) || 50, 1), 200);
|
||||
const cursorClause = cursor ? { id: String(cursor) } : undefined;
|
||||
|
||||
const responses = await prisma.formResponse.findMany({
|
||||
where,
|
||||
include: {
|
||||
registration: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: { select: { id: true, title: true } },
|
||||
}
|
||||
},
|
||||
answers: { include: { field: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
...(cursorClause ? { skip: 1, cursor: cursorClause } : {}),
|
||||
});
|
||||
|
||||
const nextCursor = responses.length === take ? responses[responses.length - 1].id : null;
|
||||
|
||||
res.json({ items: responses, nextCursor });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listFormResponses };
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
// Utility: draw a table
|
||||
function drawTable(doc, startX, startY, colWidths, rows, header) {
|
||||
let y = startY;
|
||||
doc.font('Helvetica-Bold');
|
||||
if (header && header.length) {
|
||||
let x = startX;
|
||||
header.forEach((h, i) => {
|
||||
const w = colWidths[i] || 80;
|
||||
doc.rect(x, y, w, 20).stroke();
|
||||
doc.text(String(h || ''), x + 4, y + 6, { width: w - 8 });
|
||||
x += w;
|
||||
});
|
||||
y += 20;
|
||||
}
|
||||
doc.font('Helvetica');
|
||||
rows.forEach((row) => {
|
||||
let x = startX;
|
||||
row.forEach((cell, i) => {
|
||||
const w = colWidths[i] || 80;
|
||||
const h = 18;
|
||||
doc.rect(x, y, w, h).stroke();
|
||||
doc.text(String(cell ?? ''), x + 4, y + 4, { width: w - 8 });
|
||||
x += w;
|
||||
});
|
||||
y += 18;
|
||||
// New page if overflow
|
||||
if (y > doc.page.height - 40) {
|
||||
doc.addPage();
|
||||
y = 20;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function a4Doc(orientation = 'portrait') {
|
||||
return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' });
|
||||
}
|
||||
|
||||
// POST /api/reports/pdf
|
||||
// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
|
||||
const generatePdf = async (req, res) => {
|
||||
try {
|
||||
const { title, kind, table, layered, orientation } = req.body || {};
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
|
||||
doc.pipe(res);
|
||||
|
||||
// Title
|
||||
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
if (kind === 'table' && table && Array.isArray(table.rows)) {
|
||||
const columns = Array.isArray(table.columns) ? table.columns : [];
|
||||
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
|
||||
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
// Slightly wider first column to mimic site tables
|
||||
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
|
||||
const colWidths = new Array(colCount).fill(baseWidth);
|
||||
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
|
||||
|
||||
// Draw header band
|
||||
if (columns.length) {
|
||||
let x = doc.page.margins.left;
|
||||
const y = doc.y;
|
||||
doc.save();
|
||||
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
|
||||
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
|
||||
columns.forEach((h, i) => {
|
||||
const w = colWidths[i] || baseWidth;
|
||||
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
|
||||
x += w;
|
||||
});
|
||||
doc.restore();
|
||||
doc.moveDown(1.6);
|
||||
}
|
||||
|
||||
// Zebra rows
|
||||
const rows = table.rows;
|
||||
rows.forEach((row, idx) => {
|
||||
const rowY = doc.y;
|
||||
const rowH = 18;
|
||||
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
|
||||
doc.save();
|
||||
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
|
||||
let x = doc.page.margins.left;
|
||||
row.forEach((cell, i) => {
|
||||
const w = colWidths[i] || baseWidth;
|
||||
// Cell text
|
||||
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
|
||||
// Vertical separators similar to table borders
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
|
||||
x += w;
|
||||
});
|
||||
// Right border
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
|
||||
doc.moveDown(1.1);
|
||||
if (doc.y > doc.page.height - 40) {
|
||||
doc.addPage();
|
||||
}
|
||||
});
|
||||
// Bottom border
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
|
||||
|
||||
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
|
||||
if (layered.header) {
|
||||
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
|
||||
doc.moveDown(0.3);
|
||||
}
|
||||
doc.font('Helvetica').fontSize(11);
|
||||
for (const section of layered.sections) {
|
||||
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
|
||||
doc.moveDown(0.15);
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
if (Array.isArray(section.items) && section.items.length) {
|
||||
for (const item of section.items) {
|
||||
// Bullet dot
|
||||
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
|
||||
doc.fillColor('#111827');
|
||||
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
} else {
|
||||
doc.text('No items');
|
||||
}
|
||||
doc.moveDown(0.5);
|
||||
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||
}
|
||||
} else {
|
||||
doc.font('Helvetica').text('No content');
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/reports/email
|
||||
// body: { title, kind, table?, layered?, subject?, body? }
|
||||
const emailPdf = async (req, res) => {
|
||||
try {
|
||||
const { title, kind, table, layered, subject, body, orientation } = req.body || {};
|
||||
const user = req.user;
|
||||
if (!user || !user.email) {
|
||||
res.status(400);
|
||||
throw new Error('User email not available');
|
||||
}
|
||||
|
||||
// Ensure temp dir
|
||||
const tempDir = path.join(__dirname, '..', '..', 'temp');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
|
||||
|
||||
// Build PDF to file
|
||||
await new Promise((resolve, reject) => {
|
||||
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
|
||||
const ws = fs.createWriteStream(filePath);
|
||||
doc.pipe(ws);
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report');
|
||||
doc.moveDown(0.5);
|
||||
|
||||
if (kind === 'table' && table && Array.isArray(table.rows)) {
|
||||
const columns = Array.isArray(table.columns) ? table.columns : [];
|
||||
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
|
||||
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
// Slightly wider first column
|
||||
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
|
||||
const colWidths = new Array(colCount).fill(baseWidth);
|
||||
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
|
||||
|
||||
// Header band
|
||||
if (columns.length) {
|
||||
let x = doc.page.margins.left;
|
||||
const y = doc.y;
|
||||
doc.save();
|
||||
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
|
||||
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
|
||||
columns.forEach((h, i) => {
|
||||
const w = colWidths[i] || baseWidth;
|
||||
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
|
||||
x += w;
|
||||
});
|
||||
doc.restore();
|
||||
doc.moveDown(1.6);
|
||||
}
|
||||
|
||||
// Rows zebra
|
||||
const rows = table.rows;
|
||||
rows.forEach((row, idx) => {
|
||||
const rowY = doc.y;
|
||||
const rowH = 18;
|
||||
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
|
||||
doc.save();
|
||||
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
|
||||
let x = doc.page.margins.left;
|
||||
row.forEach((cell, i) => {
|
||||
const w = colWidths[i] || baseWidth;
|
||||
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
|
||||
x += w;
|
||||
});
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
|
||||
doc.moveDown(1.1);
|
||||
if (doc.y > doc.page.height - 40) {
|
||||
doc.addPage();
|
||||
}
|
||||
});
|
||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
|
||||
|
||||
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
|
||||
if (layered.header) {
|
||||
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
|
||||
doc.moveDown(0.3);
|
||||
}
|
||||
doc.font('Helvetica').fontSize(11);
|
||||
for (const section of layered.sections) {
|
||||
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
|
||||
doc.moveDown(0.15);
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
if (Array.isArray(section.items) && section.items.length) {
|
||||
for (const item of section.items) {
|
||||
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
|
||||
doc.fillColor('#111827');
|
||||
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
} else {
|
||||
doc.text('No items');
|
||||
}
|
||||
doc.moveDown(0.5);
|
||||
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||
}
|
||||
} else {
|
||||
doc.font('Helvetica').text('No content');
|
||||
}
|
||||
|
||||
doc.end();
|
||||
ws.on('finish', resolve);
|
||||
ws.on('error', reject);
|
||||
});
|
||||
|
||||
// Send email using nodemailer (same config as tickets)
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: process.env.EMAIL_PORT,
|
||||
secure: process.env.EMAIL_PORT === '465',
|
||||
auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM,
|
||||
to: user.email,
|
||||
subject: subject || (title ? `${title} PDF` : 'Report PDF'),
|
||||
text: body || 'Please find your report attached.',
|
||||
attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }]
|
||||
});
|
||||
|
||||
// Clean
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
|
||||
res.json({ message: `Report emailed to ${user.email}` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { generatePdf, emailPdf };
|
||||
@@ -0,0 +1,109 @@
|
||||
const { listJobs, getJob, updateJob, deleteJob } = require('../utils/scheduledEmails');
|
||||
|
||||
// Normalize job for client UI
|
||||
function toClient(job) {
|
||||
const kind = job.broadcast ? 'broadcast' : (job.eventId ? 'attendees' : 'unknown');
|
||||
const subject = job?.payload?.subject || '';
|
||||
const html = job?.payload?.html || '';
|
||||
const text = job?.payload?.text || '';
|
||||
return {
|
||||
id: job.id,
|
||||
kind,
|
||||
eventId: job.eventId || null,
|
||||
broadcast: !!job.broadcast,
|
||||
scheduledAt: job.scheduledAt,
|
||||
createdAt: job.createdAt,
|
||||
status: job.status,
|
||||
attempts: job.attempts || 0,
|
||||
sentAt: job.sentAt || null,
|
||||
lastError: job.lastError || null,
|
||||
subject,
|
||||
hasHtml: !!html,
|
||||
hasText: !!text,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/scheduled-emails
|
||||
// Returns jobs excluding emails sent more than a week ago
|
||||
const listScheduledEmails = async (req, res) => {
|
||||
try {
|
||||
const raw = listJobs();
|
||||
const now = new Date();
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const filtered = raw.filter(j => {
|
||||
if (j.status === 'sent' && j.sentAt) {
|
||||
const sentAt = new Date(j.sentAt).getTime();
|
||||
return (now.getTime() - sentAt) <= weekMs;
|
||||
}
|
||||
// Include queued, sending, error by default
|
||||
return true;
|
||||
})
|
||||
// Provide most-relevant first: queued -> sending -> error -> recent sent
|
||||
.sort((a, b) => {
|
||||
const order = { queued: 0, sending: 1, error: 2, sent: 3 };
|
||||
const oa = order[a.status] ?? 99;
|
||||
const ob = order[b.status] ?? 99;
|
||||
if (oa !== ob) return oa - ob;
|
||||
// Then by scheduledAt asc
|
||||
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
|
||||
})
|
||||
.map(toClient);
|
||||
|
||||
return res.json({ jobs: filtered });
|
||||
} catch (e) {
|
||||
return res.status(400).json({ message: e?.message || 'Failed to list scheduled emails' });
|
||||
}
|
||||
};
|
||||
|
||||
// PATCH /api/scheduled-emails/:id
|
||||
// Allows editing scheduledAt, subject, html/text on queued jobs only
|
||||
const updateScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
|
||||
|
||||
const { scheduledAt, subject, html, text } = req.body || {};
|
||||
|
||||
const patch = {};
|
||||
if (scheduledAt) {
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
patch.scheduledAt = when.toISOString();
|
||||
}
|
||||
if (subject != null || html != null || text != null) {
|
||||
const payload = { ...(job.payload || {}) };
|
||||
if (subject != null) payload.subject = subject;
|
||||
if (html != null || text != null) {
|
||||
// If html provided explicitly, set html; if text provided, set text
|
||||
if (html != null) payload.html = html;
|
||||
if (text != null) payload.text = text;
|
||||
}
|
||||
patch.payload = payload;
|
||||
}
|
||||
|
||||
const updated = updateJob(id, patch);
|
||||
return res.json({ message: 'Updated', job: toClient(updated) });
|
||||
} catch (e) {
|
||||
return res.status(400).json({ message: e?.message || 'Failed to update job' });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/scheduled-emails/:id
|
||||
// Only queued jobs can be removed
|
||||
const deleteScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const job = getJob(id);
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be deleted' });
|
||||
const ok = deleteJob(id);
|
||||
if (!ok) return res.status(404).json({ message: 'Job not found' });
|
||||
return res.json({ message: 'Deleted' });
|
||||
} catch (e) {
|
||||
return res.status(400).json({ message: e?.message || 'Failed to delete job' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail };
|
||||
@@ -0,0 +1,190 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
//
|
||||
// @desc Get sections (optionally by event)
|
||||
// @route GET /api/sections?eventId=xxx
|
||||
// @access Private/Staff
|
||||
//
|
||||
const getSections = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.query;
|
||||
|
||||
const where = eventId && eventId !== 'all'
|
||||
? { eventId }
|
||||
: undefined;
|
||||
|
||||
const sections = await prisma.section.findMany({
|
||||
where,
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: {
|
||||
eventOption: true
|
||||
}
|
||||
},
|
||||
event: {
|
||||
select: { id: true, title: true }
|
||||
}
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
res.json(sections);
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Create section
|
||||
// @route POST /api/sections
|
||||
// @access Private/Admin/Supervisor
|
||||
//
|
||||
const createSection = async (req, res) => {
|
||||
try {
|
||||
const { eventId, name, allowedOptionIds } = req.body;
|
||||
|
||||
if (!eventId || !name) {
|
||||
res.status(400);
|
||||
throw new Error('Event and section name are required');
|
||||
}
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const section = await prisma.section.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
name,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Attach allowed options if provided
|
||||
if (Array.isArray(allowedOptionIds) && allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: allowedOptionIds.map(optionId => ({
|
||||
id: uuidv4(),
|
||||
sectionId: section.id,
|
||||
eventOptionId: optionId
|
||||
})),
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
|
||||
const fullSection = await prisma.section.findUnique({
|
||||
where: { id: section.id },
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: { eventOption: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(fullSection);
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Update section
|
||||
// @route PUT /api/sections/:id
|
||||
// @access Private/Admin/Supervisor
|
||||
//
|
||||
const updateSection = async (req, res) => {
|
||||
try {
|
||||
const { name, allowedOptionIds } = req.body;
|
||||
|
||||
const section = await prisma.section.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!section) {
|
||||
res.status(404);
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
const updatedSection = await prisma.section.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name || section.name,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// If allowed options supplied → reset them
|
||||
if (Array.isArray(allowedOptionIds)) {
|
||||
|
||||
await prisma.sectionOption.deleteMany({
|
||||
where: { sectionId: section.id }
|
||||
});
|
||||
|
||||
if (allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: allowedOptionIds.map(optionId => ({
|
||||
id: uuidv4(),
|
||||
sectionId: section.id,
|
||||
eventOptionId: optionId
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fullSection = await prisma.section.findUnique({
|
||||
where: { id: section.id },
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: { eventOption: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(fullSection);
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Delete section
|
||||
// @route DELETE /api/sections/:id
|
||||
// @access Private/Admin
|
||||
//
|
||||
const deleteSection = async (req, res) => {
|
||||
try {
|
||||
const section = await prisma.section.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!section) {
|
||||
res.status(404);
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
await prisma.section.delete({
|
||||
where: { id: section.id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Section deleted' });
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getSections,
|
||||
createSection,
|
||||
updateSection,
|
||||
deleteSection
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
const prisma = require('../config/db');
|
||||
const { hashPassword, generateToken } = require('../config/auth');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
|
||||
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
|
||||
|
||||
// Keys safe to return without auth — includes legal keys needed by public legal pages
|
||||
const PUBLIC_KEYS = [
|
||||
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
|
||||
'accent_color', 'logo_url', 'setup_complete', 'app_base_url',
|
||||
// Legal pages
|
||||
'legal_operator_name', 'legal_io_name', 'legal_io_email',
|
||||
'legal_website_url', 'legal_effective_date',
|
||||
];
|
||||
|
||||
// @desc Get public app settings
|
||||
// @route GET /api/settings
|
||||
// @access Public
|
||||
const getSettings = async (req, res) => {
|
||||
try {
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: PUBLIC_KEYS } } });
|
||||
const settings = {};
|
||||
for (const r of rows) settings[r.key] = r.value;
|
||||
res.json(settings);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get ALL app settings (admin view — encrypted fields masked)
|
||||
// @route GET /api/settings/all
|
||||
// @access Admin
|
||||
const getAllSettings = async (req, res) => {
|
||||
try {
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
const settings = {};
|
||||
for (const r of rows) {
|
||||
if (ENCRYPTED_KEYS.has(r.key)) {
|
||||
// Return a sentinel so the UI knows the value is set, without exposing it.
|
||||
// smtp_user (email address) we can safely return as-is after decryption so
|
||||
// the admin can see what address is configured; smtp_pass we fully mask.
|
||||
if (r.key === 'smtp_pass') {
|
||||
settings[r.key] = r.value ? '••••••••' : '';
|
||||
} else {
|
||||
// smtp_user — decrypt and return so admin can see/edit it
|
||||
try {
|
||||
settings[r.key] = isEncrypted(r.value) ? decrypt(r.value) : r.value;
|
||||
} catch {
|
||||
settings[r.key] = '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settings[r.key] = r.value;
|
||||
}
|
||||
}
|
||||
res.json(settings);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Upsert one or more app settings
|
||||
// @route PUT /api/settings
|
||||
// @access Admin
|
||||
const updateSettings = async (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
if (!updates || typeof updates !== 'object') {
|
||||
res.status(400); throw new Error('Body must be a key→value object');
|
||||
}
|
||||
|
||||
const ops = Object.entries(updates)
|
||||
.filter(([, v]) => v !== undefined && v !== null)
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
|
||||
// Encrypt sensitive keys before storing
|
||||
if (ENCRYPTED_KEYS.has(key)) {
|
||||
// If the frontend sends the masking sentinel back, skip this key (user didn't change it)
|
||||
if (storedValue === '••••••••') return null;
|
||||
if (storedValue === '') {
|
||||
// Blank = clear the setting
|
||||
return prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value: '' },
|
||||
create: { key, value: '' },
|
||||
});
|
||||
}
|
||||
storedValue = encrypt(storedValue);
|
||||
}
|
||||
|
||||
return prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value: storedValue },
|
||||
create: { key, value: storedValue },
|
||||
});
|
||||
})
|
||||
.filter(Boolean); // remove nulls (masked password skips)
|
||||
|
||||
if (ops.length) await prisma.$transaction(ops);
|
||||
invalidateSettingsCache();
|
||||
await warmCache(); // ensure in-memory cache reflects the new values before responding
|
||||
res.json({ message: 'Settings saved' });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check whether first-time setup is still needed
|
||||
// @route GET /api/settings/needs-setup
|
||||
// @access Public
|
||||
const needsSetup = async (req, res) => {
|
||||
try {
|
||||
const done = await prisma.appSetting.findUnique({ where: { key: 'setup_complete' } });
|
||||
res.json({ needsSetup: done?.value !== 'true' });
|
||||
} catch {
|
||||
// DB unreachable — don't block the app
|
||||
res.json({ needsSetup: false });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Register the first admin account during setup — returns a JWT for use in subsequent setup steps
|
||||
// @route POST /api/setup/register
|
||||
// @access Public (one-time only — blocked once users exist)
|
||||
const setupRegister = async (req, res) => {
|
||||
try {
|
||||
const count = await prisma.user.count();
|
||||
if (count > 0) {
|
||||
res.status(403); throw new Error('Setup has already been completed');
|
||||
}
|
||||
|
||||
const { adminName, adminEmail, adminPassword } = req.body;
|
||||
|
||||
if (!adminName?.trim()) { res.status(400); throw new Error('Admin name is required'); }
|
||||
if (!adminEmail?.trim()) { res.status(400); throw new Error('Admin email is required'); }
|
||||
if (!adminPassword || adminPassword.length < 8) {
|
||||
res.status(400); throw new Error('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
const hashed = await hashPassword(adminPassword);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name: adminName.trim(),
|
||||
email: adminEmail.trim().toLowerCase(),
|
||||
password: hashed,
|
||||
role: 'admin',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
|
||||
res.json({ token, name: user.name, email: user.email });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Complete first-time setup: persist settings (admin must already be registered via /api/setup/register)
|
||||
// @route POST /api/setup
|
||||
// @access Admin (use token returned by /api/setup/register)
|
||||
const runSetup = async (req, res) => {
|
||||
try {
|
||||
const setupDone = await prisma.appSetting.findUnique({ where: { key: 'setup_complete' } });
|
||||
if (setupDone?.value === 'true') {
|
||||
res.status(403); throw new Error('Setup has already been completed');
|
||||
}
|
||||
|
||||
const { settings = {} } = req.body;
|
||||
|
||||
const toSave = { ...settings, setup_complete: 'true' };
|
||||
const ops = Object.entries(toSave)
|
||||
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
|
||||
return prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
update: { value: storedValue },
|
||||
create: { key, value: storedValue },
|
||||
});
|
||||
});
|
||||
await prisma.$transaction(ops);
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
|
||||
res.json({ message: 'Setup complete. You can now log in.' });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates raw nodemailer / Node.js network errors into plain-language messages
|
||||
* suitable for display to an admin who may not know what ECONNREFUSED means.
|
||||
*/
|
||||
function friendlySmtpError(e) {
|
||||
const code = e.code || '';
|
||||
const msg = (e.message || '').toLowerCase();
|
||||
const resp = (e.response || '').toLowerCase();
|
||||
|
||||
// Authentication failures (530 = Microsoft "Client not authenticated", 535 = standard auth failure)
|
||||
if (code === 'EAUTH' || e.responseCode === 535 || e.responseCode === 530 || msg.includes('invalid login') || msg.includes('username and password') || msg.includes('not authenticated') || resp.includes('badcredentials') || resp.includes('authentication')) {
|
||||
return 'Authentication failed — check your SMTP username and password.';
|
||||
}
|
||||
|
||||
// Wrong certificate / TLS mismatch
|
||||
if (code === 'ESOCKET' && (msg.includes('wrong version') || msg.includes('ssl') || msg.includes('tls'))) {
|
||||
return 'TLS/SSL error — try toggling the "Use TLS/SSL" option or switching the port between 465 and 587.';
|
||||
}
|
||||
if (msg.includes('unable_to_verify') || msg.includes('self signed') || msg.includes('certificate')) {
|
||||
return 'SSL certificate error — the server\'s certificate could not be verified. Check the port and TLS setting.';
|
||||
}
|
||||
|
||||
// Connection refused or timed out
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return 'Connection refused — no mail server responded on that host and port. Check the host and port settings.';
|
||||
}
|
||||
if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT' || msg.includes('timed out')) {
|
||||
return 'Connection timed out — the server did not respond in time. Check the host and port, or try a different port.';
|
||||
}
|
||||
|
||||
// DNS / hostname not found
|
||||
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
||||
return 'Host not found — the SMTP hostname could not be resolved. Check for typos in the server address.';
|
||||
}
|
||||
|
||||
// Network unreachable
|
||||
if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH') {
|
||||
return 'Network unreachable — the server could not be reached. Check your network connection and the host address.';
|
||||
}
|
||||
|
||||
// Connection reset
|
||||
if (code === 'ECONNRESET') {
|
||||
return 'Connection was reset by the server — this can indicate a port mismatch or a firewall block.';
|
||||
}
|
||||
|
||||
// Generic SMTP error with a response code
|
||||
if (e.responseCode) {
|
||||
return `SMTP error ${e.responseCode}: ${e.response || e.message}`;
|
||||
}
|
||||
|
||||
// Fallback — strip overly long technical strings but keep it readable
|
||||
const raw = e.message || 'Unknown error';
|
||||
const trimmed = raw.length > 120 ? raw.slice(0, 120) + '…' : raw;
|
||||
return `Could not connect: ${trimmed}`;
|
||||
}
|
||||
|
||||
// @desc Test SMTP connection using current settings (DB or env fallbacks)
|
||||
// Optionally accepts { host, port, secure, user, pass, from } in the request
|
||||
// body to test unsaved values without saving them first.
|
||||
// @route POST /api/settings/test-smtp
|
||||
// @access Admin
|
||||
const testSmtp = async (req, res) => {
|
||||
const nodemailer = require('nodemailer');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
|
||||
try {
|
||||
// Prefer values from the request body so admins can test before saving.
|
||||
// Fall back to cache → env vars.
|
||||
const host = req.body.host || getSettingSync('smtp_host', process.env.SMTP_HOST || process.env.EMAIL_HOST || '');
|
||||
const port = req.body.port || getSettingSync('smtp_port', process.env.SMTP_PORT || process.env.EMAIL_PORT || '587');
|
||||
const secure = req.body.secure !== undefined
|
||||
? (req.body.secure === true || req.body.secure === 'true')
|
||||
: (getSettingSync('smtp_secure', process.env.SMTP_SECURE || 'false').toLowerCase() === 'true');
|
||||
const user = req.body.user || getSettingSync('smtp_user', process.env.SMTP_USER || process.env.EMAIL_USER || '');
|
||||
// For pass: if a real value is supplied in body use it; if "••••••••" is sent, read from cache.
|
||||
let pass = req.body.pass || '';
|
||||
if (!pass || pass === '••••••••') {
|
||||
pass = getSettingSync('smtp_pass', process.env.SMTP_PASS || process.env.EMAIL_PASS || '');
|
||||
} else {
|
||||
// Body supplied a plaintext password — decrypt it if it happens to be encrypted (shouldn't be, but guard anyway)
|
||||
const { isEncrypted: _ie, decrypt: _d } = require('../utils/encryption');
|
||||
if (_ie(pass)) pass = _d(pass);
|
||||
}
|
||||
const from = req.body.from || getSettingSync('smtp_from', process.env.MAIL_FROM || process.env.EMAIL_FROM || '');
|
||||
|
||||
if (!host) {
|
||||
res.status(400); throw new Error('SMTP host is not configured');
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(port, 10) || 587,
|
||||
secure: secure || String(port) === '465',
|
||||
auth: user && pass ? { user, pass } : undefined,
|
||||
});
|
||||
|
||||
// verify() checks connectivity and authentication without sending a message
|
||||
await transporter.verify();
|
||||
|
||||
// Send a real test email to the authenticated user so there's visible proof
|
||||
const adminEmail = req.user?.email;
|
||||
if (adminEmail) {
|
||||
await transporter.sendMail({
|
||||
from: from || user || 'no-reply@hope-events.local',
|
||||
to: adminEmail,
|
||||
subject: 'SMTP test — Hope Events',
|
||||
text: `This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
|
||||
html: `<p>This is a test email sent from the <strong>Hope Events</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: `SMTP connection verified${adminEmail ? ` — a test email has been sent to ${adminEmail}` : ''}` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: friendlySmtpError(e), raw: e.message || String(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getSettings, getAllSettings, updateSettings, needsSetup, setupRegister, runSetup, testSmtp };
|
||||
@@ -0,0 +1,141 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
|
||||
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
|
||||
// computed with aggregate queries — never a full payments/events list shipped to the
|
||||
// client just to be reduced down to a couple of numbers.
|
||||
|
||||
async function computeScanStats(userId) {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const whereBase = { scannedAt: { gte: startOfDay } };
|
||||
|
||||
const [totalToday, myToday, lastHour, byStaffRaw] = await Promise.all([
|
||||
prisma.ticketUsage.count({ where: whereBase }),
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: userId } }),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } } }),
|
||||
prisma.ticketUsage.groupBy({ by: ['scannedById'], where: whereBase, _count: { _all: true } }),
|
||||
]);
|
||||
|
||||
const staffIds = byStaffRaw.map((b) => b.scannedById);
|
||||
const staffUsers = staffIds.length > 0
|
||||
? await prisma.user.findMany({ where: { id: { in: staffIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const nameMap = Object.fromEntries(staffUsers.map((u) => [u.id, u.name]));
|
||||
|
||||
return {
|
||||
totalToday,
|
||||
myToday,
|
||||
lastHour,
|
||||
byStaff: byStaffRaw.map((b) => ({ scannedById: b.scannedById, name: nameMap[b.scannedById] || 'Staff', count: b._count._all })),
|
||||
};
|
||||
}
|
||||
|
||||
function getRecentScans(limit = 10) {
|
||||
return prisma.ticketUsage.findMany({
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
scannedAt: true,
|
||||
quantityRedeemed: true,
|
||||
scannedBy: { select: { id: true, name: true } },
|
||||
ticket: {
|
||||
select: {
|
||||
id: true,
|
||||
event: { select: { title: true } },
|
||||
registrationOption: { select: { eventOption: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveEventsCount() {
|
||||
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
|
||||
}
|
||||
|
||||
async function computePaymentStats({ includeWeekMonth }) {
|
||||
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
const queries = [
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay } } }),
|
||||
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
|
||||
];
|
||||
if (includeWeekMonth) {
|
||||
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
queries.push(
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek } } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth } } }),
|
||||
);
|
||||
}
|
||||
|
||||
const [totalToday, donationsToday, totalWeek, totalMonth] = await Promise.all(queries);
|
||||
|
||||
const stats = {
|
||||
totalToday: totalToday._sum.amount || 0,
|
||||
donationsToday,
|
||||
};
|
||||
if (includeWeekMonth) {
|
||||
stats.totalWeek = totalWeek._sum.amount || 0;
|
||||
stats.totalMonth = totalMonth._sum.amount || 0;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
// @desc All stats the staff dashboard needs, in one call
|
||||
// @route GET /api/stats/staff
|
||||
// @access Private/Staff+
|
||||
const getStaffDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const [scanStats, recentScans] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
]);
|
||||
res.json({ scanStats, recentScans });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc All stats the supervisor dashboard needs, in one call
|
||||
// @route GET /api/stats/supervisor
|
||||
// @access Private/Supervisor+
|
||||
const getSupervisorDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computePaymentStats({ includeWeekMonth: false }),
|
||||
getActiveEventsCount(),
|
||||
]);
|
||||
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc All stats the admin dashboard needs, in one call
|
||||
// @route GET /api/stats/admin
|
||||
// @access Private/Admin
|
||||
const getAdminDashboardStats = async (req, res) => {
|
||||
try {
|
||||
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
|
||||
computeScanStats(req.user.id),
|
||||
getRecentScans(10),
|
||||
computePaymentStats({ includeWeekMonth: true }),
|
||||
getActiveEventsCount(),
|
||||
]);
|
||||
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getStaffDashboardStats,
|
||||
getSupervisorDashboardStats,
|
||||
getAdminDashboardStats,
|
||||
};
|
||||
@@ -0,0 +1,795 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
|
||||
// @desc Generate tickets for a registration
|
||||
// @route POST /api/tickets/generate
|
||||
// @access Private/Admin
|
||||
const generateTickets = async (req, res) => {
|
||||
try {
|
||||
const { registrationId } = req.body;
|
||||
|
||||
if (!registrationId) {
|
||||
res.status(400);
|
||||
throw new Error('registrationId is required');
|
||||
}
|
||||
|
||||
await assertRegistrationEventOpen(registrationId, res);
|
||||
|
||||
// Delegate fully to the shared utility which handles deduplication/consolidation
|
||||
const newTickets = await generateTicketsForRegistration(registrationId);
|
||||
|
||||
// Fetch the final canonical ticket set (one per option)
|
||||
const options = await prisma.registrationOption.findMany({ where: { registrationId } });
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { registrationOptionId: { in: options.map(o => o.id) } },
|
||||
include: {
|
||||
registrationOption: { include: { eventOption: true } },
|
||||
user: true,
|
||||
event: true
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: `${newTickets.length} ticket(s) generated, ${tickets.length} total`,
|
||||
tickets
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get all tickets
|
||||
// @route GET /api/tickets
|
||||
// @access Private/Admin
|
||||
const getTickets = async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const include = {
|
||||
registrationOption: {
|
||||
include: {
|
||||
eventOption: true,
|
||||
registration: {
|
||||
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
};
|
||||
|
||||
const [tickets, total] = await prisma.$transaction([
|
||||
prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.ticket.count()
|
||||
]);
|
||||
|
||||
res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get user tickets
|
||||
// @route GET /api/tickets/mytickets
|
||||
// @access Private
|
||||
const getUserTickets = async (req, res) => {
|
||||
try {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
||||
},
|
||||
include: {
|
||||
registrationOption: {
|
||||
include: {
|
||||
eventOption: true,
|
||||
variant: true,
|
||||
registration: true
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
usages: {
|
||||
include: {
|
||||
scannedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(tickets);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get ticket by ID
|
||||
// @route GET /api/tickets/:id
|
||||
// @access Private
|
||||
const getTicketById = async (req, res) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
registrationOption: {
|
||||
include: {
|
||||
eventOption: true,
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
},
|
||||
usages: {
|
||||
include: {
|
||||
scannedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
// Check if user is authorized to view this ticket
|
||||
if (ticket.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') {
|
||||
res.status(403);
|
||||
throw new Error('Not authorized to view this ticket');
|
||||
}
|
||||
|
||||
res.json(ticket);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get ticket by QR code
|
||||
// @route GET /api/tickets/qr/:qrCode
|
||||
// @access Private/Staff
|
||||
const getTicketByQrCode = async (req, res) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { qrCode: req.params.qrCode },
|
||||
include: {
|
||||
registrationOption: {
|
||||
include: {
|
||||
eventOption: true,
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
},
|
||||
usages: {
|
||||
include: {
|
||||
scannedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
res.json(ticket);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Lightweight ticket preview for the scan-confirm flow (only fields the confirm modal needs)
|
||||
// @route GET /api/tickets/scan-preview/:qrCode
|
||||
// @access Private/Staff
|
||||
const getScanPreview = async (req, res) => {
|
||||
try {
|
||||
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
t.id,
|
||||
t."qrCode",
|
||||
t.quantity,
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
ro.id AS "registrationOptionId",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
ov.id AS "variantId",
|
||||
ov.name AS "variantName",
|
||||
e.id AS "evId",
|
||||
e.title AS "eventTitle",
|
||||
u.id AS "userId",
|
||||
u.name AS "userName",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "User" u ON u.id = t."userId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
res.json({
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
|
||||
},
|
||||
event: { id: r.evId, title: r.eventTitle },
|
||||
user: { id: r.userId, name: r.userName },
|
||||
usages: r.usages || []
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Scan ticket (mark as used, with optional partial quantity redemption)
|
||||
// @route POST /api/tickets/scan/:qrCode
|
||||
// @access Private/Staff
|
||||
const scanTicket = async (req, res) => {
|
||||
try {
|
||||
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
t.id,
|
||||
t.quantity,
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
e.title AS "eventTitle",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', tu.id,
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, e.id, eo.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
const ticket = {
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
event: { title: r.eventTitle },
|
||||
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } },
|
||||
usages: r.usages || []
|
||||
};
|
||||
|
||||
// Optional server-side guard: ensure ticket belongs to the requested event when provided
|
||||
const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim();
|
||||
if (providedEventId && ticket.eventId !== providedEventId) {
|
||||
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
|
||||
}
|
||||
|
||||
// Compute how many have already been redeemed
|
||||
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
return res.status(403).json({
|
||||
message: 'Ticket has already been fully used',
|
||||
ticket,
|
||||
remaining: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Determine how many to redeem this scan (defaults to all remaining)
|
||||
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
|
||||
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
||||
|
||||
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
|
||||
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
|
||||
const fullyUsed = newRemaining <= 0;
|
||||
|
||||
// Run the usage insert and ticket status update in parallel — they don't depend on each other
|
||||
const [ticketUsage] = await Promise.all([
|
||||
prisma.ticketUsage.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
ticketId: ticket.id,
|
||||
scannedById: req.user.id,
|
||||
quantityRedeemed: qtyToRedeem,
|
||||
}
|
||||
}),
|
||||
prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { isUsed: fullyUsed, updatedAt: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
res.json({
|
||||
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||
ticketUsage,
|
||||
ticket,
|
||||
qtyRedeemed: qtyToRedeem,
|
||||
totalRedeemed: newTotalRedeemed,
|
||||
remaining: newRemaining,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get tickets by event
|
||||
// @route GET /api/tickets/event/:eventId
|
||||
// @access Private/Staff
|
||||
const getTicketsByEvent = async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(1000, Math.max(1, parseInt(req.query.limit) || 1000));
|
||||
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: {
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit
|
||||
});
|
||||
|
||||
res.json(tickets);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Mark tickets as email sent
|
||||
// @route PUT /api/tickets/email-sent
|
||||
// @access Private/Admin
|
||||
const markTicketsAsEmailSent = async (req, res) => {
|
||||
try {
|
||||
const { ticketIds } = req.body;
|
||||
|
||||
if (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error('Ticket IDs are required');
|
||||
}
|
||||
|
||||
// Update tickets
|
||||
await prisma.ticket.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: ticketIds
|
||||
}
|
||||
},
|
||||
data: {
|
||||
emailSent: true,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: `${ticketIds.length} tickets marked as email sent` });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Email (and WhatsApp if preference set) tickets to user
|
||||
// @route POST /api/tickets/email
|
||||
// @access Private
|
||||
// Optional body param: channel ('email'|'whatsapp'|'both') — overrides user's notification preference for this request
|
||||
const emailTickets = async (req, res) => {
|
||||
try {
|
||||
// If caller specifies an explicit channel, build a minimal userOverride that forces that preference
|
||||
const { channel } = req.body || {};
|
||||
let userOverride = null;
|
||||
if (channel && ['email', 'whatsapp', 'both'].includes(channel)) {
|
||||
// Load base user data then override the preference
|
||||
const userId = req.user?.id;
|
||||
if (userId) {
|
||||
const baseUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true }
|
||||
});
|
||||
if (baseUser) {
|
||||
userOverride = { ...baseUser, notificationPreference: channel };
|
||||
}
|
||||
}
|
||||
}
|
||||
return await emailTicketsInternal(req, res, userOverride);
|
||||
} catch (error) {
|
||||
console.error('Error emailing tickets:', error);
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get recent ticket scans
|
||||
// @route GET /api/tickets/scans/recent
|
||||
// @access Private/Staff
|
||||
const getRecentScans = async (req, res) => {
|
||||
try {
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100));
|
||||
const eventId = req.query.eventId || undefined;
|
||||
|
||||
const scans = await prisma.ticketUsage.findMany({
|
||||
where: eventId ? { ticket: { eventId } } : undefined,
|
||||
orderBy: { scannedAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
scannedAt: true,
|
||||
quantityRedeemed: true,
|
||||
scannedBy: { select: { id: true, name: true } },
|
||||
ticket: {
|
||||
select: {
|
||||
id: true,
|
||||
event: { select: { title: true } },
|
||||
registrationOption: { select: { eventOption: { select: { name: true } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(scans);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get scan statistics
|
||||
// @route GET /api/tickets/scans/stats
|
||||
// @access Private/Staff
|
||||
const getScanStats = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.query.eventId || undefined;
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
// Total scans today (optionally by event)
|
||||
const whereBase = {
|
||||
scannedAt: { gte: startOfDay },
|
||||
...(eventId ? { ticket: { eventId } } : {})
|
||||
};
|
||||
|
||||
const [totalToday, myToday, lastHour] = await Promise.all([
|
||||
prisma.ticketUsage.count({ where: whereBase }),
|
||||
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }),
|
||||
prisma.ticketUsage.count({
|
||||
where: {
|
||||
...(eventId ? { ticket: { eventId } } : {}),
|
||||
scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
// Per-scanner breakdown today
|
||||
const byStaff = await prisma.ticketUsage.groupBy({
|
||||
by: ['scannedById'],
|
||||
where: whereBase,
|
||||
_count: { _all: true }
|
||||
});
|
||||
const staffIds = byStaff.map(b => b.scannedById);
|
||||
const staffUsers = staffIds.length > 0 ? await prisma.user.findMany({
|
||||
where: { id: { in: staffIds } },
|
||||
select: { id: true, name: true }
|
||||
}) : [];
|
||||
const nameMap = Object.fromEntries(staffUsers.map(u => [u.id, u.name]));
|
||||
|
||||
res.json({
|
||||
totalToday,
|
||||
myToday,
|
||||
lastHour,
|
||||
byStaff: byStaff.map(b => ({ scannedById: b.scannedById, name: nameMap[b.scannedById] || 'Staff', count: b._count._all }))
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send tickets to a specific phone/email (staff override for at-the-door)
|
||||
// @route POST /api/tickets/send-to
|
||||
// @access Private/Staff
|
||||
const sendTicketsTo = async (req, res) => {
|
||||
try {
|
||||
const { registrationId, ticketIds, channel, overridePhone, overrideEmail } = req.body;
|
||||
if (!registrationId && (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0)) {
|
||||
res.status(400); throw new Error('registrationId or ticketIds required');
|
||||
}
|
||||
if (!channel || !['email','whatsapp','both'].includes(channel)) {
|
||||
res.status(400); throw new Error('channel must be email, whatsapp, or both');
|
||||
}
|
||||
|
||||
// Determine which user owns the tickets to get their default contact info
|
||||
let ownerUserId;
|
||||
if (registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true } });
|
||||
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
||||
ownerUserId = reg.userId;
|
||||
} else {
|
||||
const firstTicket = await prisma.ticket.findUnique({ where: { id: ticketIds[0] }, select: { userId: true } });
|
||||
if (!firstTicket) { res.status(404); throw new Error('Ticket not found'); }
|
||||
ownerUserId = firstTicket.userId;
|
||||
}
|
||||
|
||||
// Use a mock req that targets the ticket owner; override contact details in user object via closure
|
||||
const originalUser = await prisma.user.findUnique({
|
||||
where: { id: ownerUserId },
|
||||
select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true }
|
||||
});
|
||||
if (!originalUser) { res.status(404); throw new Error('Ticket owner not found'); }
|
||||
|
||||
// Build a virtual user with override contact details
|
||||
const virtualUser = {
|
||||
...originalUser,
|
||||
email: overrideEmail || originalUser.email,
|
||||
phoneNumber: overridePhone || originalUser.phoneNumber,
|
||||
// Force the preference to match the requested channel
|
||||
notificationPreference: channel,
|
||||
};
|
||||
|
||||
// Reuse emailTickets logic via mock request but with virtual user
|
||||
// We build a minimal mock and call the internal flow directly
|
||||
const mockBody = registrationId ? { registrationId } : { ticketIds };
|
||||
const mockReq = { user: { id: ownerUserId }, body: mockBody, _virtualUser: virtualUser };
|
||||
const mockRes = { status: () => mockRes, json: (body) => { res.json(body); } };
|
||||
|
||||
// Call emailTickets with the virtual user override
|
||||
await emailTicketsInternal(mockReq, mockRes, virtualUser);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// Internal helper used by both emailTickets and sendTicketsTo
|
||||
async function emailTicketsInternal(req, res, userOverride) {
|
||||
const { ticketIds, registrationId } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
const user = userOverride || await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, name: true, phoneNumber: true, notificationPreference: true }
|
||||
});
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
const pref = user.notificationPreference || 'email';
|
||||
const noValidEmail = !user.email || user.email.endsWith('@guest.local');
|
||||
// For users without a valid email, WhatsApp is used as fallback regardless of preference
|
||||
const canSendWA = !!user.phoneNumber && ((pref === 'whatsapp' || pref === 'both') || noValidEmail);
|
||||
// Only block if neither channel can deliver
|
||||
if (noValidEmail && !canSendWA) {
|
||||
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
|
||||
}
|
||||
|
||||
let tickets;
|
||||
if (registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId, userId }
|
||||
});
|
||||
if (!registration) { res.status(404); throw new Error('Registration not found or does not belong to you'); }
|
||||
const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } });
|
||||
tickets = await prisma.ticket.findMany({
|
||||
where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId },
|
||||
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
} else {
|
||||
tickets = await prisma.ticket.findMany({
|
||||
where: { id: { in: ticketIds }, userId },
|
||||
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup by registrationOptionId
|
||||
{
|
||||
const seen = new Map(); const deduped = [];
|
||||
for (const t of tickets) {
|
||||
const key = t.registrationOptionId;
|
||||
if (!seen.has(key)) { seen.set(key, t); deduped.push(t); }
|
||||
else {
|
||||
const existing = seen.get(key);
|
||||
if ((existing.usages||[]).length === 0 && (t.usages||[]).length > 0) {
|
||||
seen.set(key, t); deduped[deduped.indexOf(existing)] = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
tickets = deduped;
|
||||
}
|
||||
if (tickets.length === 0) { res.status(404); throw new Error('No valid tickets found'); }
|
||||
|
||||
// Generate PDF
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const tempFilePath = path.join(__dirname, '..', '..', 'temp', `tickets-${userId}-${Date.now()}.pdf`);
|
||||
const tempDir = path.join(__dirname, '..', '..', 'temp');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 20 });
|
||||
const writeStream = fs.createWriteStream(tempFilePath);
|
||||
doc.pipe(writeStream);
|
||||
const pageWidth = doc.page.width - 40; const pageHeight = doc.page.height - 40;
|
||||
const ticketsPerRow = 2; const ticketsPerColumn = 4;
|
||||
const ticketWidth = pageWidth / ticketsPerRow; const ticketHeight = pageHeight / ticketsPerColumn;
|
||||
const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => {
|
||||
QRCode.toDataURL(ticket.qrCode, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
|
||||
})));
|
||||
const qrCodeMap = qrCodes.reduce((map, item) => { map[item.ticketId] = item.qrDataUrl; return map; }, {});
|
||||
let ticketIndex = 0;
|
||||
for (const ticket of tickets) {
|
||||
const row = Math.floor(ticketIndex % ticketsPerColumn);
|
||||
const col = Math.floor((ticketIndex / ticketsPerColumn) % ticketsPerRow);
|
||||
const x = col * ticketWidth + 20; const y = row * ticketHeight + 20;
|
||||
doc.rect(x, y, ticketWidth, ticketHeight).stroke();
|
||||
doc.font('Helvetica-Bold').fontSize(12).text(ticket.event.title, x + 10, y + 10, { width: ticketWidth - 20 });
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
|
||||
doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
const eventDate = new Date(ticket.event.startDate);
|
||||
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
|
||||
doc.text(`Purchased by: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45;
|
||||
const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90;
|
||||
doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize });
|
||||
doc.fontSize(8).text(`Ticket ID: ${ticket.id}`, x + 10, qrY + qrCodeSize + 5, { width: ticketWidth - 20, align: 'center' });
|
||||
ticketIndex++;
|
||||
if (ticketIndex % (ticketsPerRow * ticketsPerColumn) === 0 && ticketIndex < tickets.length) doc.addPage();
|
||||
}
|
||||
doc.end();
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
|
||||
const eventTitle = tickets[0].event.title;
|
||||
const eventDate = tickets[0].event.startDate
|
||||
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
const sentChannels = [];
|
||||
|
||||
const { sendMail, emailWrapper } = require('../utils/email');
|
||||
const { canWhatsApp, waPdf } = require('../utils/notify');
|
||||
const { buildWATicketCaption } = require('../utils/waMessages');
|
||||
const orgUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const ticketHtml = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">🎟️ Your tickets are here!</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Your tickets for <strong>${eventTitle}</strong>${eventDate ? ` on <strong>${eventDate}</strong>` : ''} are attached.</p>
|
||||
<p style="font-size:13px;color:#64748b;font-family:${ff}">Questions? Visit <a href="${orgUrl}" style="color:#2563eb">${orgUrl}</a>.</p>
|
||||
`, { preheader: `Your tickets for ${eventTitle} are attached!` });
|
||||
|
||||
// Send email unless preference is whatsapp-only
|
||||
if (pref !== 'whatsapp' && user.email && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@guest.local')) {
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Your tickets for ${eventTitle}`,
|
||||
html: ticketHtml,
|
||||
text: `Hi ${user.name},\n\nYour tickets for ${eventTitle}${eventDate ? ' on ' + eventDate : ''} are attached.\n\nSee you there!`,
|
||||
attachments: [{ filename: pdfFilename, path: tempFilePath, contentType: 'application/pdf' }],
|
||||
});
|
||||
sentChannels.push('email');
|
||||
}
|
||||
|
||||
// Send WhatsApp if preference includes it, or as fallback when email isn't available
|
||||
if (pref === 'whatsapp' || pref === 'both' || noValidEmail) {
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
const normalizedPhone = normalizeZAPhone(user.phoneNumber);
|
||||
if (normalizedPhone) {
|
||||
const caption = buildWATicketCaption({ name: user.name, eventTitle, eventDate });
|
||||
await waPdf({ ...user, phoneNumber: normalizedPhone, notificationPreference: 'both' }, tempFilePath, pdfFilename, caption).catch(() => {});
|
||||
sentChannels.push('whatsapp');
|
||||
}
|
||||
}
|
||||
|
||||
try { require('fs').unlinkSync(tempFilePath); } catch {}
|
||||
await prisma.ticket.updateMany({ where: { id: { in: tickets.map(t => t.id) } }, data: { emailSent: true, updatedAt: new Date() } });
|
||||
res.json({ message: `${tickets.length} ticket(s) sent via ${sentChannels.join(' & ') || 'no channel'}`, ticketIds: tickets.map(t => t.id) });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateTickets,
|
||||
getTickets,
|
||||
getUserTickets,
|
||||
getTicketById,
|
||||
getTicketByQrCode,
|
||||
getScanPreview,
|
||||
scanTicket,
|
||||
getTicketsByEvent,
|
||||
markTicketsAsEmailSent,
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
|
||||
// Setup multer storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadPath = path.join(__dirname, '..', '..','public', 'uploads', 'events');
|
||||
|
||||
// Check if the upload location exists
|
||||
try {
|
||||
if (!fs.existsSync(uploadPath)) {
|
||||
console.log(`Upload directory does not exist. Creating: ${uploadPath}`);
|
||||
fs.mkdirSync(uploadPath, { recursive: true });
|
||||
} else {
|
||||
// Verify we have write permissions to the directory
|
||||
fs.accessSync(uploadPath, fs.constants.W_OK);
|
||||
console.log(`Upload directory exists and is writable: ${uploadPath}`);
|
||||
}
|
||||
cb(null, uploadPath);
|
||||
} catch (error) {
|
||||
console.error(`Error with upload directory: ${error.message}`);
|
||||
cb(new Error(`Cannot access upload directory: ${error.message}`));
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueName = `${Date.now()}-${file.originalname}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
|
||||
// Multer middleware
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
|
||||
fileFilter: function (req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext)) {
|
||||
return cb(new Error('Only images are allowed'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Logo storage (separate subfolder)
|
||||
const logoStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
|
||||
try {
|
||||
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
|
||||
cb(null, uploadPath);
|
||||
} catch (error) {
|
||||
cb(new Error(`Cannot access upload directory: ${error.message}`));
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `logo-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const uploadLogo = multer({
|
||||
storage: logoStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
|
||||
fileFilter: function (req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp', '.svg'].includes(ext)) {
|
||||
return cb(new Error('Only image files are allowed'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Controller function
|
||||
const uploadEventImage = (req, res) => {
|
||||
// Check for multer errors which would be passed in req.multerError
|
||||
if (req.multerError) {
|
||||
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const imageUrl = `/uploads/events/${req.file.filename}`;
|
||||
res.status(200).json({ url: imageUrl });
|
||||
};
|
||||
|
||||
const uploadLogoImage = (req, res) => {
|
||||
if (req.multerError) {
|
||||
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'No file uploaded' });
|
||||
}
|
||||
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
upload,
|
||||
uploadEventImage,
|
||||
uploadLogo,
|
||||
uploadLogoImage,
|
||||
};
|
||||
@@ -0,0 +1,934 @@
|
||||
const prisma = require('../config/db');
|
||||
const { generateToken, hashPassword, comparePassword } = require('../config/auth');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Resolve a client IP from the request (works behind proxies)
|
||||
function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||
|
||||
// Fire-and-forget: send a login notification email with approximate geo location
|
||||
async function sendLoginNotification(user, req) {
|
||||
try {
|
||||
const ip = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || 'Unknown device';
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
|
||||
let location = 'Unknown location';
|
||||
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
|
||||
try {
|
||||
const geo = await axios.get(
|
||||
`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
if (geo.data?.status === 'success') {
|
||||
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
|
||||
if (parts.length) location = parts.join(', ');
|
||||
}
|
||||
} catch { /* geo lookup failure is non-fatal */ }
|
||||
}
|
||||
|
||||
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
|
||||
const { buildWALogin } = require('../utils/waMessages');
|
||||
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
|
||||
// Security: always email; also WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: 'New login to your Hope Events account', ...content });
|
||||
const { waText } = require('../utils/notify');
|
||||
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[login notification] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: send welcome email with next upcoming events
|
||||
async function sendWelcomeEmail(user) {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gt: new Date() } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 3,
|
||||
select: { title: true, startDate: true },
|
||||
});
|
||||
const { sendMail, buildWelcomeEmail } = require('../utils/email');
|
||||
const { buildWAWelcome } = require('../utils/waMessages');
|
||||
const content = buildWelcomeEmail({ name: user.name, events });
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
// Welcome is always sent via email; also via WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: 'Welcome to Hope Events!', ...content });
|
||||
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[welcome email] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// @desc Register a new user
|
||||
// @route POST /api/users
|
||||
// @access Public
|
||||
const registerUser = async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, phoneNumber, notificationPreference } = req.body;
|
||||
|
||||
// Normalize phone using SA-aware normalisation
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
|
||||
|
||||
// Validate preference — WhatsApp requires a valid phone number
|
||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let pref = allowedPrefs.includes(notificationPreference) ? notificationPreference : 'email';
|
||||
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
|
||||
pref = 'email'; // silently fall back if no valid number
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const userExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email },
|
||||
...(phone ? [{ phoneNumber: phone }] : [])
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (userExists) {
|
||||
res.status(400);
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
phoneNumber: phone,
|
||||
notificationPreference: pref,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Send welcome email in the background — don't block the response
|
||||
sendWelcomeEmail(user).catch(() => {});
|
||||
|
||||
res.status(201).json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion)
|
||||
});
|
||||
} else {
|
||||
res.status(400);
|
||||
throw new Error('Invalid user data');
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Auth user & get token
|
||||
// @route POST /api/users/login
|
||||
// @access Public
|
||||
const loginUser = async (req, res) => {
|
||||
try {
|
||||
const {email, password} = req.body;
|
||||
|
||||
const rawInput = email;
|
||||
|
||||
// Normalize phone input — convert SA local format (0xx) → international (27xx)
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
const normalizedPhone = normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
|
||||
|
||||
// Check for user email or phone
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: rawInput },
|
||||
...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : []),
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(401);
|
||||
throw new Error('User does not exist');
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
// If the account has a real email (not a guest placeholder), send an activation link via email
|
||||
if (user.email && !user.email.endsWith('@guest.local')) {
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true }
|
||||
});
|
||||
await prisma.passwordReset.create({
|
||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||
});
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||
sendMail({ to: user.email, subject: 'Activate your Hope Events account', ...content })
|
||||
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
||||
} catch (e) {
|
||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
||||
}
|
||||
// No real email — if they have a phone number, send the activation link via WhatsApp
|
||||
if (user.phoneNumber) {
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true }
|
||||
});
|
||||
await prisma.passwordReset.create({
|
||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||
});
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events');
|
||||
const waMessage = [
|
||||
`🔓 *Activate your ${orgName} account*`,
|
||||
'',
|
||||
`Hi ${user.name || 'there'},`,
|
||||
'',
|
||||
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
||||
'',
|
||||
activationUrl,
|
||||
'',
|
||||
`_This link expires in 24 hours._`,
|
||||
].join('\n');
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
||||
} catch (e) {
|
||||
console.warn('[activation token WA] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_TIME = 5 * 60 * 1000; // 5 min
|
||||
|
||||
// Check lock
|
||||
if (user.lockUntil && user.lockUntil > new Date()) {
|
||||
const now = new Date().getTime();
|
||||
const lockTime = new Date(user.lockUntil).getTime();
|
||||
|
||||
const diffMs = lockTime - now;
|
||||
const diffMinutes = Math.ceil(diffMs / (1000 * 60));
|
||||
|
||||
throw new Error(`Too many attempts. Try again in ${diffMinutes} minute(s).`);
|
||||
}
|
||||
|
||||
// Check password
|
||||
const isMatch = await comparePassword(password, user.password);
|
||||
|
||||
if (!isMatch) {
|
||||
const attempts = user.failedAttempts + 1;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedAttempts: attempts,
|
||||
lockUntil:
|
||||
attempts >= MAX_ATTEMPTS
|
||||
? new Date(Date.now() + LOCK_TIME)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error("Invalid email or password");
|
||||
}
|
||||
|
||||
// ✅ SUCCESS → reset attempts
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedAttempts: 0, lockUntil: null },
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true },
|
||||
});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(updated, req).catch(() => {});
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
role: updated.role,
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get user profile
|
||||
// @route GET /api/users/profile
|
||||
// @access Private
|
||||
const getUserProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json(user);
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update user profile
|
||||
// @route PUT /api/users/profile
|
||||
// @access Private
|
||||
const updateUserProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {};
|
||||
|
||||
// If a password change is requested, verify current password for safety
|
||||
let newHashedPassword = undefined;
|
||||
if (typeof password === 'string' && password.trim().length > 0) {
|
||||
const newPw = password.trim();
|
||||
if (!currentPassword || typeof currentPassword !== 'string' || currentPassword.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error('Current password is required to set a new password');
|
||||
}
|
||||
const matches = await comparePassword(currentPassword, user.password);
|
||||
if (!matches) {
|
||||
res.status(400);
|
||||
throw new Error('Current password is incorrect');
|
||||
}
|
||||
if (newPw.length < 8) {
|
||||
res.status(400);
|
||||
throw new Error('New password must be at least 8 characters long');
|
||||
}
|
||||
newHashedPassword = await hashPassword(newPw);
|
||||
}
|
||||
|
||||
// Normalize phone
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
let newPhone = user.phoneNumber;
|
||||
if (phoneNumber !== undefined) {
|
||||
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
|
||||
}
|
||||
|
||||
// Validate notification preference — WhatsApp requires a valid SA phone number
|
||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let newPref = user.notificationPreference;
|
||||
if (notificationPreference !== undefined) {
|
||||
newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference;
|
||||
if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) {
|
||||
newPref = 'email';
|
||||
}
|
||||
}
|
||||
|
||||
// Update user data
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: name || user.name,
|
||||
email: email || user.email,
|
||||
password: newHashedPassword ? newHashedPassword : user.password,
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
|
||||
// If the password was changed, send a security alert email (fire-and-forget)
|
||||
if (newHashedPassword) {
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
|
||||
sendMail({ to: updatedUser.email, subject: 'Your Hope Events password was changed', ...content })
|
||||
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(updatedUser, content.text).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get all users
|
||||
// @route GET /api/users
|
||||
// @access Private/Admin
|
||||
const getUsers = async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const select = {
|
||||
id: true, name: true, email: true, role: true,
|
||||
phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true
|
||||
};
|
||||
|
||||
// Build filters
|
||||
const where = {};
|
||||
if (req.query.isActive !== undefined) {
|
||||
where.isActive = req.query.isActive === 'true';
|
||||
}
|
||||
if (req.query.role) {
|
||||
where.role = req.query.role;
|
||||
}
|
||||
if (req.query.search) {
|
||||
const s = req.query.search.trim();
|
||||
where.OR = [
|
||||
{ name: { contains: s, mode: 'insensitive' } },
|
||||
{ email: { contains: s, mode: 'insensitive' } },
|
||||
{ phoneNumber: { contains: s, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await prisma.$transaction([
|
||||
prisma.user.findMany({ where, select, orderBy: { name: 'asc' }, skip, take: limit }),
|
||||
prisma.user.count({ where })
|
||||
]);
|
||||
|
||||
res.json({ data: users, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check whether an account already exists for a given email and/or phone
|
||||
// @route GET /api/users/check-exists
|
||||
// @access Private/Supervisor
|
||||
const checkUserExists = async (req, res) => {
|
||||
try {
|
||||
const email = typeof req.query.email === 'string' ? req.query.email.trim() : '';
|
||||
const rawPhone = typeof req.query.phone === 'string' ? req.query.phone.trim() : '';
|
||||
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
const phone = normalizeZAPhone(rawPhone) || (rawPhone ? rawPhone.replace(/\D/g, '') : '');
|
||||
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
|
||||
|
||||
const searchClauses = [
|
||||
...(email ? [{ email }] : []),
|
||||
...(phone ? [{ phoneNumber: phone }] : []),
|
||||
...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []),
|
||||
];
|
||||
|
||||
if (searchClauses.length === 0) {
|
||||
return res.json({ exists: false });
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { OR: searchClauses },
|
||||
select: { email: true, phoneNumber: true },
|
||||
});
|
||||
|
||||
res.json({
|
||||
exists: !!existingUser,
|
||||
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
|
||||
hasPhone: !!existingUser?.phoneNumber,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get user by ID
|
||||
// @route GET /api/users/:id
|
||||
// @access Private/Admin
|
||||
const getUserById = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json(user);
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update user
|
||||
// @route PUT /api/users/:id
|
||||
// @access Private/Admin
|
||||
const updateUser = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password } = req.body;
|
||||
|
||||
// Prepare data update, allow admin to set a new password
|
||||
const data = {
|
||||
name: name || user.name,
|
||||
email: email || user.email,
|
||||
role: role || user.role,
|
||||
isActive: isActive !== undefined ? isActive : user.isActive,
|
||||
phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
if (password && typeof password === 'string' && password.trim().length > 0) {
|
||||
data.password = await hashPassword(password.trim());
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete user
|
||||
// @route DELETE /api/users/:id
|
||||
// @access Private/Admin
|
||||
const deleteUser = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Instead of deleting, we deactivate the user
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
isActive: false,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'User deactivated' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Anonymize user data (GDPR / "right to be forgotten")
|
||||
// @route POST /api/users/:id/anonymize
|
||||
// @access Private/Admin
|
||||
const anonymizeUser = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${req.params.id}@deleted.local`,
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Request password reset
|
||||
// @route POST /api/users/forgot
|
||||
// @access Public
|
||||
const requestPasswordReset = async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
if (!email || typeof email !== 'string') {
|
||||
res.status(400);
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
// Explicitly check existence as requested
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'Email not found' });
|
||||
}
|
||||
|
||||
// Create token valid for 1 hour
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
// If PasswordReset model isn't available (migration not run), return clear error
|
||||
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
|
||||
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
|
||||
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
|
||||
}
|
||||
|
||||
// Invalidate previous tokens (optional)
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
|
||||
data: { used: true }
|
||||
});
|
||||
|
||||
await prisma.passwordReset.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
used: false
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
|
||||
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
|
||||
// Security: always email
|
||||
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
|
||||
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
|
||||
// Also WhatsApp if preferred (security message — sent in addition to email)
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(user, emailContent.text).catch(() => {});
|
||||
|
||||
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reset password using token
|
||||
// @route POST /api/users/reset
|
||||
// @access Public
|
||||
const resetPassword = async (req, res) => {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
if (!token || !password) {
|
||||
res.status(400);
|
||||
throw new Error('Token and new password are required');
|
||||
}
|
||||
|
||||
// If PasswordReset model isn't available, avoid crashing and return a generic error
|
||||
if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function' || typeof prisma.passwordReset.update !== 'function') {
|
||||
res.status(400);
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
const reset = await prisma.passwordReset.findUnique({ where: { token } });
|
||||
if (!reset || reset.used || reset.expiresAt < new Date()) {
|
||||
res.status(400);
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: reset.userId } });
|
||||
if (!user || !user.isActive) {
|
||||
res.status(400);
|
||||
throw new Error('User not found or inactive');
|
||||
}
|
||||
|
||||
const newHashed = await hashPassword(password);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }),
|
||||
prisma.passwordReset.update({ where: { token }, data: { used: true } })
|
||||
]);
|
||||
|
||||
res.json({ message: 'Password has been reset successfully' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Activate account using token (for inactive/guest accounts)
|
||||
// @route POST /api/users/activate
|
||||
// @access Public
|
||||
const activateAccount = async (req, res) => {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
if (!token || !password) {
|
||||
res.status(400);
|
||||
throw new Error('Token and password are required');
|
||||
}
|
||||
if (typeof password !== 'string' || password.trim().length < 8) {
|
||||
res.status(400);
|
||||
throw new Error('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function') {
|
||||
res.status(400);
|
||||
throw new Error('Invalid or expired token');
|
||||
}
|
||||
|
||||
const reset = await prisma.passwordReset.findUnique({ where: { token } });
|
||||
if (!reset || reset.used || reset.expiresAt < new Date()) {
|
||||
res.status(400);
|
||||
throw new Error('Invalid or expired activation link');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: reset.userId } });
|
||||
if (!user) {
|
||||
res.status(400);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const newHashed = await hashPassword(password.trim());
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: newHashed, isActive: true, updatedAt: new Date() }
|
||||
}),
|
||||
prisma.passwordReset.update({ where: { token }, data: { used: true } })
|
||||
]);
|
||||
|
||||
const updated = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true }
|
||||
});
|
||||
|
||||
// Send welcome email (skip guest/placeholder accounts)
|
||||
if (updated.email && !updated.email.endsWith('@guest.local')) {
|
||||
sendWelcomeEmail(updated).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Account activated successfully.',
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
role: updated.role,
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Revoke all active sessions for the logged-in user (increments tokenVersion)
|
||||
// @route POST /api/users/revoke-sessions
|
||||
// @access Private
|
||||
const revokeMySession = async (req, res) => {
|
||||
try {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
select: { id: true, role: true, tokenVersion: true },
|
||||
});
|
||||
// Bumping tokenVersion invalidates every existing token, including the one
|
||||
// this request just used. Issue a fresh token for the current device so it
|
||||
// isn't logged out too.
|
||||
res.json({
|
||||
message: 'All other sessions have been signed out.',
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Admin: revoke all sessions for a specific user
|
||||
// @route POST /api/users/:id/revoke-sessions
|
||||
// @access Private/Admin
|
||||
const adminRevokeUserSessions = async (req, res) => {
|
||||
try {
|
||||
const target = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!target) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
});
|
||||
res.json({ message: `Sessions revoked for ${target.name}.` });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Close (and optionally anonymise) the logged-in user's own account
|
||||
// @route POST /api/users/close-account
|
||||
// @access Private
|
||||
const closeAccount = async (req, res) => {
|
||||
try {
|
||||
const { deleteData = false, password } = req.body || {};
|
||||
|
||||
// Re-verify password before allowing account closure
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
const passwordOk = await comparePassword(password, user.password);
|
||||
if (!passwordOk) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
|
||||
// Capture real details before any anonymisation so the email goes to the right address
|
||||
const realEmail = user.email;
|
||||
const realName = user.name;
|
||||
|
||||
if (deleteData) {
|
||||
// Anonymise: wipe all personal data while keeping the row intact for referential integrity
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${uuidv4()}@deleted.invalid`,
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
|
||||
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
|
||||
// WhatsApp while we still have phone (send before data wipe completes in-flight)
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
|
||||
}
|
||||
return res.json({ message: 'Your account and personal data have been removed.' });
|
||||
} else {
|
||||
// Soft-deactivate only
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
isActive: false,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Send closure confirmation
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: false });
|
||||
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {});
|
||||
}
|
||||
return res.json({ message: 'Your account has been deactivated.' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
registerUser,
|
||||
loginUser,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
checkUserExists,
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
anonymizeUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
activateAccount,
|
||||
revokeMySession,
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
};
|
||||
@@ -0,0 +1,572 @@
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
|
||||
// @desc Handle Yoco webhook events
|
||||
// @route POST /api/webhooks/yoco
|
||||
// @access Public
|
||||
const handleYocoWebhook = async (req, res) => {
|
||||
try {
|
||||
const headers = req.headers;
|
||||
const requestBody = req.rawBody;
|
||||
|
||||
// Log every JSON request body for webhook controller
|
||||
try {
|
||||
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
||||
let parsedBody = null;
|
||||
if (typeof requestBody === 'string') {
|
||||
try {
|
||||
parsedBody = JSON.parse(requestBody);
|
||||
} catch (e) {
|
||||
// Not valid JSON; keep as raw string
|
||||
}
|
||||
} else if (requestBody && typeof requestBody === 'object') {
|
||||
parsedBody = requestBody;
|
||||
}
|
||||
} catch (logErr) {
|
||||
// Fail-safe: never block webhook processing due to logging issues
|
||||
console.error('Failed to log webhook JSON request:', logErr);
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
const id = headers['webhook-id'];
|
||||
const timestamp = headers['webhook-timestamp'];
|
||||
|
||||
const rawSigHeader = headers['webhook-signature'];
|
||||
if (!id || !timestamp || !rawSigHeader) {
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Missing webhook headers',
|
||||
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) }
|
||||
};
|
||||
return res.status(400).json(responseObj);
|
||||
}
|
||||
|
||||
// Reject replayed requests (Yoco recommends a 3-minute tolerance window)
|
||||
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
|
||||
if (isNaN(webhookTimeMs) || Math.abs(Date.now() - webhookTimeMs) > 3 * 60 * 1000) {
|
||||
return res.status(400).json({ success: false, message: 'Webhook timestamp outside acceptable window' });
|
||||
}
|
||||
|
||||
// Construct the signed content
|
||||
const signedContent = `${id}.${timestamp}.${requestBody}`;
|
||||
|
||||
// Get the webhook secret from environment variables
|
||||
const secret = process.env.YOCO_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables');
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Server configuration error',
|
||||
details: { error: 'Missing webhook secret configuration' }
|
||||
};
|
||||
return res.status(500).json(responseObj);
|
||||
}
|
||||
|
||||
const secretBytes = Buffer.from(secret.split('_')[1], "base64");
|
||||
|
||||
// Calculate expected signature
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secretBytes)
|
||||
.update(signedContent)
|
||||
.digest('base64');
|
||||
|
||||
// Accept if any of the space-separated signatures match (supports key rotation)
|
||||
const signatures = rawSigHeader.split(' ').map(s => s.split(',')[1]).filter(Boolean);
|
||||
const signatureValid = signatures.some(sig => {
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(sig));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!signatureValid) {
|
||||
console.error('Invalid webhook signature');
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Invalid signature',
|
||||
details: { error: 'Webhook signature verification failed' }
|
||||
};
|
||||
return res.status(403).json(responseObj);
|
||||
}
|
||||
|
||||
// Parse the webhook payload
|
||||
const webhookData = JSON.parse(requestBody);
|
||||
|
||||
// Extract checkoutId from payload or metadata
|
||||
const receivedCheckoutId = webhookData?.payload?.checkoutId || webhookData?.payload?.metadata?.checkoutId || webhookData?.payload?.metadata?.checkout_id;
|
||||
|
||||
// Optional filtering: allow only specific checkoutIds or prefixes for this endpoint
|
||||
try {
|
||||
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
|
||||
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
|
||||
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const hasFilter = allowedIds.length > 0 || allowedPrefixes.length > 0;
|
||||
if (hasFilter) {
|
||||
const idMatches = receivedCheckoutId && (
|
||||
allowedIds.includes(receivedCheckoutId) ||
|
||||
allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref))
|
||||
);
|
||||
if (!idMatches) {
|
||||
// Store event for audit/unreconciled tracking
|
||||
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store filtered yoco event:', e?.message || e); }
|
||||
// Acknowledge but ignore processing for unrelated checkout flows
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
|
||||
data: {
|
||||
filtered: true,
|
||||
receivedCheckoutId,
|
||||
allowedIdsCount: allowedIds.length,
|
||||
allowedPrefixesCount: allowedPrefixes.length
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
}
|
||||
}
|
||||
} catch (filterErr) {
|
||||
// Never block webhook due to filter parsing errors; log only
|
||||
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
|
||||
}
|
||||
|
||||
// Automatic filtering by database registration record, with donation allowance
|
||||
try {
|
||||
if (receivedCheckoutId) {
|
||||
const existingRegistration = await prisma.registration.findFirst({
|
||||
where: { checkoutId: receivedCheckoutId },
|
||||
select: { id: true }
|
||||
});
|
||||
if (!existingRegistration) {
|
||||
// Allow donations (no registration) to proceed based on metadata
|
||||
const meta = webhookData?.payload?.metadata || {};
|
||||
const isDonation = meta?.type === 'donation' || !!meta?.isDonation || !!meta?.eventId;
|
||||
if (!isDonation) {
|
||||
try {
|
||||
await saveYocoTransaction(webhookData);
|
||||
} catch (saveErr) {
|
||||
console.warn('Failed to save unreconciled transaction:', saveErr?.message || saveErr);
|
||||
}
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
|
||||
data: {
|
||||
filtered: true,
|
||||
reason: 'no_registration_for_checkoutId',
|
||||
receivedCheckoutId
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (dbFilterErr) {
|
||||
// Do not fail webhook due to DB filter issues
|
||||
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
|
||||
}
|
||||
|
||||
let processedData = null;
|
||||
|
||||
// Process different event types
|
||||
switch (webhookData.type) {
|
||||
case 'payment.succeeded':
|
||||
processedData = await handlePaymentSucceeded(webhookData);
|
||||
break;
|
||||
// Add more event types as needed
|
||||
default:
|
||||
console.log(`Unhandled webhook event type: ${webhookData.type}`);
|
||||
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store unhandled yoco event:', e?.message || e); }
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: 'Webhook received but event type not handled',
|
||||
data: {
|
||||
eventType: webhookData.type,
|
||||
webhookId: id,
|
||||
timestamp: timestamp
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
}
|
||||
|
||||
// Return detailed success response
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: `Successfully processed ${webhookData.type} webhook`,
|
||||
data: {
|
||||
eventType: webhookData.type,
|
||||
webhookId: id,
|
||||
timestamp: timestamp,
|
||||
processedData
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: error.message,
|
||||
details: {
|
||||
error: error.stack,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
return res.status(500).json(responseObj);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle payment.succeeded event
|
||||
const handlePaymentSucceeded = async (webhookData) => {
|
||||
try {
|
||||
const paymentData = webhookData.payload;
|
||||
|
||||
// Log the payment data
|
||||
console.log('Payment succeeded:', paymentData);
|
||||
|
||||
// Extract payment details
|
||||
const {
|
||||
id: yocoPaymentId,
|
||||
amount,
|
||||
currency,
|
||||
status,
|
||||
metadata,
|
||||
paymentMethodDetails: method,
|
||||
checkoutId
|
||||
} = paymentData;
|
||||
|
||||
// Check if payment already exists to avoid duplicates
|
||||
const existingPayment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
externalId: yocoPaymentId
|
||||
}
|
||||
});
|
||||
|
||||
if (existingPayment) {
|
||||
console.log(`Payment ${yocoPaymentId} already processed`);
|
||||
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: existingPayment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction for existing payment:', e?.message || e); }
|
||||
return {
|
||||
status: 'already_processed',
|
||||
paymentId: existingPayment.id,
|
||||
externalId: yocoPaymentId
|
||||
};
|
||||
}
|
||||
|
||||
// Find the registration by checkoutId
|
||||
let registration = null;
|
||||
if (checkoutId) {
|
||||
registration = await prisma.registration.findFirst({
|
||||
where: {
|
||||
checkoutId: checkoutId
|
||||
},
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true
|
||||
}
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If no registration found by checkoutId, try metadata
|
||||
if (!registration && metadata && metadata.registrationId) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: {
|
||||
id: metadata.registrationId
|
||||
},
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true
|
||||
}
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve userId: registration owner → metadata → first active admin (never use a non-existent UUID)
|
||||
let resolvedUserId = registration?.userId || metadata?.userId || null;
|
||||
if (!resolvedUserId) {
|
||||
try {
|
||||
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
|
||||
resolvedUserId = adminUser?.id || null;
|
||||
} catch (e) { /* ignore — payment will fail below if still null */ }
|
||||
}
|
||||
|
||||
// Create payment record
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(), // Generate a UUID for the payment
|
||||
amount: amount / 100, // Convert cents to your currency unit
|
||||
method: method.type, // The type of payment from the webhook
|
||||
status: status === 'succeeded' ? 'completed' : status,
|
||||
externalId: yocoPaymentId,
|
||||
registrationId: registration?.id || null,
|
||||
userId: resolvedUserId,
|
||||
eventId: registration?.eventId || metadata?.eventId || null,
|
||||
isDonation: !registration?.id
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
registration: registration ? {
|
||||
include: {
|
||||
event: true
|
||||
}
|
||||
} : undefined,
|
||||
event: (registration?.eventId || metadata?.eventId) ? true : undefined
|
||||
}
|
||||
});
|
||||
|
||||
// If this payment is for a registration, update the registration status
|
||||
let updatedRegistration = null;
|
||||
let generatedTickets = [];
|
||||
|
||||
if (registration) {
|
||||
updatedRegistration = await updateRegistrationStatus(registration.id);
|
||||
|
||||
// If registration status is 'paid', tickets were generated
|
||||
if (updatedRegistration.status === 'paid') {
|
||||
// Find the generated tickets
|
||||
generatedTickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
registrationOption: {
|
||||
registrationId: registration.id
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
qrCode: true,
|
||||
isUsed: true,
|
||||
registrationOptionId: true,
|
||||
eventId: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Payment ${yocoPaymentId} processed successfully`);
|
||||
|
||||
// Mark the Yoco transaction as reconciled and link the payment
|
||||
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: payment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction after payment create:', e?.message || e); }
|
||||
|
||||
// Send emails: payment confirmation first, then tickets (guarantees order)
|
||||
const _whPaymentId = payment.id;
|
||||
const _whUserId = registration?.userId;
|
||||
const _whRegId = registration?.id;
|
||||
const _whTicketsGenerated = generatedTickets.length > 0;
|
||||
(async () => {
|
||||
try {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
await sendPaymentEmails(_whPaymentId);
|
||||
} catch (e) {
|
||||
console.error('Failed to send webhook payment emails:', e);
|
||||
}
|
||||
if (_whTicketsGenerated && _whUserId && _whRegId) {
|
||||
const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Return processed data
|
||||
return {
|
||||
status: 'success',
|
||||
payment: {
|
||||
id: payment.id,
|
||||
externalId: yocoPaymentId,
|
||||
amount: payment.amount,
|
||||
method: payment.method,
|
||||
status: payment.status,
|
||||
createdAt: payment.createdAt
|
||||
},
|
||||
registration: registration ? {
|
||||
id: registration.id,
|
||||
status: updatedRegistration ? updatedRegistration.status : registration.status,
|
||||
eventId: registration.eventId,
|
||||
eventTitle: registration.event?.title,
|
||||
userId: registration.userId,
|
||||
userName: registration.user?.name
|
||||
} : null,
|
||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing payment.succeeded event:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Update registration status based on payments
|
||||
const updateRegistrationStatus = async (registrationId) => {
|
||||
try {
|
||||
// Refresh early-bird pricing before computing totalDue — ensures expired/exhausted tiers
|
||||
// are accounted for so we never mark a registration paid based on stale prices.
|
||||
try { await refreshPricingForRegistration(registrationId); } catch (e) {
|
||||
console.warn('[webhook] Price refresh failed:', e?.message);
|
||||
}
|
||||
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: {
|
||||
include: {
|
||||
earlyBirdTiers: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
throw new Error(`Registration ${registrationId} not found`);
|
||||
}
|
||||
|
||||
// Calculate total amount paid
|
||||
const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
|
||||
// Calculate total amount due (uses priceSnapshot — refreshed above)
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
|
||||
// Update registration status based on payment
|
||||
let newStatus;
|
||||
if (totalPaid >= totalDue) {
|
||||
newStatus = 'paid';
|
||||
} else if (totalPaid > 0) {
|
||||
newStatus = 'partial_paid';
|
||||
} else {
|
||||
newStatus = 'pending';
|
||||
}
|
||||
|
||||
const updatedRegistration = await prisma.registration.update({
|
||||
where: { id: registrationId },
|
||||
data: {
|
||||
status: newStatus,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Generate tickets if status is "paid" (email handled by caller after payment email)
|
||||
let generatedTickets = [];
|
||||
if (newStatus === 'paid') {
|
||||
try {
|
||||
generatedTickets = await generateTicketsForRegistration(registrationId);
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
// Don't throw the error, just log it - we still want to update the registration status
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated registration
|
||||
return {
|
||||
...updatedRegistration,
|
||||
totalPaid,
|
||||
totalDue,
|
||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating registration status:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWhatsappWebhook = async (req, res) => {
|
||||
try {
|
||||
const { body } = req;
|
||||
const { message } = body;
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleYocoWebhook,
|
||||
handleWhatsappWebhook,
|
||||
};
|
||||
|
||||
// Helper: save or update Yoco transactions for any webhook event
|
||||
async function saveYocoTransaction(webhookData, updates = {}) {
|
||||
try {
|
||||
const payload = webhookData?.payload || {};
|
||||
const externalId = payload?.id || webhookData?.id;
|
||||
if (!externalId) {
|
||||
throw new Error('Missing external id in webhook payload');
|
||||
}
|
||||
|
||||
const amount = typeof payload?.amount === 'number' ? payload.amount : null; // cents if provided
|
||||
const currency = payload?.currency || null;
|
||||
const createdDateStr = payload?.createdDate || webhookData?.createdDate;
|
||||
const createdDate = createdDateStr ? new Date(createdDateStr) : null;
|
||||
const checkoutId = payload?.checkoutId || payload?.metadata?.checkoutId || payload?.metadata?.checkout_id || null;
|
||||
const methodType = payload?.paymentMethodDetails?.type || null;
|
||||
|
||||
// Try find existing
|
||||
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
|
||||
if (existing) {
|
||||
// Update with any provided updates
|
||||
if (updates && Object.keys(updates).length > 0) {
|
||||
return await prisma.yocoTransaction.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...updates, updatedAt: new Date() }
|
||||
});
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new
|
||||
const record = await prisma.yocoTransaction.create({
|
||||
data: {
|
||||
externalId,
|
||||
amount,
|
||||
currency,
|
||||
createdDate,
|
||||
checkoutId,
|
||||
methodType,
|
||||
reconciled: false,
|
||||
raw: webhookData,
|
||||
...updates
|
||||
}
|
||||
});
|
||||
return record;
|
||||
} catch (e) {
|
||||
// Unique constraint race: fetch existing
|
||||
if (e?.code === 'P2002') {
|
||||
const payload = webhookData?.payload || {};
|
||||
const externalId = payload?.id || webhookData?.id;
|
||||
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
|
||||
if (existing && updates && Object.keys(updates).length > 0) {
|
||||
return await prisma.yocoTransaction.update({ where: { id: existing.id }, data: { ...updates, updatedAt: new Date() } });
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
}
|
||||
|
||||
function getFrontendBaseUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function replacePlaceholders(str, ctx) {
|
||||
if (!str) return str;
|
||||
return String(str)
|
||||
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
|
||||
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
|
||||
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
|
||||
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '');
|
||||
}
|
||||
|
||||
function parseFreeformPhones(lines) {
|
||||
const recipients = [];
|
||||
const input = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/);
|
||||
for (const raw of input) {
|
||||
const s = String(raw || '').trim();
|
||||
if (!s) continue;
|
||||
// Format: Name <phone> or just phone
|
||||
const m = s.match(/^(.*?)<\s*([+\d\s()-]+)\s*>\s*$/);
|
||||
if (m) {
|
||||
recipients.push({ phone: m[2].trim(), name: m[1].trim().replace(/^"|"$/g, '').trim() });
|
||||
} else {
|
||||
// Accept raw phone-like strings
|
||||
const digits = s.replace(/\D/g, '');
|
||||
if (digits.length >= 9) recipients.push({ phone: s, name: '' });
|
||||
}
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
// @desc Preview WhatsApp broadcast recipients
|
||||
// @route POST /api/whatsapp-broadcasts/preview
|
||||
// @access Private/Supervisor or Admin
|
||||
const previewWhatsAppBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { userIds, phones, eventId } = req.body || {};
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
|
||||
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||
let users = [];
|
||||
if (ids.length) {
|
||||
users = await prisma.user.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, name: true, phoneNumber: true, notificationPreference: true }
|
||||
});
|
||||
}
|
||||
|
||||
const extra = parseFreeformPhones(phones);
|
||||
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const phone = normalizeZAPhone(u.phoneNumber);
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const phone = normalizeZAPhone(r.phone);
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send WhatsApp broadcast now
|
||||
// @route POST /api/whatsapp-broadcasts/send
|
||||
// @access Private/Supervisor or Admin
|
||||
const sendWhatsAppBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { message, userIds, phones, eventId } = req.body || {};
|
||||
if (!message) {
|
||||
return res.status(400).json({ message: 'message is required' });
|
||||
}
|
||||
|
||||
const { normalizeZAPhone, sendText } = require('../utils/whatsapp');
|
||||
|
||||
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||
let users = [];
|
||||
if (ids.length) {
|
||||
users = await prisma.user.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, name: true, phoneNumber: true }
|
||||
});
|
||||
}
|
||||
const extra = parseFreeformPhones(phones);
|
||||
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
const phone = normalizeZAPhone(u.phoneNumber);
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: u.name || '' });
|
||||
}
|
||||
for (const r of extra) {
|
||||
const phone = normalizeZAPhone(r.phone);
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients with phone numbers' });
|
||||
}
|
||||
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventTitle = event?.title || '';
|
||||
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
|
||||
const eventLink = event ? `${getFrontendBaseUrl()}/events/${encodeURIComponent(event.id)}` : '';
|
||||
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink };
|
||||
const finalMessage = replacePlaceholders(message, ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[wa-broadcast] failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Schedule a WhatsApp broadcast
|
||||
// @route POST /api/whatsapp-broadcasts/schedule
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleWhatsAppBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, message, userIds, phones, eventId } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!message) return res.status(400).json({ message: 'message is required' });
|
||||
|
||||
const payload = { message, userIds, phones, eventId };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
broadcast: true,
|
||||
channel: 'whatsapp',
|
||||
scheduledAt: when.toISOString(),
|
||||
createdById: req.user?.id || null,
|
||||
payload,
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: 'WhatsApp broadcast scheduled', job: created });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast };
|
||||
@@ -0,0 +1,224 @@
|
||||
const {
|
||||
getConfig, setConfig,
|
||||
getStatus, startSession, restartSession, logoutSession,
|
||||
getQr, requestPairingCode,
|
||||
createInstance, deleteInstance,
|
||||
} = require('../utils/whatsapp');
|
||||
const { sendMail } = require('../utils/email');
|
||||
|
||||
// ─── Config management ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/whatsapp/config
|
||||
* Returns the current WAWP credentials (token is masked).
|
||||
*/
|
||||
const getConfigHandler = async (req, res) => {
|
||||
try {
|
||||
const { token, instanceId } = await getConfig();
|
||||
// Mask the token for display — show first 4 chars + asterisks
|
||||
const maskedToken = token
|
||||
? token.slice(0, 4) + '*'.repeat(Math.max(0, token.length - 4))
|
||||
: '';
|
||||
res.json({
|
||||
tokenMasked: maskedToken,
|
||||
instanceId: instanceId || '',
|
||||
hasToken: !!token,
|
||||
hasInstance: !!instanceId,
|
||||
configured: !!(token && instanceId),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.message || 'Failed to load config' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/whatsapp/config
|
||||
* Body: { token, instanceId }
|
||||
* Saves credentials to DB; instanceId is optional (keep existing if omitted).
|
||||
*/
|
||||
const saveConfigHandler = async (req, res) => {
|
||||
try {
|
||||
const { token, instanceId } = req.body || {};
|
||||
|
||||
const current = await getConfig();
|
||||
|
||||
// Resolve token: '_clear_' resets it; blank/absent keeps existing
|
||||
let resolvedToken = current.token;
|
||||
if (token === '_clear_') {
|
||||
resolvedToken = '';
|
||||
} else if (token && token.trim()) {
|
||||
resolvedToken = token.trim();
|
||||
}
|
||||
|
||||
// Resolve instanceId: blank/absent keeps existing
|
||||
const resolvedInstanceId = (instanceId && instanceId.trim())
|
||||
? instanceId.trim()
|
||||
: current.instanceId || '';
|
||||
|
||||
await setConfig(resolvedToken, resolvedInstanceId);
|
||||
res.json({ message: 'WAWP configuration saved successfully.' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.message || 'Failed to save config' });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Instance lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/whatsapp/create-instance
|
||||
* Body: { name? }
|
||||
* Creates a new WAWP session instance and saves its id to the DB.
|
||||
*/
|
||||
const createInstanceHandler = async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body || {};
|
||||
const data = await createInstance(name);
|
||||
res.json({ message: 'Instance created and saved.', ...data });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/whatsapp/delete-instance
|
||||
* Deletes the current WAWP instance and clears it from the DB.
|
||||
*/
|
||||
const deleteInstanceHandler = async (req, res) => {
|
||||
try {
|
||||
const data = await deleteInstance();
|
||||
res.json({ message: 'Instance deleted.', ...data });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Admin session endpoints ──────────────────────────────────────────────────
|
||||
|
||||
const getStatusHandler = async (req, res) => {
|
||||
try {
|
||||
res.json(await getStatus());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getQrHandler = async (req, res) => {
|
||||
try {
|
||||
const data = await getQr();
|
||||
// Normalise: strip leading "data:image/png;base64," if WAWP already includes it,
|
||||
// so the client always receives a clean base64 string it can prefix itself.
|
||||
if (data?.qr) {
|
||||
data.qr = data.qr.replace(/^data:image\/png;base64,/, '');
|
||||
}
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const requestCodeHandler = async (req, res) => {
|
||||
try {
|
||||
const { phoneNumber } = req.body || {};
|
||||
if (!phoneNumber) return res.status(400).json({ message: 'phoneNumber is required' });
|
||||
res.json(await requestPairingCode(phoneNumber));
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const logoutHandler = async (req, res) => {
|
||||
try {
|
||||
res.json(await logoutSession());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const startHandler = async (req, res) => {
|
||||
try {
|
||||
res.json(await startSession());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const restartHandler = async (req, res) => {
|
||||
try {
|
||||
res.json(await restartSession());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Webhook — auto-recovery ──────────────────────────────────────────────────
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // ms between each attempt
|
||||
const STATUS_WAIT_MS = 10_000; // wait after restart before checking
|
||||
|
||||
const handleWebhook = async (req, res) => {
|
||||
// Acknowledge immediately so WAWP doesn't time out
|
||||
res.status(200).json({ ok: true });
|
||||
|
||||
try {
|
||||
const { event, session } = req.body || {};
|
||||
if (event !== 'session.status') return;
|
||||
|
||||
const status = session?.status;
|
||||
// Only auto-recover on FAILED — STOPPED may be intentional
|
||||
if (status !== 'FAILED') return;
|
||||
|
||||
console.warn('[whatsapp webhook] Session FAILED — starting recovery...');
|
||||
|
||||
let recovered = false;
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAYS[i]));
|
||||
try {
|
||||
await restartSession();
|
||||
await new Promise(r => setTimeout(r, STATUS_WAIT_MS));
|
||||
const info = await getStatus();
|
||||
const s = info?.status;
|
||||
if (s === 'WORKING' || s === 'SCAN_QR_CODE' || s === 'STARTING') {
|
||||
recovered = true;
|
||||
console.info(`[whatsapp webhook] Session recovered on attempt ${i + 1} (status: ${s})`);
|
||||
break;
|
||||
}
|
||||
console.warn(`[whatsapp webhook] Attempt ${i + 1}: status still ${s}`);
|
||||
} catch (e) {
|
||||
console.warn(`[whatsapp webhook] Attempt ${i + 1} error:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!recovered) {
|
||||
console.error(`[whatsapp webhook] Could not recover after ${MAX_ATTEMPTS} attempts — sending admin alert`);
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|
||||
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
|
||||
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/whatsapp`;
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
await sendMail({
|
||||
to: adminEmail,
|
||||
subject: 'WhatsApp session is down — action required',
|
||||
text: `The Hope Events WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
|
||||
html: `<p>The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[whatsapp webhook] Unhandled error in recovery handler:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getConfigHandler,
|
||||
saveConfigHandler,
|
||||
createInstanceHandler,
|
||||
deleteInstanceHandler,
|
||||
getStatusHandler,
|
||||
getQrHandler,
|
||||
requestCodeHandler,
|
||||
logoutHandler,
|
||||
startHandler,
|
||||
restartHandler,
|
||||
handleWebhook,
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
const prisma = require('../config/db');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
|
||||
function getYocoModel() {
|
||||
// Gracefully handle environments where the Prisma client hasn't been regenerated
|
||||
// and YocoTransaction model is not available yet.
|
||||
return prisma && prisma.yocoTransaction ? prisma.yocoTransaction : null;
|
||||
}
|
||||
|
||||
// @desc Get all Yoco transactions (optionally filter by reconciled and ignored)
|
||||
// @route GET /api/yoco-transactions
|
||||
// @access Private/Admin or Supervisor
|
||||
const getAllYocoTransactions = async (req, res) => {
|
||||
try {
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const { reconciled, ignored } = req.query;
|
||||
const where = {};
|
||||
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
|
||||
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
|
||||
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
|
||||
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
|
||||
const items = await Yoco.findMany({
|
||||
where,
|
||||
orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }]
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: items });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Yoco transactions:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get unreconciled Yoco transactions (not reconciled and not ignored)
|
||||
// @route GET /api/yoco-transactions/unreconciled
|
||||
// @access Private/Admin or Supervisor
|
||||
const getUnreconciledYocoTransactions = async (req, res) => {
|
||||
try {
|
||||
// Basic role check if auth middleware sets req.user
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
|
||||
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
|
||||
const items = await Yoco.findMany({
|
||||
where: { reconciled: false, ignored: false },
|
||||
orderBy: { createdDate: 'desc' }
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: items });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unreconciled Yoco transactions:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reconcile a Yoco transaction to a Registration or Donation (creates Payment)
|
||||
// @route POST /api/yoco-transactions/:id/reconcile
|
||||
// @access Private/Admin or Supervisor
|
||||
const reconcileYocoTransaction = async (req, res) => {
|
||||
try {
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { registrationId, eventId } = req.body || {};
|
||||
|
||||
const ytx = await Yoco.findUnique({ where: { id } });
|
||||
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
||||
if (ytx.reconciled && ytx.paymentId) {
|
||||
return res.status(409).json({ success: false, message: 'Already reconciled', data: ytx });
|
||||
}
|
||||
|
||||
// Load registration if provided
|
||||
let registration = null;
|
||||
if (registrationId) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
event: true,
|
||||
user: true,
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
|
||||
}
|
||||
|
||||
// Determine payment fields
|
||||
const amountFloat = typeof ytx.amount === 'number' ? (ytx.amount / 100) : 0;
|
||||
if (!amountFloat || amountFloat <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid amount on YocoTransaction for reconciliation' });
|
||||
}
|
||||
|
||||
const externalId = ytx.externalId || undefined;
|
||||
|
||||
// Resolve a valid userId for the Payment to satisfy FK constraints
|
||||
let resolvedUserId = null;
|
||||
if (registration?.userId) {
|
||||
resolvedUserId = registration.userId;
|
||||
} else if (ytx?.raw?.payload?.metadata?.userId) {
|
||||
const metaUserId = String(ytx.raw.payload.metadata.userId);
|
||||
try {
|
||||
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
|
||||
if (exists) resolvedUserId = metaUserId;
|
||||
} catch {}
|
||||
}
|
||||
if (!resolvedUserId && req.user?.id) {
|
||||
// Fallback to the acting supervisor/admin to avoid FK violations
|
||||
resolvedUserId = req.user.id;
|
||||
}
|
||||
if (!resolvedUserId) {
|
||||
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
|
||||
}
|
||||
|
||||
// Build payment data
|
||||
const paymentData = {
|
||||
amount: amountFloat,
|
||||
method: ytx.methodType || 'card',
|
||||
userId: resolvedUserId,
|
||||
registrationId: registration?.id || null,
|
||||
eventId: registration?.eventId || eventId || null,
|
||||
isDonation: !registration?.id,
|
||||
externalId: externalId,
|
||||
status: 'completed'
|
||||
};
|
||||
|
||||
// Ensure donation has an eventId
|
||||
if (!registration && !paymentData.eventId) {
|
||||
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
|
||||
}
|
||||
|
||||
// Create payment
|
||||
// Preserve original Yoco creation time to correctly evaluate early-bird pricing
|
||||
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
|
||||
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
|
||||
|
||||
// Optionally update registration status when applicable and generate/email tickets if paid
|
||||
let generatedTickets = [];
|
||||
if (registration) {
|
||||
try {
|
||||
const updatedReg = await updateRegistrationStatus(registration.id);
|
||||
if (updatedReg && updatedReg.status === 'paid') {
|
||||
try {
|
||||
generatedTickets = await generateTicketsForRegistration(registration.id);
|
||||
if (Array.isArray(generatedTickets) && generatedTickets.length > 0) {
|
||||
try {
|
||||
const mockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
await emailTickets(mockReq, mockRes);
|
||||
} catch (emailErr) {
|
||||
console.error('Error emailing tickets after Yoco reconciliation:', emailErr);
|
||||
}
|
||||
}
|
||||
} catch (genErr) {
|
||||
console.error('Error generating tickets after Yoco reconciliation:', genErr);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// log but do not fail reconciliation
|
||||
console.warn('Failed to update registration after reconciliation:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Send emails for the reconciled payment
|
||||
try {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
await sendPaymentEmails(payment.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to send payment emails after reconciliation:', e);
|
||||
}
|
||||
|
||||
// Update yoco transaction as reconciled
|
||||
const updatedTx = await Yoco.update({
|
||||
where: { id: ytx.id },
|
||||
data: { reconciled: true, paymentId: payment.id }
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined } });
|
||||
} catch (error) {
|
||||
console.error('Failed to reconcile Yoco transaction:', error);
|
||||
// Handle unique externalId conflicts (if a Payment with same externalId already exists)
|
||||
if (error?.code === 'P2002') {
|
||||
try {
|
||||
const existing = await prisma.payment.findFirst({ where: { externalId: (await getYocoModel()?.findUnique({ where: { id: req.params.id } }))?.externalId } });
|
||||
if (existing) {
|
||||
const updatedTx = await getYocoModel()?.update({ where: { id: req.params.id }, data: { reconciled: true, paymentId: existing.id } });
|
||||
if (updatedTx) {
|
||||
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment: existing }, message: 'Linked to existing payment' });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// Minimal registration status update mirroring webhook logic
|
||||
async function updateRegistrationStatus(registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) return null;
|
||||
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
|
||||
let newStatus = 'pending';
|
||||
if (totalPaid >= totalDue) newStatus = 'paid';
|
||||
else if (totalPaid > 0) newStatus = 'partial_paid';
|
||||
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
// @desc Ignore a Yoco transaction (mark as ignored and reconciled without creating a payment)
|
||||
// @route POST /api/yoco-transactions/:id/ignore
|
||||
// @access Private/Admin or Supervisor
|
||||
const ignoreYocoTransaction = async (req, res) => {
|
||||
try {
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
const { id } = req.params;
|
||||
const ytx = await Yoco.findUnique({ where: { id } });
|
||||
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
||||
|
||||
if (ytx.reconciled && ytx.ignored) {
|
||||
return res.status(200).json({ success: true, data: ytx, message: 'Already ignored' });
|
||||
}
|
||||
|
||||
const updated = await Yoco.update({
|
||||
where: { id },
|
||||
data: { ignored: true, reconciled: true }
|
||||
});
|
||||
return res.status(200).json({ success: true, data: updated });
|
||||
} catch (error) {
|
||||
console.error('Failed to ignore Yoco transaction:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllYocoTransactions,
|
||||
getUnreconciledYocoTransactions,
|
||||
reconcileYocoTransaction,
|
||||
ignoreYocoTransaction
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
const rateLimit = require("express-rate-limit");
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// Protect routes - verify token
|
||||
const protect = async (req, res, next) => {
|
||||
let token;
|
||||
|
||||
// Check if token exists in headers
|
||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||
try {
|
||||
// Get token from header
|
||||
token = req.headers.authorization.split(' ')[1];
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Get user from the token (exclude password)
|
||||
req.user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
phoneNumber: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!req.user) {
|
||||
res.status(401);
|
||||
return next(new Error('User not found'));
|
||||
}
|
||||
|
||||
if (!req.user.isActive) {
|
||||
res.status(401);
|
||||
return next(new Error('User account is deactivated'));
|
||||
}
|
||||
|
||||
// Revocation check — tokenVersion in JWT must match DB
|
||||
// Old tokens without tokenVersion are treated as version 0
|
||||
const tokenVer = decoded.tokenVersion ?? 0;
|
||||
if (tokenVer !== req.user.tokenVersion) {
|
||||
res.status(401);
|
||||
return next(new Error('Session has been revoked. Please log in again.'));
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(401);
|
||||
return next(new Error('Not authorized, token failed'));
|
||||
}
|
||||
} else {
|
||||
res.status(401);
|
||||
return next(new Error('Not authorized, no token'));
|
||||
}
|
||||
};
|
||||
|
||||
// Admin only middleware
|
||||
const admin = (req, res, next) => {
|
||||
if (req.user && req.user.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
res.status(403);
|
||||
return next(new Error('Not authorized as an admin'));
|
||||
}
|
||||
};
|
||||
|
||||
// Staff or higher middleware
|
||||
const staff = (req, res, next) => {
|
||||
if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor' || req.user.role === 'staff')) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403);
|
||||
return next(new Error('Not authorized as staff'));
|
||||
}
|
||||
};
|
||||
|
||||
// Supervisor or higher middleware
|
||||
const supervisor = (req, res, next) => {
|
||||
if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor')) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403);
|
||||
return next(new Error('Not authorized as a supervisor'));
|
||||
}
|
||||
};
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 15,
|
||||
message: "Too many login attempts. Try again later.",
|
||||
});
|
||||
|
||||
// Optional auth — populates req.user if a valid token is present, but never rejects the request
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer')) {
|
||||
return next();
|
||||
}
|
||||
try {
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
|
||||
});
|
||||
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
|
||||
req.user = user;
|
||||
}
|
||||
} catch {
|
||||
// Token invalid or expired — proceed without user
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { protect, admin, staff, supervisor, loginLimiter, optionalAuth };
|
||||
@@ -0,0 +1,20 @@
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// Not found error handler — don't echo the URL back (leaks route structure)
|
||||
const notFound = (req, res, next) => {
|
||||
const error = new Error('Not Found');
|
||||
res.status(404);
|
||||
next(error);
|
||||
};
|
||||
|
||||
// General error handler
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
const statusCode = res.statusCode === 200 ? 500 : res.statusCode;
|
||||
|
||||
res.status(statusCode).json({
|
||||
message: safeErrorMessage(err),
|
||||
stack: process.env.NODE_ENV === 'production' ? undefined : err.stack,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { notFound, errorHandler };
|
||||
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { scheduleAutomations } = require('../controllers/automationController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// Schedule multiple automation emails for an event
|
||||
router.post('/schedule', protect, supervisor, scheduleAutomations);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBanner, setBanner } = require('../controllers/bannerController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', getBanner);
|
||||
router.post('/', protect, supervisor, setBanner);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewBroadcast, sendBroadcast, scheduleBroadcast } = require('../controllers/broadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.post('/preview', protect, supervisor, previewBroadcast);
|
||||
router.post('/send', protect, supervisor, sendBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCashup, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit', protect, supervisor, getCashupAudit);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventCashup);
|
||||
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
|
||||
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
||||
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCosts, createEventCost, updateEventCost, deleteEventCost } = require('../controllers/costController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Mounted at /api — paths below match the documented /api/events/:eventId/costs and /api/costs/:id shape
|
||||
router.get('/events/:eventId/costs', protect, supervisor, getEventCosts);
|
||||
router.post('/events/:eventId/costs', protect, admin, createEventCost);
|
||||
router.put('/costs/:id', protect, admin, updateEventCost);
|
||||
router.delete('/costs/:id', protect, admin, deleteEventCost);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,86 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createEvent,
|
||||
getEvents,
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
getEventById,
|
||||
updateEvent,
|
||||
getEventNotifyRecipients,
|
||||
updateEventNotifyRecipients,
|
||||
deleteEvent,
|
||||
createEventOption,
|
||||
updateEventOption,
|
||||
deleteEventOption,
|
||||
createOptionVariant,
|
||||
updateOptionVariant,
|
||||
deleteOptionVariant,
|
||||
listEventAttachments,
|
||||
uploadEventAttachment,
|
||||
deleteEventAttachment,
|
||||
attachmentsStatus,
|
||||
attachmentsSync,
|
||||
emailEventAttendees,
|
||||
scheduleEmailEventAttendees,
|
||||
whatsappEventAttendees,
|
||||
scheduleWhatsappEventAttendees,
|
||||
getEventByAlias
|
||||
} = require('../controllers/eventController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Public routes
|
||||
router.get('/', getEvents);
|
||||
|
||||
// Staff/supervisor/admin: all events including hidden, optional past/inactive filters
|
||||
router.get('/all', protect, getEventsAll);
|
||||
|
||||
// Admin routes
|
||||
router.get('/admin/all', protect, admin, getAllEvents);
|
||||
router.get('/attachments/status', protect, admin, attachmentsStatus);
|
||||
router.post('/attachments/sync', protect, admin, attachmentsSync);
|
||||
|
||||
// Public route for single event (keep after specific admin routes).
|
||||
// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin
|
||||
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
|
||||
router.get('/:id', optionalAuth, getEventById);
|
||||
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
||||
|
||||
// Create/Update/Delete event
|
||||
router.post('/', protect, supervisor, createEvent);
|
||||
router.route('/:id')
|
||||
.put(protect, supervisor, updateEvent)
|
||||
.delete(protect, admin, deleteEvent);
|
||||
|
||||
// Notification recipients: who gets registration/payment/daily-summary emails for this event
|
||||
router.route('/:id/notify-recipients')
|
||||
.get(protect, supervisor, getEventNotifyRecipients)
|
||||
.put(protect, supervisor, updateEventNotifyRecipients);
|
||||
|
||||
// Attachments routes
|
||||
router.get('/:id/attachments', listEventAttachments); // public read (used by event detail page)
|
||||
router.post('/:id/attachments', protect, supervisor, ...uploadEventAttachment);
|
||||
router.delete('/:eventId/attachments/:attachmentId', protect, supervisor, deleteEventAttachment);
|
||||
|
||||
// Bulk email attendees
|
||||
router.post('/:id/email-attendees', protect, supervisor, emailEventAttendees);
|
||||
// Schedule bulk email to attendees
|
||||
router.post('/:id/email-attendees/schedule', protect, supervisor, scheduleEmailEventAttendees);
|
||||
// Bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees', protect, supervisor, whatsappEventAttendees);
|
||||
// Schedule bulk WhatsApp message to attendees
|
||||
router.post('/:id/whatsapp-attendees/schedule', protect, supervisor, scheduleWhatsappEventAttendees);
|
||||
|
||||
// Event options routes
|
||||
router.post('/:id/options', protect, supervisor, createEventOption);
|
||||
router.route('/options/:id')
|
||||
.put(protect, supervisor, updateEventOption)
|
||||
.delete(protect, admin, deleteEventOption);
|
||||
|
||||
// Option variant routes
|
||||
router.post('/options/:id/variants', protect, supervisor, createOptionVariant);
|
||||
router.route('/variants/:id')
|
||||
.put(protect, supervisor, updateOptionVariant)
|
||||
.delete(protect, admin, deleteOptionVariant);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listFormResponses } = require('../controllers/formController');
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
|
||||
// Staff or higher can view submitted forms
|
||||
router.get('/responses', protect, staff, listFormResponses);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,33 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createPayment,
|
||||
getPayments,
|
||||
getUserPayments,
|
||||
getPaymentById,
|
||||
getPaymentsByRegistration,
|
||||
getPaymentsByEvent,
|
||||
assignDonationToRegistration,
|
||||
createYocoCheckout,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
} = require('../controllers/paymentController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
|
||||
// Protected routes
|
||||
router.post('/', protect, supervisor, createPayment);
|
||||
router.post('/yoco-checkout', protect, createYocoCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.get('/:id', protect, getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, supervisor, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
|
||||
router.post('/refund', protect, supervisor, createRefund);
|
||||
router.get('/admin/stats', protect, admin, getPaymentStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,37 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
createRegistration,
|
||||
getRegistrations,
|
||||
getUserRegistrations,
|
||||
getRegistrationById,
|
||||
updateRegistrationStatus,
|
||||
cancelRegistration,
|
||||
getRegistrationsByEvent,
|
||||
createManualRegistration,
|
||||
updateRegistrationOptions,
|
||||
submitFormResponses, replaceFormResponses,
|
||||
getFormDraft, saveFormDraft,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Registration - auth optional when event doesn't require it
|
||||
router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
router.get('/:id/forms/draft', optionalAuth, getFormDraft);
|
||||
router.put('/:id/forms/draft', optionalAuth, saveFormDraft);
|
||||
router.post('/:id/forms/responses', optionalAuth, submitFormResponses);
|
||||
router.put('/:id/forms/responses', protect, supervisor, replaceFormResponses);
|
||||
|
||||
// Admin/Staff routes
|
||||
router.get('/', protect, staff, getRegistrations);
|
||||
router.put('/:id', protect, staff, updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, createManualRegistration);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { generatePdf, emailPdf } = require('../controllers/reportController');
|
||||
const { protect } = require('../middleware/authMiddleware');
|
||||
|
||||
// Generate and download PDF
|
||||
router.post('/pdf', protect, generatePdf);
|
||||
|
||||
// Email PDF to current user
|
||||
router.post('/email', protect, emailPdf);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listScheduledEmails, updateScheduledEmail, deleteScheduledEmail } = require('../controllers/scheduledEmailsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// All routes require supervisor (or admin via middleware logic) access
|
||||
router.get('/', protect, supervisor, listScheduledEmails);
|
||||
router.patch('/:id', protect, supervisor, updateScheduledEmail);
|
||||
router.delete('/:id', protect, supervisor, deleteScheduledEmail);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getSections,
|
||||
createSection,
|
||||
deleteSection,
|
||||
updateSection
|
||||
} = require('../controllers/sectionController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', protect, staff, getSections);
|
||||
router.post('/', protect, supervisor, createSection);
|
||||
router.delete('/:id', protect, admin, deleteSection);
|
||||
router.put('/:id', protect, supervisor, updateSection);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getSettings, getAllSettings, updateSettings, needsSetup, testSmtp } = require('../controllers/settingsController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/needs-setup', needsSetup);
|
||||
router.get('/all', protect, admin, getAllSettings);
|
||||
router.get('/', getSettings);
|
||||
router.put('/', protect, admin, updateSettings);
|
||||
router.post('/test-smtp', protect, admin, testSmtp);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { setupRegister, runSetup } = require('../controllers/settingsController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Step 1 of setup: create the admin account, returns a JWT token
|
||||
router.post('/register', setupRegister);
|
||||
|
||||
// Step 2 of setup: finalize settings (requires the token from /register)
|
||||
router.post('/', protect, admin, runSetup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats } = require('../controllers/statsController');
|
||||
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
|
||||
// single response instead of the dashboard making several separate calls (and, previously,
|
||||
// pulling full payments/events lists just to reduce them to a couple of numbers).
|
||||
router.get('/staff', protect, staff, getStaffDashboardStats);
|
||||
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
|
||||
router.get('/admin', protect, admin, getAdminDashboardStats);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,39 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
generateTickets,
|
||||
getTickets,
|
||||
getUserTickets,
|
||||
getTicketById,
|
||||
getTicketByQrCode,
|
||||
getScanPreview,
|
||||
scanTicket,
|
||||
getTicketsByEvent,
|
||||
markTicketsAsEmailSent,
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
|
||||
// Protected routes
|
||||
router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
|
||||
// Staff routes
|
||||
router.post('/send-to', protect, staff, sendTicketsTo);
|
||||
router.get('/scan-preview/:qrCode', protect, staff, getScanPreview);
|
||||
router.get('/qr/:qrCode', protect, staff, getTicketByQrCode);
|
||||
router.post('/scan/:qrCode', protect, staff, scanTicket);
|
||||
router.get('/event/:eventId', protect, staff, getTicketsByEvent);
|
||||
router.get('/scans/recent', protect, staff, getRecentScans);
|
||||
router.get('/scans/stats', protect, staff, getScanStats);
|
||||
|
||||
// Admin routes
|
||||
router.post('/generate', protect, supervisor, generateTickets);
|
||||
router.get('/', protect, supervisor, getTickets);
|
||||
router.put('/email-sent', protect, admin, markTicketsAsEmailSent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,41 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage } = require('../controllers/uploadController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// @route POST /api/uploads/event-image
|
||||
// @desc Upload an image for an event
|
||||
// @access Private (Supervisor+ recommended)
|
||||
router.post('/event-image', protect, supervisor, (req, res, next) => {
|
||||
upload.single('image')(req, res, (err) => {
|
||||
if (err) {
|
||||
req.multerError = err;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, uploadEventImage);
|
||||
|
||||
// @route POST /api/uploads/logo
|
||||
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
|
||||
async function logoAccess(req, res, next) {
|
||||
try {
|
||||
const count = await prisma.user.count();
|
||||
if (count === 0) return next(); // first-time setup
|
||||
return protect(req, res, () => admin(req, res, next));
|
||||
} catch {
|
||||
return protect(req, res, () => admin(req, res, next));
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/logo', logoAccess, (req, res, next) => {
|
||||
uploadLogo.single('image')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
});
|
||||
}, uploadLogoImage);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,52 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
registerUser,
|
||||
loginUser,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
checkUserExists,
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
anonymizeUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
activateAccount,
|
||||
revokeMySession,
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
} = require('../controllers/userController');
|
||||
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
|
||||
|
||||
// Public routes
|
||||
router.post('/', registerUser);
|
||||
router.post('/login', loginLimiter, loginUser);
|
||||
router.post('/forgot', requestPasswordReset);
|
||||
router.post('/reset', resetPassword);
|
||||
router.post('/activate', activateAccount);
|
||||
|
||||
// Protected routes
|
||||
router.route('/profile')
|
||||
.get(protect, getUserProfile)
|
||||
.put(protect, updateUserProfile);
|
||||
|
||||
router.post('/revoke-sessions', protect, revokeMySession);
|
||||
router.post('/close-account', protect, closeAccount);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
.get(protect, supervisor, getUsers);
|
||||
|
||||
router.get('/check-exists', protect, supervisor, checkUserExists);
|
||||
|
||||
router.route('/:id')
|
||||
.get(protect, admin, getUserById)
|
||||
.put(protect, admin, updateUser)
|
||||
.delete(protect, admin, deleteUser);
|
||||
|
||||
router.post('/:id/revoke-sessions', protect, admin, adminRevokeUserSessions);
|
||||
router.post('/:id/anonymize', protect, admin, anonymizeUser);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
|
||||
|
||||
// Yoco webhook endpoint
|
||||
router.post('/yoco', handleYocoWebhook);
|
||||
router.post('/whatsapp', handleWhatsappWebhook);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { previewWhatsAppBroadcast, sendWhatsAppBroadcast, scheduleWhatsAppBroadcast } = require('../controllers/whatsappBroadcastController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
router.post('/preview', protect, supervisor, previewWhatsAppBroadcast);
|
||||
router.post('/send', protect, supervisor, sendWhatsAppBroadcast);
|
||||
router.post('/schedule', protect, supervisor, scheduleWhatsAppBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,37 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getConfigHandler,
|
||||
saveConfigHandler,
|
||||
createInstanceHandler,
|
||||
deleteInstanceHandler,
|
||||
getStatusHandler,
|
||||
getQrHandler,
|
||||
requestCodeHandler,
|
||||
logoutHandler,
|
||||
startHandler,
|
||||
restartHandler,
|
||||
handleWebhook,
|
||||
} = require('../controllers/whatsappController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Public webhook (called by WAWP servers)
|
||||
router.post('/webhook', handleWebhook);
|
||||
|
||||
// Admin-only: credentials config
|
||||
router.get( '/config', protect, admin, getConfigHandler);
|
||||
router.post('/config', protect, admin, saveConfigHandler);
|
||||
|
||||
// Admin-only: instance lifecycle
|
||||
router.post('/create-instance', protect, admin, createInstanceHandler);
|
||||
router.post('/delete-instance', protect, admin, deleteInstanceHandler);
|
||||
|
||||
// Admin-only: session management
|
||||
router.get( '/status', protect, admin, getStatusHandler);
|
||||
router.get( '/qr', protect, admin, getQrHandler);
|
||||
router.post('/request-code', protect, admin, requestCodeHandler);
|
||||
router.post('/logout', protect, admin, logoutHandler);
|
||||
router.post('/start', protect, admin, startHandler);
|
||||
router.post('/restart', protect, admin, restartHandler);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllYocoTransactions, getUnreconciledYocoTransactions, reconcileYocoTransaction, ignoreYocoTransaction } = require('../controllers/yocoTransactionsController');
|
||||
const { protect, supervisor} = require('../middleware/authMiddleware');
|
||||
|
||||
// GET all Yoco transactions (admin/supervisor)
|
||||
router.get('/', protect, supervisor, getAllYocoTransactions);
|
||||
|
||||
// GET all unreconciled Yoco transactions (admin/supervisor)
|
||||
router.get('/unreconciled', protect, supervisor, getUnreconciledYocoTransactions);
|
||||
|
||||
// POST reconcile a Yoco transaction
|
||||
router.post('/:id/reconcile', protect, supervisor, reconcileYocoTransaction);
|
||||
|
||||
// POST ignore a Yoco transaction
|
||||
router.post('/:id/ignore', protect, supervisor, ignoreYocoTransaction);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,107 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Checks whether the Prisma client has the EventAttachment model and the table is queryable.
|
||||
* @param {import('@prisma/client').PrismaClient} prisma
|
||||
*/
|
||||
async function canUseEventAttachment(prisma) {
|
||||
const hasModel = !!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
|
||||
if (!hasModel) return { ok: false, reason: 'NO_MODEL' };
|
||||
try {
|
||||
await prisma.eventAttachment.findFirst({});
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: 'QUERY_ERROR', error: String(e?.message || e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all manifest files for event attachments and inserts missing rows into DB.
|
||||
* It is idempotent: it will skip entries that already exist by id. If id lookup fails,
|
||||
* it tries to avoid duplicates using a composite check (eventId + filename + size).
|
||||
*
|
||||
* @param {import('@prisma/client').PrismaClient} prisma
|
||||
* @param {{ dryRun?: boolean, removeManifestAfterImport?: boolean }} [options]
|
||||
* @returns {Promise<{processedFiles:number, imported:number, skipped:number, errors:Array<{file:string,error:string}>, details:Array<{file:string, imported:number, skipped:number}>}>}
|
||||
*/
|
||||
async function syncManifestsToDb(prisma, options = {}) {
|
||||
const { dryRun = false, removeManifestAfterImport = false } = options;
|
||||
const baseDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
const result = { processedFiles: 0, imported: 0, skipped: 0, errors: [], details: [] };
|
||||
|
||||
const check = await canUseEventAttachment(prisma);
|
||||
if (!check.ok) {
|
||||
return { ...result, errors: [{ file: '*', error: `Attachments model not usable (${check.reason})${check.error ? ': ' + check.error : ''}` }] };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(baseDir)) return result;
|
||||
const files = fs.readdirSync(baseDir).filter(f => f.endsWith('.attachments.json'));
|
||||
|
||||
for (const file of files) {
|
||||
const manifestPath = path.join(baseDir, file);
|
||||
result.processedFiles += 1;
|
||||
let list = [];
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
list = JSON.parse(raw) || [];
|
||||
} catch (e) {
|
||||
result.errors.push({ file, error: 'Failed to parse JSON: ' + String(e?.message || e) });
|
||||
continue;
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const entry of list) {
|
||||
try {
|
||||
// Check existing by id first
|
||||
const existsById = entry?.id ? await prisma.eventAttachment.findUnique({ where: { id: entry.id } }) : null;
|
||||
if (existsById) { skipped++; continue; }
|
||||
|
||||
// Check using composite heuristic to avoid duplicates
|
||||
const maybeExisting = await prisma.eventAttachment.findFirst({
|
||||
where: {
|
||||
eventId: entry.eventId,
|
||||
filename: entry.filename,
|
||||
size: typeof entry.size === 'number' ? entry.size : undefined,
|
||||
}
|
||||
});
|
||||
if (maybeExisting) { skipped++; continue; }
|
||||
|
||||
if (!dryRun) {
|
||||
await prisma.eventAttachment.create({
|
||||
data: {
|
||||
id: entry.id || undefined,
|
||||
eventId: entry.eventId,
|
||||
originalName: entry.originalName || entry.filename || 'file',
|
||||
filename: entry.filename,
|
||||
mimeType: entry.mimeType || 'application/octet-stream',
|
||||
size: typeof entry.size === 'number' ? entry.size : 0,
|
||||
url: entry.url,
|
||||
createdAt: entry.createdAt ? new Date(entry.createdAt) : undefined,
|
||||
}
|
||||
});
|
||||
}
|
||||
imported++;
|
||||
} catch (e) {
|
||||
result.errors.push({ file, error: 'Insert failed: ' + String(e?.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
result.imported += imported;
|
||||
result.skipped += skipped;
|
||||
result.details.push({ file, imported, skipped });
|
||||
|
||||
// Optionally remove manifest if all entries are in DB now
|
||||
if (!dryRun && removeManifestAfterImport && imported > 0) {
|
||||
try {
|
||||
fs.unlinkSync(manifestPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { syncManifestsToDb, canUseEventAttachment };
|
||||
@@ -0,0 +1,204 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const METHOD_BUCKETS = ['cash', 'card', 'eft'];
|
||||
const ALL_METHODS = ['cash', 'card', 'eft', 'other'];
|
||||
|
||||
// Standard South African Rand note/coin denominations used for the cash count grid.
|
||||
const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1];
|
||||
|
||||
function emptyByMethod(fill = 0) {
|
||||
return { cash: fill, card: fill, eft: fill, other: fill };
|
||||
}
|
||||
|
||||
// Normalizes a free-text Payment.method into one of the fixed cashup buckets.
|
||||
function bucketForMethod(method) {
|
||||
const m = String(method || '').toLowerCase();
|
||||
if (m.includes('cash')) return 'cash';
|
||||
if (m.includes('eft')) return 'eft';
|
||||
if (m.includes('card') || m.includes('yoco')) return 'card';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// Throws if the event is closed. Callers wrap this in their existing try/catch
|
||||
// (res.statusCode is set before throwing, matching the rest of the controllers).
|
||||
async function assertEventOpen(eventId, res) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, cashupStatus: true } });
|
||||
if (!event) {
|
||||
if (res) res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
if (event.cashupStatus === 'closed') {
|
||||
if (res) res.status(400);
|
||||
throw new Error('This event is closed. Reopen it (admin only) before making changes.');
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
// Same check, but resolves the event via a registrationId first (for payment/registration flows
|
||||
// that receive a registrationId rather than an eventId directly).
|
||||
async function assertRegistrationEventOpen(registrationId, res) {
|
||||
const registration = await prisma.registration.findUnique({ where: { id: registrationId }, select: { eventId: true } });
|
||||
if (!registration) {
|
||||
if (res) res.status(404);
|
||||
throw new Error('Registration not found');
|
||||
}
|
||||
await assertEventOpen(registration.eventId, res);
|
||||
return registration;
|
||||
}
|
||||
|
||||
// Core computation shared by the cashup preview/close endpoints and the Cashup/Finance/Profit reports.
|
||||
//
|
||||
// Two figures must never be conflated: "revenue" (gross, profit-relevant) and "expected cash"
|
||||
// (net of any costs paid out of a method's float, for physically verifying a drawer/float).
|
||||
// Mixing them up would double-subtract method-tagged costs from profit.
|
||||
async function computeEventFinancials(eventId) {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, cashupStatus: true, cashupDraft: true, closedAt: true, closedById: true, reopenedAt: true, reopenedById: true }
|
||||
});
|
||||
if (!event) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const [payments, costs, tickets, salesRows, history] = await Promise.all([
|
||||
prisma.payment.findMany({
|
||||
where: { OR: [{ eventId }, { registration: { eventId } }] }
|
||||
}),
|
||||
prisma.eventCost.findMany({ where: { eventId }, include: { eventOption: { select: { id: true, name: true } } } }),
|
||||
prisma.ticket.findMany({
|
||||
where: { eventId },
|
||||
include: { registrationOption: { select: { eventOptionId: true } } }
|
||||
}),
|
||||
prisma.registrationOption.findMany({
|
||||
where: { registration: { eventId, status: 'paid' } },
|
||||
include: { eventOption: { select: { id: true, name: true, price: true } } }
|
||||
}),
|
||||
prisma.eventCashup.findMany({
|
||||
where: { eventId },
|
||||
include: {
|
||||
lines: { include: { denominations: true } },
|
||||
performedBy: { select: { id: true, name: true, email: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
]);
|
||||
|
||||
// Ticket quantity sold per EventOption (used to price per-item costs)
|
||||
const quantityByOption = {};
|
||||
for (const t of tickets) {
|
||||
const optId = t.registrationOption?.eventOptionId;
|
||||
if (!optId) continue;
|
||||
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
|
||||
}
|
||||
|
||||
const nonRefundPayments = payments.filter(p => p.amount > 0);
|
||||
const unallocatedDonations = payments.filter(p => p.isDonation && !p.registrationId);
|
||||
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + p.amount, 0);
|
||||
const totalDonations = payments.filter(p => p.isDonation).reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
const paymentsByMethod = emptyByMethod();
|
||||
for (const p of nonRefundPayments) {
|
||||
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
|
||||
}
|
||||
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
// Costs, with computed totals and attribution to a payment method's float (if tagged)
|
||||
const costBreakdown = costs.map(c => {
|
||||
const total = c.costType === 'per_item'
|
||||
? c.amount * (quantityByOption[c.eventOptionId] || 0)
|
||||
: c.amount;
|
||||
return { ...c, total };
|
||||
});
|
||||
const costsByMethod = emptyByMethod();
|
||||
let untaggedCostsTotal = 0;
|
||||
for (const c of costBreakdown) {
|
||||
if (c.paidFromMethod && ALL_METHODS.includes(c.paidFromMethod)) {
|
||||
costsByMethod[c.paidFromMethod] += c.total;
|
||||
} else {
|
||||
untaggedCostsTotal += c.total;
|
||||
}
|
||||
}
|
||||
const totalCosts = untaggedCostsTotal + ALL_METHODS.reduce((s, m) => s + costsByMethod[m], 0);
|
||||
|
||||
// What should physically be on hand per method, after known payouts from that float
|
||||
const expectedCashByMethod = emptyByMethod();
|
||||
for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m];
|
||||
|
||||
// Most recent reconciliation on record (if any) — the source of "actual" truth
|
||||
const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null;
|
||||
const reconciled = latestReconciled ? {
|
||||
id: latestReconciled.id,
|
||||
action: latestReconciled.action,
|
||||
createdAt: latestReconciled.createdAt,
|
||||
performedBy: latestReconciled.performedBy,
|
||||
notes: latestReconciled.notes,
|
||||
byMethod: (() => {
|
||||
const out = {};
|
||||
for (const m of ALL_METHODS) {
|
||||
const line = latestReconciled.lines.find(l => l.method === m);
|
||||
out[m] = {
|
||||
expected: line ? line.expectedAmount : expectedCashByMethod[m],
|
||||
actual: line && line.actualAmount != null ? line.actualAmount : null,
|
||||
variance: line && line.variance != null ? line.variance : null,
|
||||
notes: line ? line.notes : null,
|
||||
denominations: line ? line.denominations : []
|
||||
};
|
||||
}
|
||||
return out;
|
||||
})()
|
||||
} : null;
|
||||
|
||||
// Reconciled value is truth; system (expected) value is the fallback when nothing was counted.
|
||||
// Tagged costs are added back so a reconciled cash count still yields a correct *gross* income figure —
|
||||
// costs get subtracted from profit exactly once, via totalCosts, never twice.
|
||||
const effectiveGrossIncomeByMethod = emptyByMethod();
|
||||
for (const m of ALL_METHODS) {
|
||||
const actual = reconciled?.byMethod?.[m]?.actual;
|
||||
const base = actual != null ? actual : expectedCashByMethod[m];
|
||||
effectiveGrossIncomeByMethod[m] = base + costsByMethod[m];
|
||||
}
|
||||
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
|
||||
const netProfit = effectiveTotalRevenue - totalCosts;
|
||||
|
||||
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown
|
||||
const salesByOptionMap = {};
|
||||
for (const ro of salesRows) {
|
||||
const opt = ro.eventOption;
|
||||
if (!opt) continue;
|
||||
if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 };
|
||||
const unitPrice = ro.priceSnapshot != null ? ro.priceSnapshot : opt.price;
|
||||
salesByOptionMap[opt.id].quantitySold += ro.quantity;
|
||||
salesByOptionMap[opt.id].revenue += unitPrice * ro.quantity;
|
||||
}
|
||||
const salesByOption = Object.values(salesByOptionMap);
|
||||
|
||||
return {
|
||||
event,
|
||||
paymentsByMethod,
|
||||
totalRevenue,
|
||||
costs: costBreakdown,
|
||||
costsByMethod,
|
||||
untaggedCostsTotal,
|
||||
totalCosts,
|
||||
expectedCashByMethod,
|
||||
reconciled,
|
||||
effectiveGrossIncomeByMethod,
|
||||
effectiveTotalRevenue,
|
||||
netProfit,
|
||||
unallocatedDonations,
|
||||
unallocatedDonationsTotal,
|
||||
totalDonations,
|
||||
salesByOption,
|
||||
history
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
METHOD_BUCKETS,
|
||||
ALL_METHODS,
|
||||
ZAR_DENOMINATIONS,
|
||||
bucketForMethod,
|
||||
assertEventOpen,
|
||||
assertRegistrationEventOpen,
|
||||
computeEventFinancials
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const TEMP_DIR = path.join(__dirname, '..', '..', 'temp');
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
/**
|
||||
* Deletes files in the temp directory that are older than 7 days.
|
||||
* Returns a summary: { deleted, errors }.
|
||||
*/
|
||||
function cleanupTempFiles() {
|
||||
try {
|
||||
if (!fs.existsSync(TEMP_DIR)) return { deleted: 0, errors: 0 };
|
||||
|
||||
const files = fs.readdirSync(TEMP_DIR);
|
||||
const cutoff = Date.now() - MAX_AGE_MS;
|
||||
let deleted = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(TEMP_DIR, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.isFile() && stat.mtimeMs < cutoff) {
|
||||
fs.unlinkSync(filePath);
|
||||
deleted++;
|
||||
}
|
||||
} catch {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted, errors };
|
||||
} catch {
|
||||
return { deleted: 0, errors: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { cleanupTempFiles };
|
||||
@@ -0,0 +1,389 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
// ─── Transport ────────────────────────────────────────────────────────────────
|
||||
// The SMTP transporter is built lazily and rebuilt whenever the settings cache
|
||||
// reports a different configuration (e.g. after an admin updates SMTP settings).
|
||||
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
/** Read current SMTP config from settings cache, falling back to env vars. */
|
||||
function _smtpConfig() {
|
||||
return {
|
||||
host: getSettingSync('smtp_host', process.env.SMTP_HOST || process.env.EMAIL_HOST || ''),
|
||||
port: getSettingSync('smtp_port', process.env.SMTP_PORT || process.env.EMAIL_PORT || '587'),
|
||||
secure: getSettingSync('smtp_secure', process.env.SMTP_SECURE || process.env.EMAIL_SECURE || 'false'),
|
||||
user: getSettingSync('smtp_user', process.env.SMTP_USER || process.env.EMAIL_USER || ''),
|
||||
pass: getSettingSync('smtp_pass', process.env.SMTP_PASS || process.env.EMAIL_PASS || ''),
|
||||
from: getSettingSync('smtp_from', process.env.MAIL_FROM || process.env.EMAIL_FROM || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function _configHash(c) {
|
||||
return [c.host, c.port, c.secure, c.user, c.pass].join('|');
|
||||
}
|
||||
|
||||
let _cachedTransporter = null;
|
||||
let _cachedConfigHash = null;
|
||||
|
||||
function _getTransporter() {
|
||||
const cfg = _smtpConfig();
|
||||
const hash = _configHash(cfg);
|
||||
|
||||
if (!_cachedTransporter || hash !== _cachedConfigHash) {
|
||||
_cachedConfigHash = hash;
|
||||
if (cfg.host) {
|
||||
_cachedTransporter = nodemailer.createTransport({
|
||||
host: cfg.host,
|
||||
port: parseInt(cfg.port, 10) || 587,
|
||||
secure: String(cfg.secure).toLowerCase() === 'true' || String(cfg.port) === '465',
|
||||
auth: cfg.user && cfg.pass ? { user: cfg.user, pass: cfg.pass } : undefined,
|
||||
});
|
||||
} else {
|
||||
_cachedTransporter = nodemailer.createTransport({ jsonTransport: true });
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.info('[email] SMTP not configured — using jsonTransport (dev). Configure SMTP via Admin → Site Settings or set SMTP_HOST env var.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { transporter: _cachedTransporter, cfg };
|
||||
}
|
||||
|
||||
async function sendMail({ to, subject, html, text, attachments }) {
|
||||
// Never send to anonymised/deleted accounts
|
||||
if (!to || String(to).endsWith('@deleted.invalid')) {
|
||||
console.info('[email] Skipping send to deleted account:', to);
|
||||
return;
|
||||
}
|
||||
const { transporter, cfg } = _getTransporter();
|
||||
const from = cfg.from || 'no-reply@hope-events.local';
|
||||
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) });
|
||||
|
||||
if (transporter.options && transporter.options.jsonTransport) {
|
||||
try {
|
||||
const payload = typeof info.message === 'string' ? JSON.parse(info.message) : info.message;
|
||||
console.info('[email][dev] simulated:', { to, subject, envelope: info.envelope });
|
||||
if (payload && payload.html) console.info('[email][dev] html (first 300 chars):', String(payload.html).slice(0, 300));
|
||||
} catch {
|
||||
console.info('[email][dev] simulated (raw):', info && info.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared template helpers ──────────────────────────────────────────────────
|
||||
|
||||
function getOrg() {
|
||||
const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001';
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||||
tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'),
|
||||
email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
|
||||
url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''),
|
||||
headerColor: getSettingSync('accent_color', process.env.EMAIL_HEADER_COLOR || '#1e3a5f'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps HTML content in a professional, responsive email shell.
|
||||
* @param {string} body - Inner HTML content
|
||||
* @param {{ preheader?: string }} options
|
||||
*/
|
||||
function emailWrapper(body, { preheader = '' } = {}) {
|
||||
const org = getOrg();
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<title>${org.name}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f1f5f9;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">
|
||||
${preheader ? `<div style="display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all">${preheader}‌ ‌ ‌ ‌ </div>` : ''}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f1f5f9">
|
||||
<tr><td align="center" style="padding:40px 16px">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px">
|
||||
|
||||
<!-- Header -->
|
||||
<tr><td style="background:linear-gradient(135deg,${org.headerColor} 0%,#2d5287 100%);border-radius:12px 12px 0 0;padding:40px 48px;text-align:center">
|
||||
<h1 style="color:#ffffff;font-size:28px;font-weight:800;margin:0;letter-spacing:-0.5px;font-family:${ff};text-shadow:0 1px 3px rgba(0,0,0,0.3)">${org.name}</h1>
|
||||
<p style="color:#ffffff;font-size:14px;font-weight:500;margin:8px 0 0 0;font-family:${ff};letter-spacing:0.3px;opacity:0.9">${org.tagline}</p>
|
||||
</td></tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr><td style="background:#ffffff;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;padding:48px 48px 40px 48px">
|
||||
<div style="font-family:${ff};color:#1e293b;font-size:15px;line-height:1.75">
|
||||
${body}
|
||||
</div>
|
||||
</td></tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr><td style="background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;padding:24px 48px;text-align:center">
|
||||
<p style="color:#94a3b8;font-size:12px;margin:0 0 4px 0;font-family:${ff}">
|
||||
${org.name} •
|
||||
<a href="mailto:${org.email}" style="color:#94a3b8;text-decoration:underline">${org.email}</a> •
|
||||
<a href="${org.url}" style="color:#94a3b8;text-decoration:underline">${org.url}</a>
|
||||
</p>
|
||||
<p style="color:#cbd5e1;font-size:11px;margin:6px 0 0 0;font-family:${ff}">
|
||||
This email was sent because you have an account or registration with ${org.name}.
|
||||
</p>
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Renders a prominent CTA button. */
|
||||
function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto">
|
||||
<tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px">
|
||||
<a href="${url}" target="_blank"
|
||||
style="display:inline-block;padding:14px 36px;font-size:15px;font-weight:700;color:${fg};text-decoration:none;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;letter-spacing:0.2px;mso-hide:none"
|
||||
>${label}</a>
|
||||
</td></tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/** Renders a fallback link below a CTA button. */
|
||||
function fallbackLink(url) {
|
||||
return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all">
|
||||
Or copy this link: <a href="${url}" style="color:#2563eb">${url}</a>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
/** Horizontal rule. */
|
||||
function divider() {
|
||||
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
|
||||
}
|
||||
|
||||
/** Coloured callout box. type: info | success | warning | danger | neutral */
|
||||
function callout(content, type = 'info') {
|
||||
const map = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' },
|
||||
success: { bg: '#f0fdf4', border: '#22c55e', color: '#166534' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', color: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', color: '#991b1b' },
|
||||
neutral: { bg: '#f8fafc', border: '#e2e8f0', color: '#475569' },
|
||||
};
|
||||
const s = map[type] || map.info;
|
||||
return `<div style="background:${s.bg};border-left:4px solid ${s.border};border-radius:0 6px 6px 0;padding:16px 20px;margin:24px 0;color:${s.color};font-size:14px;line-height:1.6">
|
||||
${content}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Numbered payment option row. */
|
||||
function paymentOption(num, title, detail) {
|
||||
return `<tr>
|
||||
<td style="padding:14px 16px 14px 0;vertical-align:top;width:28px">
|
||||
<div style="width:26px;height:26px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
|
||||
</td>
|
||||
<td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top">
|
||||
<p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p>
|
||||
<div style="font-size:13px;color:#64748b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;line-height:1.5">${detail}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ─── Builder functions ────────────────────────────────────────────────────────
|
||||
|
||||
function buildPasswordResetEmail({ name, resetUrl }) {
|
||||
const org = getOrg();
|
||||
const preheader = `Reset your ${org.name} password. This link expires in 1 hour.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Password reset request</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">We received a request to reset your password.</p>
|
||||
|
||||
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151">Click the button below to choose a new password. This link will expire in <strong>1 hour</strong>.</p>
|
||||
|
||||
${ctaButton('Reset my password', resetUrl)}
|
||||
${fallbackLink(resetUrl)}
|
||||
|
||||
${divider()}
|
||||
<p style="margin:0;font-size:13px;color:#94a3b8">
|
||||
If you did not request a password reset, you can safely ignore this email — your password will not be changed.
|
||||
If you're concerned, contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||
</p>`;
|
||||
|
||||
const text = `Hi ${name || 'there'},\n\nWe received a request to reset your ${org.name} password.\n\nReset link (expires in 1 hour):\n${resetUrl}\n\nIf you did not request this, ignore this email.\n\n${org.name} — ${org.email}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildPasswordChangedEmail({ name, when, supportEmail }) {
|
||||
const org = getOrg();
|
||||
const contact = supportEmail || org.email;
|
||||
const whenText = when ? new Date(when).toLocaleString() : 'recently';
|
||||
const preheader = `Your ${org.name} password was changed. If this wasn't you, act immediately.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Password changed</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Security notification for your account</p>
|
||||
|
||||
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151">
|
||||
Your <strong>${org.name}</strong> account password was successfully changed
|
||||
${when ? `on <strong>${whenText}</strong>` : 'recently'}.
|
||||
</p>
|
||||
|
||||
${callout(`<strong>This wasn't you?</strong><br/>
|
||||
If you did not make this change, your account may be compromised. Contact us immediately at
|
||||
<a href="mailto:${contact}" style="color:#991b1b;font-weight:600">${contact}</a>
|
||||
and reset your password right away.`, 'danger')}
|
||||
|
||||
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">
|
||||
If you made this change, no further action is needed. This is an automated security notification.
|
||||
</p>`;
|
||||
|
||||
const text = `Hi ${name || 'there'},\n\nYour ${org.name} password was changed ${when ? 'on ' + whenText : 'recently'}.\n\nIf you did NOT make this change, contact us immediately at ${contact}.\n\n${org.name} — ${org.email}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildLoginNotificationEmail({ name, when, location, userAgent }) {
|
||||
const org = getOrg();
|
||||
const preheader = `New login to your ${org.name} account detected.`;
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">New login detected</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">A new session was opened on your account</p>
|
||||
|
||||
<p style="margin:0 0 24px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
|
||||
<tr style="background:#f8fafc">
|
||||
<td style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;width:36%;font-family:${ff}">Detail</td>
|
||||
<td style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Value</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Time</td>
|
||||
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;font-family:${ff}">${when}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Location</td>
|
||||
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;font-family:${ff}">${location}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#64748b;font-size:13px;font-weight:600;font-family:${ff}">Device</td>
|
||||
<td style="padding:12px 16px;border-top:1px solid #f1f5f9;color:#1e293b;font-size:14px;font-weight:500;word-break:break-all;font-family:${ff}">${userAgent}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${callout(`<strong>Not you?</strong> If you don't recognise this login, change your password immediately and contact us at <a href="mailto:${org.email}" style="color:#991b1b">${org.email}</a>.`, 'danger')}
|
||||
|
||||
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">If this was you, no action is needed.</p>`;
|
||||
|
||||
const text = `Hi ${name || 'there'},\n\nA new login to your ${org.name} account was detected.\n\nTime: ${when}\nLocation: ${location}\nDevice: ${userAgent}\n\nIf this was NOT you, change your password immediately and contact ${org.email}.\n\n${org.name}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildWelcomeEmail({ name, events }) {
|
||||
const org = getOrg();
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const hasEvents = Array.isArray(events) && events.length > 0;
|
||||
const preheader = `Welcome to ${org.name}! Your account is ready.`;
|
||||
|
||||
const eventsBlock = hasEvents
|
||||
? `${divider()}
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 16px 0">Upcoming events</p>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
${events.map(e => `<tr>
|
||||
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9">
|
||||
<p style="margin:0 0 2px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:${ff}">${e.title}</p>
|
||||
<p style="margin:0;font-size:13px;color:#64748b;font-family:${ff}">${new Date(e.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</p>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</table>
|
||||
${ctaButton('Browse all events', org.url + '/events')}`
|
||||
: `<p style="color:#64748b;font-size:14px;margin:0">Keep an eye on our website — new events are added regularly!</p>
|
||||
${ctaButton('View events', org.url + '/events')}`;
|
||||
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Welcome to ${org.name}!</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Your account has been created</p>
|
||||
|
||||
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 0 0;color:#374151">
|
||||
Your ${org.name} account is set up and ready to go. Use your account to register for events, manage your bookings, and view your tickets — all in one place.
|
||||
</p>
|
||||
|
||||
${eventsBlock}
|
||||
|
||||
${divider()}
|
||||
<p style="margin:0;font-size:13px;color:#94a3b8">We look forward to seeing you at our events!</p>`;
|
||||
|
||||
const eventsText = hasEvents
|
||||
? `Upcoming events:\n${events.map(e => `- ${e.title} (${new Date(e.startDate).toLocaleDateString()})`).join('\n')}`
|
||||
: 'Keep an eye on our website for upcoming events.';
|
||||
const text = `Welcome to ${org.name}, ${name || 'there'}!\n\nYour account has been created successfully.\n\n${eventsText}\n\n${org.url}\n\n${org.name} — ${org.email}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildAccountActivationEmail({ name, activationUrl }) {
|
||||
const org = getOrg();
|
||||
const preheader = `Activate your ${org.name} account to get started.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Activate your account</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">One step to access your events and tickets</p>
|
||||
|
||||
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151">
|
||||
Your ${org.name} account is ready, but needs to be activated. Click below to set your password and access your registrations, tickets, and more.
|
||||
</p>
|
||||
|
||||
${ctaButton('Activate my account', activationUrl, { bg: '#059669' })}
|
||||
${fallbackLink(activationUrl)}
|
||||
|
||||
${callout('This activation link expires in <strong>24 hours</strong>.', 'warning')}
|
||||
|
||||
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8">
|
||||
If you didn't expect this email, you can safely ignore it.
|
||||
</p>`;
|
||||
|
||||
const text = `Hi ${name || 'there'},\n\nActivate your ${org.name} account by visiting the link below:\n\n${activationUrl}\n\nThis link expires in 24 hours.\n\nIf you didn't expect this, ignore this email.\n\n${org.name} — ${org.email}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
const org = getOrg();
|
||||
const preheader = `Your ${org.name} account has been closed.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Account closed</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Confirmation of account closure</p>
|
||||
|
||||
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 16px 0;color:#374151">
|
||||
This email confirms that your <strong>${org.name}</strong> account has been successfully closed.
|
||||
</p>
|
||||
${dataDeleted
|
||||
? `<p style="margin:0 0 16px 0;color:#374151">All personal data associated with your account has been permanently deleted as requested.</p>`
|
||||
: `<p style="margin:0 0 16px 0;color:#374151">Your account has been deactivated. If you would also like your personal data permanently deleted, please contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.</p>`
|
||||
}
|
||||
<p style="margin:0 0 0 0;color:#374151">
|
||||
If you did not request this, please contact us immediately at
|
||||
<a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||
</p>`;
|
||||
|
||||
const dataNote = dataDeleted
|
||||
? 'All personal data has been permanently deleted.'
|
||||
: 'Your account has been deactivated. Contact us if you also want your data deleted.';
|
||||
const text = `Hi ${name || 'there'},\n\nYour ${org.name} account has been closed.\n\n${dataNote}\n\nIf you did not request this, contact us immediately at ${org.email}.\n\n${org.name}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendMail,
|
||||
emailWrapper,
|
||||
ctaButton,
|
||||
fallbackLink,
|
||||
divider,
|
||||
callout,
|
||||
paymentOption,
|
||||
buildPasswordResetEmail,
|
||||
buildPasswordChangedEmail,
|
||||
buildLoginNotificationEmail,
|
||||
buildWelcomeEmail,
|
||||
buildAccountActivationEmail,
|
||||
buildAccountClosedEmail,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* AES-256-GCM encryption helpers for sensitive settings stored in the database.
|
||||
*
|
||||
* Encrypted values are stored with an "enc:" prefix so they can be detected
|
||||
* and decrypted transparently when read back from the cache.
|
||||
*
|
||||
* Key is derived once at startup from JWT_SECRET via scrypt so it never
|
||||
* appears in plain text in the codebase.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const SENTINEL = 'enc:';
|
||||
|
||||
// Derive a stable 32-byte key from JWT_SECRET using scrypt.
|
||||
// The fixed salt is fine here — its purpose is to namespace this key
|
||||
// so it can't be confused with keys derived for other purposes.
|
||||
const ENCRYPTION_KEY = (() => {
|
||||
const secret = process.env.JWT_SECRET || 'default-unsafe-key-CHANGE-IN-PRODUCTION';
|
||||
if (secret === 'default-unsafe-key-CHANGE-IN-PRODUCTION') {
|
||||
console.warn('[encryption] WARNING: JWT_SECRET is not set. Sensitive settings will be encrypted with an insecure fallback key. Set JWT_SECRET in production.');
|
||||
}
|
||||
return crypto.scryptSync(secret, 'hope-events-settings-aes256gcm-v1', 32);
|
||||
})();
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string. Returns an "enc:<iv>:<authTag>:<ciphertext>" string.
|
||||
* Returns the original value if it is already encrypted or falsy.
|
||||
* @param {string} plaintext
|
||||
* @returns {string}
|
||||
*/
|
||||
function encrypt(plaintext) {
|
||||
if (!plaintext) return plaintext;
|
||||
if (typeof plaintext !== 'string') plaintext = String(plaintext);
|
||||
if (plaintext.startsWith(SENTINEL)) return plaintext; // already encrypted
|
||||
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value produced by encrypt(). Returns the plaintext.
|
||||
* Returns the original value unchanged if it does not start with "enc:".
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function decrypt(value) {
|
||||
if (!value || typeof value !== 'string') return value;
|
||||
if (!value.startsWith(SENTINEL)) return value; // not encrypted
|
||||
try {
|
||||
const inner = value.slice(SENTINEL.length);
|
||||
const parts = inner.split(':');
|
||||
if (parts.length !== 3) throw new Error('Invalid encrypted value format');
|
||||
const [ivHex, tagHex, cipherHex] = parts;
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const ciphertext = Buffer.from(cipherHex,'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (err) {
|
||||
console.error('[encryption] Failed to decrypt value:', err.message);
|
||||
return ''; // return empty rather than crashing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the value looks like it was produced by encrypt().
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEncrypted(value) {
|
||||
return typeof value === 'string' && value.startsWith(SENTINEL);
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt, isEncrypted };
|
||||
@@ -0,0 +1,43 @@
|
||||
// Prisma error codes all match P followed by 4 digits
|
||||
const PRISMA_CODE_RE = /^P\d{4}$/;
|
||||
|
||||
/**
|
||||
* Returns a client-safe error message.
|
||||
* Prisma errors and connection errors can expose table names, column names,
|
||||
* DB hostnames and ports — replace those with generic messages.
|
||||
* Intentional application errors (thrown with `new Error('...')` in controllers)
|
||||
* are passed through unchanged.
|
||||
*/
|
||||
function safeErrorMessage(err) {
|
||||
if (!err) return 'An unexpected error occurred.';
|
||||
|
||||
const code = String(err.code || '');
|
||||
|
||||
// Prisma client / query engine errors
|
||||
if (PRISMA_CODE_RE.test(code)) {
|
||||
switch (code) {
|
||||
case 'P2002': return 'A record with that value already exists.';
|
||||
case 'P2025': return 'Record not found.';
|
||||
case 'P2003': return 'Operation failed due to a related record constraint.';
|
||||
case 'P2016': return 'Required record not found.';
|
||||
default: return 'A database error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
const msg = String(err.message || '');
|
||||
|
||||
// DB connection / network errors leak hostnames and ports
|
||||
if (
|
||||
msg.includes("Can't reach database") ||
|
||||
msg.includes('ECONNREFUSED') ||
|
||||
msg.includes('ETIMEDOUT') ||
|
||||
msg.includes('Connection refused') ||
|
||||
msg.includes('database server')
|
||||
) {
|
||||
return 'A database connection error occurred. Please try again later.';
|
||||
}
|
||||
|
||||
return msg || 'An unexpected error occurred.';
|
||||
}
|
||||
|
||||
module.exports = { safeErrorMessage };
|
||||
@@ -0,0 +1,984 @@
|
||||
const prisma = require('../config/db');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
|
||||
function fmtAmount(amt) {
|
||||
const n = Number(amt || 0);
|
||||
return `R${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return String(d); }
|
||||
}
|
||||
|
||||
function fmtDateShort(d) {
|
||||
try { return new Date(d).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); } catch { return String(d); }
|
||||
}
|
||||
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
function getOrg() {
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||||
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
|
||||
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function getRegistrationsInbox() {
|
||||
// Supports comma-separated list; return the first address for single-recipient use
|
||||
const raw = getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || '');
|
||||
return raw.split(',')[0].trim() || '';
|
||||
}
|
||||
|
||||
function getRegistrationsInboxAll() {
|
||||
// Returns the full comma-separated string for multi-recipient sends
|
||||
return getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves who should receive registration/payment/daily-summary notices for an event:
|
||||
* the event's configured `notifyRecipients`, or the event creator when none are set.
|
||||
*/
|
||||
function getEventNotifyEmails(event) {
|
||||
const recipients = Array.isArray(event?.notifyRecipients) ? event.notifyRecipients : [];
|
||||
const emails = recipients.map(u => u?.email).filter(Boolean);
|
||||
if (emails.length) return emails;
|
||||
return event?.createdBy?.email ? [event.createdBy.email] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the deduplicated admin "to" list for an event notification: the global
|
||||
* registrations inbox plus that event's notify recipients (or its creator as fallback).
|
||||
*/
|
||||
function buildEventNotifyRecipientList(event) {
|
||||
const inbox = getRegistrationsInbox();
|
||||
const seen = new Set();
|
||||
const to = [];
|
||||
if (inbox) { seen.add(inbox); to.push(inbox); }
|
||||
for (const email of getEventNotifyEmails(event)) {
|
||||
if (!seen.has(email)) { seen.add(email); to.push(email); }
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
// ─── Data loaders ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadRegistrationFull(registrationId) {
|
||||
return prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
payments: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPaymentFull(paymentId) {
|
||||
return prisma.payment.findUnique({
|
||||
where: { id: paymentId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||
registration: {
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
payments: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
},
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Shared building blocks ───────────────────────────────────────────────────
|
||||
|
||||
/** Renders the selections summary table. */
|
||||
function selectionsTable(registrationOptions) {
|
||||
const rows = (registrationOptions || []).map(ro => {
|
||||
const name = ro.eventOption?.name || 'Option';
|
||||
const qty = ro.quantity || 1;
|
||||
const price = ro.eventOption?.price || 0;
|
||||
return `<tr>
|
||||
<td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td>
|
||||
<td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td>
|
||||
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(price * qty)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
|
||||
|
||||
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 4px 0">
|
||||
<tr style="background:#f8fafc">
|
||||
<th style="padding:10px 16px 10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:left;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Item</th>
|
||||
<th style="padding:10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:center;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Qty</th>
|
||||
<th style="padding:10px 0 10px 16px;font-size:12px;font-weight:700;color:#64748b;text-align:right;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Amount</th>
|
||||
</tr>
|
||||
${rows.join('')}
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/** Renders a financial summary line. */
|
||||
function financialSummary(totalDue, totalPaid, balance) {
|
||||
const ff2 = ff;
|
||||
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0 0 0">
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#64748b;font-family:${ff2}">Total due</td>
|
||||
<td style="padding:6px 0;font-size:13px;color:#374151;text-align:right;font-weight:500;font-family:${ff2}">${fmtAmount(totalDue)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#64748b;font-family:${ff2}">Amount paid</td>
|
||||
<td style="padding:6px 0;font-size:13px;color:#374151;text-align:right;font-weight:500;font-family:${ff2}">${fmtAmount(totalPaid)}</td>
|
||||
</tr>
|
||||
<tr style="border-top:2px solid #e2e8f0">
|
||||
<td style="padding:10px 0 6px 0;font-size:15px;font-weight:800;color:#${balance <= 0 ? '059669' : '1e293b'};font-family:${ff2}">${balance <= 0 ? 'Fully paid' : 'Balance due'}</td>
|
||||
<td style="padding:10px 0 6px 0;font-size:15px;font-weight:800;color:#${balance <= 0 ? '059669' : '1e293b'};text-align:right;font-family:${ff2}">${fmtAmount(balance)}</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the account CTA section at the bottom of user-facing registration emails.
|
||||
* isActive: true → Login prompt; false → Create account prompt.
|
||||
*/
|
||||
function accountCta(isActive, siteUrl) {
|
||||
if (isActive) {
|
||||
return `${divider()}
|
||||
<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 6px 0;font-family:${ff}">Manage your registration online</p>
|
||||
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">
|
||||
Log in to your account to view your registration, make payments, and download your tickets.
|
||||
</p>
|
||||
${ctaButton('Log in to your account', siteUrl + '/login', { bg: '#0f172a' })}`;
|
||||
}
|
||||
return `${divider()}
|
||||
<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 6px 0;font-family:${ff}">Create your account</p>
|
||||
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">
|
||||
Create a free account to manage your registrations, make payments online, and access your tickets — all in one place.
|
||||
</p>
|
||||
${ctaButton('Create your account', siteUrl + '/register', { bg: '#0f172a' })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the payment options section for registration emails.
|
||||
* @param {{ balance, yocoLink, source, siteUrl, formRequired }}
|
||||
* source: 'admin' (manual/self-service/at-door) | 'user' (self-register via website)
|
||||
*/
|
||||
function paymentSection({ balance, yocoLink, source, siteUrl, formRequired, isUserActive }) {
|
||||
if (balance <= 0 && !formRequired) {
|
||||
return callout(
|
||||
`<strong style="font-size:15px">🎟️ You\'re all set!</strong><br/>
|
||||
<span style="font-size:13px">No payment required. Your tickets have been sent in a separate email.</span>`,
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
if (balance <= 0 && formRequired) {
|
||||
return callout(
|
||||
`<strong>One more step — attendee form required</strong><br/>
|
||||
<span style="font-size:13px">This event requires an attendee information form before tickets can be issued. The form was presented during registration. If you haven't submitted it yet, please contact us.</span>`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
// Balance due — build payment options
|
||||
const isAdmin = source === 'admin';
|
||||
let optNum = 1;
|
||||
const options = [];
|
||||
|
||||
if (isAdmin && yocoLink) {
|
||||
options.push(paymentOption(
|
||||
optNum++,
|
||||
'Pay online now (quickest)',
|
||||
`<a href="${yocoLink}" style="color:#2563eb;font-weight:600;text-decoration:underline">${yocoLink}</a><br/>
|
||||
<span style="color:#94a3b8;font-style:italic">Already paid? You can safely ignore this option.</span>`
|
||||
));
|
||||
}
|
||||
|
||||
options.push(paymentOption(
|
||||
optNum++,
|
||||
`Pay via our website`,
|
||||
`Visit <a href="${siteUrl}" style="color:#2563eb">${siteUrl}</a> to pay online.${
|
||||
isUserActive === false
|
||||
? `<br/><span style="color:#64748b">You'll need to create a free account to pay online.</span>`
|
||||
: isUserActive === true
|
||||
? `<br/><span style="color:#64748b">Log in to access your registration and pay.</span>`
|
||||
: ''
|
||||
}`
|
||||
));
|
||||
|
||||
options.push(paymentOption(
|
||||
optNum++,
|
||||
'Pay at the door',
|
||||
'Cash and card accepted at the event entrance. No need to pre-pay — your registration is already confirmed.'
|
||||
));
|
||||
|
||||
return `<p style="font-size:16px;font-weight:700;color:#0f172a;margin:32px 0 8px 0;font-family:${ff}">How to pay</p>
|
||||
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">Choose any of the following payment methods:</p>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">${options.join('')}</table>
|
||||
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">Your tickets will be emailed once your payment is confirmed.</p>`;
|
||||
}
|
||||
|
||||
// ─── Registration confirmation (user self-registered via website) ──────────────
|
||||
|
||||
function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
|
||||
const org = getOrg();
|
||||
const eventTitle = reg.event?.title || 'the event';
|
||||
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
const isUserActive = reg.user?.isActive;
|
||||
|
||||
const heading = isNew ? 'Registration confirmed!' : 'Registration updated';
|
||||
const subtext = isNew
|
||||
? `Thank you for registering for <strong>${eventTitle}</strong>.`
|
||||
: `Your registration for <strong>${eventTitle}</strong> has been updated.`;
|
||||
const subject = isNew ? `Registration confirmed – ${eventTitle}` : `Registration updated – ${eventTitle}`;
|
||||
const preheader = isNew
|
||||
? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!`
|
||||
: `Your registration for ${eventTitle} has been updated.`;
|
||||
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${heading}</p>
|
||||
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">${isNew ? 'Your spot is reserved' : 'Changes saved'}</p>
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
|
||||
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||
${selectionsTable(reg.registrationOptions)}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
|
||||
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n');
|
||||
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name} — ${org.email}\n${org.url}`;
|
||||
|
||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
// ─── Registration confirmation (admin / self-service / at-door) ───────────────
|
||||
|
||||
function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, formRequired = false, isNew = true } = {}) {
|
||||
const org = getOrg();
|
||||
const eventTitle = reg.event?.title || 'the event';
|
||||
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
const isUserActive = reg.user?.isActive;
|
||||
|
||||
const heading = isNew ? 'Registration confirmed!' : 'Registration updated';
|
||||
const subtext = isNew
|
||||
? `You have been registered for <strong>${eventTitle}</strong>.`
|
||||
: `Your registration for <strong>${eventTitle}</strong> has been updated.`;
|
||||
const subject = isNew ? `Registration confirmed – ${eventTitle}` : `Registration updated – ${eventTitle}`;
|
||||
const preheader = isNew
|
||||
? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!`
|
||||
: `Your registration for ${eventTitle} has been updated.`;
|
||||
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${heading}</p>
|
||||
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">${isNew ? 'Your spot is reserved' : 'Changes saved'}</p>
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
|
||||
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||
${selectionsTable(reg.registrationOptions)}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
|
||||
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n');
|
||||
const payText = balance > 0
|
||||
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
|
||||
: formRequired
|
||||
? 'An attendee form is required before your tickets are issued. Please complete it at the registration desk.'
|
||||
: 'No payment required — your tickets have been sent in a separate email.';
|
||||
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You have been registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${payText}\n\n${org.name} — ${org.email}\n${org.url}`;
|
||||
|
||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
// ─── Admin notification (internal) ───────────────────────────────────────────
|
||||
|
||||
function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } = {}) {
|
||||
const org = getOrg();
|
||||
const eventTitle = reg.event?.title || 'Event';
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
const verb = isUpdated ? 'updated' : (isNew ? 'created' : 'modified');
|
||||
const subject = isUpdated
|
||||
? `Registration updated: ${eventTitle} — ${reg.user?.name || 'Unknown'}`
|
||||
: `New registration: ${eventTitle} — ${reg.user?.name || 'Unknown'}`;
|
||||
|
||||
const itemRows = (reg.registrationOptions || []).map(ro =>
|
||||
`<tr>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const body = `
|
||||
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0">Registration ${verb}</p>
|
||||
<p style="font-size:13px;color:#64748b;margin:0 0 28px 0">${org.name} — internal notification</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
|
||||
<tr style="background:#f8fafc">
|
||||
<td colspan="2" style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Registrant details</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;width:30%;font-family:${ff};border-top:1px solid #f1f5f9">Name</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user?.name || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Email</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user?.email || '—'}</td>
|
||||
</tr>
|
||||
${reg.user?.phoneNumber ? `<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Phone</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user.phoneNumber}</td>
|
||||
</tr>` : ''}
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Event</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${eventTitle}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Status</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.status || 'pending'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Reg ID</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${reg.id}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${itemRows ? `<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 8px 0;font-family:${ff}">Items</p>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 16px 0">
|
||||
<tr style="background:#f8fafc">
|
||||
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Option</th>
|
||||
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:center;font-family:${ff}">Qty</th>
|
||||
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:right;font-family:${ff}">Amount</th>
|
||||
</tr>${itemRows}
|
||||
</table>` : ''}
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0 0 0">
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:4px 0;font-family:${ff}">Total due</td>
|
||||
<td style="font-size:13px;color:#374151;text-align:right;font-weight:500;padding:4px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:4px 0;font-family:${ff}">Paid</td>
|
||||
<td style="font-size:13px;color:#374151;text-align:right;font-weight:500;padding:4px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:8px 0 0 0;border-top:1px solid #e2e8f0;font-family:${ff}">Balance</td>
|
||||
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:8px 0 0 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
const to = buildEventNotifyRecipientList(reg.event);
|
||||
const text = `Registration ${verb}\n\nEvent: ${eventTitle}\nName: ${reg.user?.name || '—'}\nEmail: ${reg.user?.email || '—'}\nStatus: ${reg.status || 'pending'}\nTotal due: ${fmtAmount(totalDue)} | Paid: ${fmtAmount(totalPaid)} | Balance: ${fmtAmount(balance)}\nReg ID: ${reg.id}`;
|
||||
return { to, subject, text, html: emailWrapper(body) };
|
||||
}
|
||||
|
||||
// ─── Payment receipt ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildPaymentReceipt(payment) {
|
||||
const org = getOrg();
|
||||
const isReg = !!payment.registrationId && payment.registration;
|
||||
const eventTitle = isReg
|
||||
? (payment.registration?.event?.title || 'the event')
|
||||
: (payment.event?.title || 'the event');
|
||||
|
||||
if (isReg) {
|
||||
const reg = payment.registration;
|
||||
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
const isUserActive = reg.user?.isActive;
|
||||
const subject = `Payment received – ${eventTitle}`;
|
||||
const preheader = `We received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.`;
|
||||
|
||||
const historyRows = (reg.payments || [])
|
||||
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
.map(p => `<tr>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtDate(p.createdAt)}</td>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${p.method || '—'}</td>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-weight:600;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(p.amount)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Payment received</p>
|
||||
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Thank you — we've got your payment</p>
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||
We received your payment of <strong>${fmtAmount(payment.amount)}</strong> for <strong>${eventTitle}</strong>.
|
||||
</p>
|
||||
|
||||
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} received</strong><br/>
|
||||
<span style="font-size:13px">Payment method: ${payment.method || '—'} • Date: ${fmtDate(payment.createdAt)}</span>`,
|
||||
'success')}
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
|
||||
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
|
||||
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
|
||||
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${historyRows ? `<p style="font-size:14px;font-weight:700;color:#0f172a;margin:24px 0 8px 0;font-family:${ff}">Payment history</p>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
|
||||
<tr style="background:#f8fafc">
|
||||
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Date</th>
|
||||
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Method</th>
|
||||
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:right;font-family:${ff}">Amount</th>
|
||||
</tr>${historyRows}
|
||||
</table>` : ''}
|
||||
|
||||
${balance <= 0
|
||||
? callout('<strong>You\'re fully paid!</strong> Your tickets have been emailed to you separately.', 'success')
|
||||
: ''}
|
||||
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
|
||||
const text = `Payment received\n\nHi ${payment.user?.name || 'there'},\n\nWe received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\nThank you!\n\n${org.name} — ${org.email}`;
|
||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
// Donation receipt
|
||||
const subject = `Donation received – ${eventTitle}`;
|
||||
const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Thank you for your donation!</p>
|
||||
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your generosity makes a difference</p>
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.
|
||||
</p>
|
||||
|
||||
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} donated</strong><br/>
|
||||
<span style="font-size:13px">Method: ${payment.method || '—'} • Date: ${fmtDate(payment.createdAt)}</span>`,
|
||||
'success')}
|
||||
|
||||
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8;font-family:${ff}">We appreciate your generous support. Thank you!</p>`;
|
||||
|
||||
const text = `Thank you for your donation!\n\nHi ${payment.user?.name || 'there'},\n\nWe received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.\n\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\n\nThank you!\n\n${org.name} — ${org.email}`;
|
||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
// ─── Donation applied to a registration ────────────────────────────────────────
|
||||
//
|
||||
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
|
||||
// someone else's donation to their registration — they didn't pay anything themselves,
|
||||
// so "payment received" wording would be wrong. Kept anonymous (no donor name) by design.
|
||||
|
||||
function buildDonationAppliedToRegistrant(payment) {
|
||||
const org = getOrg();
|
||||
const reg = payment.registration;
|
||||
const eventTitle = reg?.event?.title || 'the event';
|
||||
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
|
||||
const totalPaid = (reg?.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
const isUserActive = reg?.user?.isActive;
|
||||
const subject = `A donation was applied to your registration – ${eventTitle}`;
|
||||
const preheader = `A donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle}.`;
|
||||
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">A donation was applied to your registration</p>
|
||||
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Good news about your balance</p>
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg?.user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||
A donation of <strong>${fmtAmount(payment.amount)}</strong> was applied to your registration for <strong>${eventTitle}</strong> by our team.
|
||||
</p>
|
||||
|
||||
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} applied</strong><br/>
|
||||
<span style="font-size:13px">Date: ${fmtDate(payment.createdAt)}</span>`,
|
||||
'success')}
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
|
||||
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
|
||||
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
|
||||
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${balance <= 0
|
||||
? callout('<strong>You\'re fully paid!</strong> Your tickets have been emailed to you separately.', 'success')
|
||||
: ''}
|
||||
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
|
||||
const text = `A donation was applied to your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle} by our team.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${org.name} — ${org.email}`;
|
||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
// ─── Payment admin notification ───────────────────────────────────────────────
|
||||
|
||||
function buildPaymentAdminNotice(payment) {
|
||||
const isReg = !!payment.registrationId && payment.registration;
|
||||
const eventTitle = isReg ? (payment.registration?.event?.title || 'Event') : (payment.event?.title || 'Event');
|
||||
const payerName = payment.user?.name || '—';
|
||||
const payerEmail = payment.user?.email || '—';
|
||||
const subject = `Payment recorded: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`;
|
||||
const type = isReg ? 'Registration payment' : 'Donation';
|
||||
|
||||
const body = `
|
||||
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0">Payment recorded</p>
|
||||
<p style="font-size:13px;color:#64748b;margin:0 0 28px 0">Internal notification</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
|
||||
<tr style="background:#f8fafc">
|
||||
<td colspan="2" style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Payment details</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;width:30%;font-family:${ff};border-top:1px solid #f1f5f9">Amount</td>
|
||||
<td style="padding:10px 16px;font-size:15px;color:#059669;font-weight:800;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(payment.amount)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Type</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Payer</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${payerName} <${payerEmail}></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Event</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${eventTitle}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Method</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${payment.method || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Date</td>
|
||||
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtDate(payment.createdAt)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Payment ID</td>
|
||||
<td style="padding:10px 16px;font-size:12px;color:#94a3b8;font-family:${ff};border-top:1px solid #f1f5f9">${payment.id}</td>
|
||||
</tr>
|
||||
${payment.externalId ? `<tr>
|
||||
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">External ID</td>
|
||||
<td style="padding:10px 16px;font-size:12px;color:#94a3b8;font-family:${ff};border-top:1px solid #f1f5f9">${payment.externalId}</td>
|
||||
</tr>` : ''}
|
||||
</table>`;
|
||||
|
||||
const event = isReg ? payment.registration?.event : payment.event;
|
||||
const to = buildEventNotifyRecipientList(event);
|
||||
const text = `Payment recorded\n\nAmount: ${fmtAmount(payment.amount)}\nType: ${type}\nPayer: ${payerName} <${payerEmail}>\nEvent: ${eventTitle}\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\nID: ${payment.id}`;
|
||||
return { to, subject, text, html: emailWrapper(body) };
|
||||
}
|
||||
|
||||
// ─── Refund email ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRefundEmail(payment) {
|
||||
const org = getOrg();
|
||||
const user = payment.user;
|
||||
const amt = Math.abs(payment.amount || 0);
|
||||
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
|
||||
const subject = `Refund processed – ${fmtAmount(amt)} for ${eventTitle}`;
|
||||
const preheader = `Your refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.`;
|
||||
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Refund processed</p>
|
||||
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your refund is on its way</p>
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||
A refund of <strong>${fmtAmount(amt)}</strong> has been processed for <strong>${eventTitle}</strong>.
|
||||
${payment.status ? `<br/>Reason: ${payment.status}` : ''}
|
||||
</p>
|
||||
|
||||
${callout(`<strong style="font-size:15px">${fmtAmount(amt)} refunded</strong><br/>
|
||||
<span style="font-size:13px">Method: ${payment.method || 'original payment method'} • Date: ${fmtDate(payment.createdAt)}</span>`,
|
||||
'info')}
|
||||
|
||||
<p style="margin:24px 0 0 0;font-size:14px;color:#374151;font-family:${ff}">
|
||||
Refunds may take a few business days to appear depending on your bank and payment method.
|
||||
If you have any questions, contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||
</p>`;
|
||||
|
||||
const text = `Refund processed\n\nHi ${user?.name || 'there'},\n\nA refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.\n\nMethod: ${payment.method || 'original method'}\nDate: ${fmtDate(payment.createdAt)}\n\n${org.name} — ${org.email}`;
|
||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
// ─── Daily event summary ──────────────────────────────────────────────────────
|
||||
|
||||
function buildDailySummary(ev, registrations, payments, now) {
|
||||
const org = getOrg();
|
||||
const subject = `Daily summary: ${ev.title} — ${new Date(now).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`;
|
||||
|
||||
const regRows = registrations.map(r => {
|
||||
const totalDue = computeRegistrationTotalDue(r, now);
|
||||
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
return { name: r.user?.name || '?', email: r.user?.email || '', status: r.status, totalDue, totalPaid, balance };
|
||||
});
|
||||
|
||||
const payRows = payments.map(p => ({
|
||||
date: fmtDate(p.createdAt),
|
||||
amount: p.amount,
|
||||
method: p.method || '—',
|
||||
isDonation: p.isDonation || !p.registrationId,
|
||||
payerName: p.registration?.user?.name || p.user?.name || '?',
|
||||
payerEmail: p.user?.email || p.registration?.user?.email || '',
|
||||
}));
|
||||
|
||||
const totalRegistrations = regRows.length;
|
||||
const totalRevenue = payRows.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const paidCount = regRows.filter(r => r.status === 'paid').length;
|
||||
const pendingCount = regRows.filter(r => r.status === 'pending' || r.status === 'partial_paid').length;
|
||||
|
||||
const statsRow = (label, value, color = '#1e293b') =>
|
||||
`<td style="text-align:center;padding:16px;border-right:1px solid #f1f5f9">
|
||||
<div style="font-size:24px;font-weight:800;color:${color};font-family:${ff}">${value}</div>
|
||||
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">${label}</div>
|
||||
</td>`;
|
||||
|
||||
const regTableHtml = regRows.length
|
||||
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;font-size:13px">
|
||||
<tr style="background:#f8fafc">
|
||||
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Name</th>
|
||||
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Email</th>
|
||||
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Status</th>
|
||||
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Total</th>
|
||||
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Paid</th>
|
||||
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Balance</th>
|
||||
</tr>
|
||||
${regRows.map((r, i) => `<tr style="background:${i % 2 === 0 ? '#ffffff' : '#f8fafc'}">
|
||||
<td style="padding:10px 12px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${r.name}</td>
|
||||
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${r.email}</td>
|
||||
<td style="padding:10px 12px;border-top:1px solid #f1f5f9">
|
||||
<span style="background:${r.status === 'paid' ? '#dcfce7' : r.status === 'partial_paid' ? '#fef9c3' : '#f1f5f9'};color:${r.status === 'paid' ? '#166534' : r.status === 'partial_paid' ? '#854d0e' : '#475569'};padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-family:${ff}">${r.status}</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:#374151;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.totalDue)}</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:#374151;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.totalPaid)}</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:${r.balance > 0 ? '#b45309' : '#059669'};font-weight:700;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.balance)}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`
|
||||
: `<p style="color:#94a3b8;font-size:14px;margin:0;font-family:${ff}">No registrations yet.</p>`;
|
||||
|
||||
const payTableHtml = payRows.length
|
||||
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;font-size:13px">
|
||||
<tr style="background:#f8fafc">
|
||||
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Date</th>
|
||||
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Payer</th>
|
||||
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Type</th>
|
||||
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Method</th>
|
||||
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Amount</th>
|
||||
</tr>
|
||||
${payRows.map((p, i) => `<tr style="background:${i % 2 === 0 ? '#ffffff' : '#f8fafc'}">
|
||||
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${p.date}</td>
|
||||
<td style="padding:10px 12px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${p.payerName}${p.payerEmail ? `<br/><span style="font-size:11px;color:#94a3b8">${p.payerEmail}</span>` : ''}</td>
|
||||
<td style="padding:10px 12px;border-top:1px solid #f1f5f9">
|
||||
<span style="background:${p.isDonation ? '#eff6ff' : '#f0fdf4'};color:${p.isDonation ? '#1e40af' : '#166534'};padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-family:${ff}">${p.isDonation ? 'Donation' : 'Registration'}</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9">${p.method}</td>
|
||||
<td style="padding:10px 12px;text-align:right;color:#059669;font-weight:700;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(p.amount)}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`
|
||||
: `<p style="color:#94a3b8;font-size:14px;margin:0;font-family:${ff}">No payments recorded yet.</p>`;
|
||||
|
||||
const body = `
|
||||
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0;font-family:${ff}">Daily Summary</p>
|
||||
<p style="font-size:13px;color:#64748b;margin:0 0 4px 0;font-family:${ff}">${new Date(now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</p>
|
||||
<p style="font-size:16px;font-weight:700;color:#1e293b;margin:0 0 24px 0;font-family:${ff}">${ev.title}</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 32px 0;text-align:center">
|
||||
<tr>
|
||||
${statsRow('Total registrations', totalRegistrations)}
|
||||
${statsRow('Confirmed paid', paidCount, '#059669')}
|
||||
${statsRow('Awaiting payment', pendingCount, '#d97706')}
|
||||
<td style="text-align:center;padding:16px">
|
||||
<div style="font-size:24px;font-weight:800;color:#2563eb;font-family:${ff}">${fmtAmount(totalRevenue)}</div>
|
||||
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">Total collected</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${divider()}
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 16px 0;font-family:${ff}">Registrations</p>
|
||||
${regTableHtml}
|
||||
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:32px 0 16px 0;font-family:${ff}">Payments & Donations</p>
|
||||
${payTableHtml}
|
||||
|
||||
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0 0;font-family:${ff}">
|
||||
Event starts: ${fmtDate(ev.startDate)} • Event ends: ${fmtDate(ev.endDate)}
|
||||
</p>`;
|
||||
|
||||
const text = [
|
||||
`Daily Summary — ${ev.title}`,
|
||||
new Date(now).toLocaleDateString(),
|
||||
'',
|
||||
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount} | Revenue: ${fmtAmount(totalRevenue)}`,
|
||||
'',
|
||||
'Registrations:',
|
||||
...(regRows.length ? regRows.map(r => ` ${r.name} <${r.email}> — ${r.status} — due: ${fmtAmount(r.totalDue)} paid: ${fmtAmount(r.totalPaid)} balance: ${fmtAmount(r.balance)}`) : [' (none)']),
|
||||
'',
|
||||
'Payments:',
|
||||
...(payRows.length ? payRows.map(p => ` ${p.date} — ${fmtAmount(p.amount)} via ${p.method} — ${p.isDonation ? 'Donation' : 'Registration'} — ${p.payerName}`) : [' (none)']),
|
||||
].join('\n');
|
||||
|
||||
return { subject, text, html: emailWrapper(body) };
|
||||
}
|
||||
|
||||
// ─── Send functions ───────────────────────────────────────────────────────────
|
||||
|
||||
async function sendRegistrationEmails(registrationId) {
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
|
||||
// Email: only for real addresses (skip guest.local placeholders)
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
const msg = buildRegistrationConfirmation(reg, { isNew: true });
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
}
|
||||
// WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
|
||||
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
}
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send registration emails:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRegistrationUpdatedEmails(registrationId) {
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
const msg = buildRegistrationConfirmation(reg, { isNew: false });
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
}
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
}
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send registration updated emails:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) {
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew });
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
}
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
}
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send self-service registration emails:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPaymentEmails(paymentId) {
|
||||
try {
|
||||
const payment = await loadPaymentFull(paymentId);
|
||||
if (!payment) return;
|
||||
const user = payment.registration?.user || payment.user;
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const { buildWAPayment } = require('./waMessages');
|
||||
|
||||
const sends = [];
|
||||
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
if (hasValidEmail) {
|
||||
const msg = buildPaymentReceipt(payment);
|
||||
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
}
|
||||
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't
|
||||
if (hasValidEmail) {
|
||||
sends.push(waText(user, buildWAPayment(payment)));
|
||||
} else {
|
||||
sends.push(waTextAny(user, buildWAPayment(payment)));
|
||||
}
|
||||
const hasEvent = !!(payment.registration?.eventId || payment.eventId);
|
||||
if (hasEvent) {
|
||||
const adminMsg = buildPaymentAdminNotice(payment);
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
}
|
||||
}
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send payment emails:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRefundEmail(refundPaymentId) {
|
||||
try {
|
||||
const payment = await loadPaymentFull(refundPaymentId);
|
||||
if (!payment) return;
|
||||
|
||||
const user = payment.user;
|
||||
if (!user?.email || user.email.endsWith('@guest.local')) return;
|
||||
|
||||
const msg = buildRefundEmail(payment);
|
||||
const sends = [sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })];
|
||||
|
||||
const adminMsg = buildPaymentAdminNotice(payment);
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: `Refund: ${adminMsg.subject}`, html: adminMsg.html, text: adminMsg.text }));
|
||||
}
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send refund email:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Sent when staff apply a donation to someone's registration. Only the registrant is
|
||||
// notified (anonymously, per design) — the donor already received their donation-received
|
||||
// notification when the donation was originally made, so they are deliberately not emailed
|
||||
// again here, and any leftover/unassigned remainder from a partial allocation is silent too.
|
||||
async function sendDonationAssignmentEmails(paymentId) {
|
||||
try {
|
||||
const payment = await loadPaymentFull(paymentId);
|
||||
if (!payment || !payment.registration) return;
|
||||
const user = payment.registration.user;
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const { buildWADonationAppliedToRegistrant } = require('./waMessages');
|
||||
|
||||
const sends = [];
|
||||
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
if (hasValidEmail) {
|
||||
const msg = buildDonationAppliedToRegistrant(payment);
|
||||
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
}
|
||||
if (hasValidEmail) {
|
||||
sends.push(waText(user, buildWADonationAppliedToRegistrant(payment)));
|
||||
} else {
|
||||
sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment)));
|
||||
}
|
||||
const adminMsg = buildPaymentAdminNotice(payment);
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
}
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send donation-assignment emails:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDailyEventSummaries(now = new Date()) {
|
||||
try {
|
||||
const today = new Date(now);
|
||||
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
|
||||
let events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gte: today } },
|
||||
include: notifyInclude,
|
||||
orderBy: { startDate: 'asc' },
|
||||
});
|
||||
|
||||
try {
|
||||
events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } },
|
||||
include: notifyInclude,
|
||||
orderBy: { startDate: 'asc' },
|
||||
});
|
||||
} catch {}
|
||||
|
||||
await Promise.allSettled(events.map(async ev => {
|
||||
const registrations = await prisma.registration.findMany({
|
||||
where: { eventId: ev.id },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
payments: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: { OR: [{ eventId: ev.id }, { registration: { eventId: ev.id } }] },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
registration: { select: { id: true, user: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now);
|
||||
const to = buildEventNotifyRecipientList(ev);
|
||||
if (to.length) await sendMail({ to: to.join(','), subject, html, text });
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to send daily event summaries:', e);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendRegistrationEmails,
|
||||
sendRegistrationUpdatedEmails,
|
||||
sendPaymentEmails,
|
||||
sendDailyEventSummaries,
|
||||
sendRefundEmail,
|
||||
sendSelfServiceRegistrationEmails,
|
||||
sendDonationAssignmentEmails,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Routing helpers for sending notifications based on a user's
|
||||
* notificationPreference (email | whatsapp | both).
|
||||
*
|
||||
* Security-critical messages (password reset, login alert, password changed,
|
||||
* account closed) always send email regardless of preference, and additionally
|
||||
* send a WhatsApp message when preference includes it.
|
||||
*
|
||||
* Informational messages (registration, payment, tickets) respect the
|
||||
* preference fully.
|
||||
*/
|
||||
|
||||
const { isValidZAPhone, sendText, sendPdf } = require('./whatsapp');
|
||||
|
||||
/** Returns true when the user should receive a WhatsApp notification. */
|
||||
function canWhatsApp(user) {
|
||||
if (!user) return false;
|
||||
const pref = user.notificationPreference || 'email';
|
||||
return (pref === 'whatsapp' || pref === 'both') && isValidZAPhone(user.phoneNumber);
|
||||
}
|
||||
|
||||
/** Returns true when the user should receive an email notification. */
|
||||
function shouldEmail(user) {
|
||||
if (!user) return false;
|
||||
const pref = user.notificationPreference || 'email';
|
||||
return pref === 'email' || pref === 'both';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget WhatsApp text to a user.
|
||||
* Silently skips if the user can't receive WhatsApp.
|
||||
*/
|
||||
async function waText(user, message) {
|
||||
if (!canWhatsApp(user)) return;
|
||||
try {
|
||||
await sendText(user.phoneNumber, message);
|
||||
} catch (e) {
|
||||
console.warn('[notify] WhatsApp text failed:', e?.response?.data?.message || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget WhatsApp PDF to a user.
|
||||
* Silently skips if the user can't receive WhatsApp.
|
||||
*/
|
||||
async function waPdf(user, localPdfPath, filename, caption) {
|
||||
if (!canWhatsApp(user)) return;
|
||||
try {
|
||||
await sendPdf(user.phoneNumber, localPdfPath, filename, caption);
|
||||
} catch (e) {
|
||||
console.warn('[notify] WhatsApp PDF failed:', e?.response?.data?.message || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like waText but bypasses the notification preference check.
|
||||
* Use as a fallback when email is not available (guest / no valid email),
|
||||
* so the user still receives notifications via WhatsApp if they have a phone.
|
||||
*/
|
||||
async function waTextAny(user, message) {
|
||||
if (!user || !isValidZAPhone(user.phoneNumber)) return;
|
||||
try {
|
||||
await sendText(user.phoneNumber, message);
|
||||
} catch (e) {
|
||||
console.warn('[notify] WhatsApp text (fallback) failed:', e?.response?.data?.message || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like waPdf but bypasses the notification preference check.
|
||||
* Use as a fallback when email is not available (guest / no valid email).
|
||||
*/
|
||||
async function waPdfAny(user, localPdfPath, filename, caption) {
|
||||
if (!user || !isValidZAPhone(user.phoneNumber)) return;
|
||||
try {
|
||||
await sendPdf(user.phoneNumber, localPdfPath, filename, caption);
|
||||
} catch (e) {
|
||||
console.warn('[notify] WhatsApp PDF (fallback) failed:', e?.response?.data?.message || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { canWhatsApp, shouldEmail, waText, waPdf, waTextAny, waPdfAny };
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Early-bird pricing utility
|
||||
*
|
||||
* Rules:
|
||||
* - Base price is eventOption.price
|
||||
* - resolveOptionPrice: picks the cheapest applicable tier, checking both deadline AND stock limits.
|
||||
* Used at registration-creation time and again at payment-initiation time.
|
||||
* - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and
|
||||
* as a fallback for legacy RegistrationOption rows that have no priceSnapshot.
|
||||
* - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after
|
||||
* refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice.
|
||||
* - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption
|
||||
* that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier
|
||||
* is now expired or its stock is exhausted.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
/**
|
||||
* Compute effective unit price for an event option at a given time considering early-bird tiers.
|
||||
* Only checks deadline (not stock). Used for display and as a legacy fallback.
|
||||
*
|
||||
* @param {object} eventOption - includes price:number and earlyBirdTiers?:Array<{deadline:string|Date, price:number}>
|
||||
* @param {Date|string|null} referenceTime - usually the last payment time; if null, falls back to atTime
|
||||
* @param {Date} atTime - payment/evaluation time (e.g., now or payment.createdAt)
|
||||
* @returns {number}
|
||||
*/
|
||||
function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
|
||||
if (!eventOption) return 0;
|
||||
const base = Number(eventOption.price || 0);
|
||||
const tiers = Array.isArray(eventOption.earlyBirdTiers) ? eventOption.earlyBirdTiers.slice() : [];
|
||||
if (!tiers.length) return base;
|
||||
|
||||
const t = atTime ? new Date(atTime) : new Date();
|
||||
// If no referenceTime (no payments yet), use atTime so early-bird applies based on "now"
|
||||
const ref = referenceTime ? new Date(referenceTime) : t;
|
||||
|
||||
// Only tiers whose deadline is after BOTH reference and payment/evaluation times qualify
|
||||
const applicable = tiers
|
||||
.map(x => ({ ...x, deadline: new Date(x.deadline) }))
|
||||
.filter(x => (ref < x.deadline) && (t < x.deadline))
|
||||
.sort((a, b) => a.deadline.getTime() - b.deadline.getTime() || (a.order || 0) - (b.order || 0) || a.price - b.price);
|
||||
|
||||
if (applicable.length === 0) return base;
|
||||
const chosen = applicable[0];
|
||||
const price = Number(chosen.price);
|
||||
if (!(price >= 0)) return base;
|
||||
return price;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best applicable early-bird tier price for an event option.
|
||||
* Checks BOTH deadline AND stock limits. Cancelled registrations are excluded from stock counts.
|
||||
* Tiers are sorted cheapest-first (best deal for the user); earliest deadline breaks ties.
|
||||
*
|
||||
* @param {object} option - EventOption with price, earlyBirdTiers[]
|
||||
* @param {number} requestedQty - quantity being purchased
|
||||
* @returns {Promise<{ price: number, tierId: string|null }>}
|
||||
*/
|
||||
async function resolveOptionPrice(option, requestedQty = 1) {
|
||||
const now = new Date();
|
||||
// Only option-level tiers (no variant association)
|
||||
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
|
||||
.filter(t => !t.variantId)
|
||||
.slice();
|
||||
|
||||
// Sort cheapest-first; earliest deadline breaks ties
|
||||
tiers.sort((a, b) => {
|
||||
if (a.price !== b.price) return a.price - b.price;
|
||||
return new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
|
||||
});
|
||||
|
||||
for (const tier of tiers) {
|
||||
// Skip expired tiers
|
||||
if (now >= new Date(tier.deadline)) continue;
|
||||
|
||||
// Check stock limit if one is set
|
||||
if (tier.stockLimit > 0) {
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: {
|
||||
appliedTierId: tier.id,
|
||||
registration: { status: { not: 'cancelled' } }
|
||||
},
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const tierSold = soldAgg._sum?.quantity || 0;
|
||||
if (tierSold + requestedQty > tier.stockLimit) continue; // tier exhausted — try next
|
||||
}
|
||||
|
||||
return { price: tier.price, tierId: tier.id };
|
||||
}
|
||||
|
||||
// No tier applicable — fall back to base option price
|
||||
return { price: option.price, tierId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best applicable early-bird tier price for a specific variant.
|
||||
* Checks tiers that have variantId matching the given variant.
|
||||
* Falls back to variant.price (or option.price if variant has no override) when no tier applies.
|
||||
*
|
||||
* @param {object} option - EventOption with price, earlyBirdTiers[], variants[]
|
||||
* @param {string} variantId
|
||||
* @param {number} requestedQty
|
||||
* @returns {Promise<{ price: number, tierId: string|null }>}
|
||||
*/
|
||||
async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
|
||||
const now = new Date();
|
||||
const variant = (option.variants || []).find(v => v.id === variantId);
|
||||
const basePrice = (variant && variant.price !== null && variant.price !== undefined)
|
||||
? Number(variant.price)
|
||||
: Number(option.price || 0);
|
||||
|
||||
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
|
||||
.filter(t => t.variantId === variantId)
|
||||
.slice();
|
||||
|
||||
if (!tiers.length) return { price: basePrice, tierId: null };
|
||||
|
||||
tiers.sort((a, b) => {
|
||||
if (a.price !== b.price) return a.price - b.price;
|
||||
return new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
|
||||
});
|
||||
|
||||
for (const tier of tiers) {
|
||||
if (now >= new Date(tier.deadline)) continue;
|
||||
if (tier.stockLimit > 0) {
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const tierSold = soldAgg._sum?.quantity || 0;
|
||||
if (tierSold + requestedQty > tier.stockLimit) continue;
|
||||
}
|
||||
return { price: tier.price, tierId: tier.id };
|
||||
}
|
||||
|
||||
return { price: basePrice, tierId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-evaluate early-bird prices for all RegistrationOptions that have an appliedTierId.
|
||||
* If a tier is now expired or its stock is exhausted, the next applicable tier (or base price)
|
||||
* is resolved and priceSnapshot + appliedTierId are updated in the DB.
|
||||
*
|
||||
* Call this before processing any payment to ensure stock-based price forfeiture is enforced.
|
||||
*
|
||||
* @param {string} registrationId
|
||||
* @returns {Promise<{ changed: boolean }>}
|
||||
*/
|
||||
async function refreshPricingForRegistration(registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true, variants: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!registration) return { changed: false };
|
||||
|
||||
let anyChanged = false;
|
||||
|
||||
for (const ro of registration.registrationOptions) {
|
||||
// Only refresh options that were priced via a tier
|
||||
if (!ro.appliedTierId) continue;
|
||||
|
||||
// Find the currently applied tier
|
||||
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId);
|
||||
|
||||
if (currentTier && new Date() < new Date(currentTier.deadline)) {
|
||||
// The tier's deadline is still in the future — honor the locked price.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deadline has passed (or tier record missing) — resolve the next applicable tier
|
||||
const resolved = ro.variantId
|
||||
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity)
|
||||
: await resolveOptionPrice(ro.eventOption, ro.quantity);
|
||||
|
||||
const tierChanged = resolved.tierId !== ro.appliedTierId;
|
||||
const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001;
|
||||
|
||||
if (tierChanged || priceChanged) {
|
||||
await prisma.registrationOption.update({
|
||||
where: { id: ro.id },
|
||||
data: {
|
||||
priceSnapshot: resolved.price,
|
||||
appliedTierId: resolved.tierId
|
||||
}
|
||||
});
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { changed: anyChanged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute total due for a registration at a given time.
|
||||
*
|
||||
* Uses priceSnapshot when present (authoritative — set at registration time and refreshed
|
||||
* before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice
|
||||
* for legacy rows without a snapshot.
|
||||
*
|
||||
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption}
|
||||
* and optionally payments[]
|
||||
* @param {Date} atTime - evaluation time (used for legacy fallback only)
|
||||
* @returns {number}
|
||||
*/
|
||||
function computeRegistrationTotalDue(registration, atTime) {
|
||||
if (!registration || !Array.isArray(registration.registrationOptions)) return 0;
|
||||
|
||||
// Determine the last payment time (used only for the legacy getEffectiveUnitPrice fallback)
|
||||
let lastPaymentAt = null;
|
||||
try {
|
||||
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
|
||||
lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return registration.registrationOptions.reduce((sum, ro) => {
|
||||
const qty = Number(ro.quantity || 0);
|
||||
let unit;
|
||||
|
||||
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) {
|
||||
// priceSnapshot is authoritative — set at registration creation and kept current
|
||||
// by refreshPricingForRegistration at payment initiation time.
|
||||
unit = ro.priceSnapshot;
|
||||
} else {
|
||||
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines
|
||||
const eo = ro.eventOption || {};
|
||||
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime);
|
||||
}
|
||||
|
||||
return sum + qty * unit;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEffectiveUnitPrice,
|
||||
resolveOptionPrice,
|
||||
resolveVariantTierPrice,
|
||||
refreshPricingForRegistration,
|
||||
computeRegistrationTotalDue,
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const QUEUE_PATH = path.join(DATA_DIR, 'scheduled-emails.json');
|
||||
|
||||
function ensureStore() {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
if (!fs.existsSync(QUEUE_PATH)) fs.writeFileSync(QUEUE_PATH, JSON.stringify({ jobs: [] }, null, 2), 'utf-8');
|
||||
} catch (e) {
|
||||
// Best-effort; throws will surface to caller
|
||||
}
|
||||
}
|
||||
|
||||
function loadAll() {
|
||||
ensureStore();
|
||||
try {
|
||||
const raw = fs.readFileSync(QUEUE_PATH, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
const jobs = Array.isArray(data?.jobs) ? data.jobs : [];
|
||||
return jobs;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveAll(jobs) {
|
||||
ensureStore();
|
||||
const payload = { jobs: Array.isArray(jobs) ? jobs : [] };
|
||||
// Simple atomic-ish write
|
||||
const tmp = QUEUE_PATH + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
fs.renameSync(tmp, QUEUE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a scheduled job
|
||||
* @param {object} job { id?, eventId, createdById, scheduledAt: ISO string, payload: body for emailEventAttendees }
|
||||
*/
|
||||
function addJob(job) {
|
||||
const now = new Date();
|
||||
const id = job.id || uuidv4();
|
||||
const rec = {
|
||||
id,
|
||||
eventId: job.eventId || null,
|
||||
broadcast: !!job.broadcast,
|
||||
createdById: job.createdById || null,
|
||||
scheduledAt: job.scheduledAt,
|
||||
createdAt: now.toISOString(),
|
||||
status: 'queued', // queued | sending | sent | error
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
payload: job.payload || {},
|
||||
};
|
||||
const jobs = loadAll();
|
||||
jobs.push(rec);
|
||||
saveAll(jobs);
|
||||
return rec;
|
||||
}
|
||||
|
||||
function listJobs(filter = {}) {
|
||||
const jobs = loadAll();
|
||||
// Basic filter support
|
||||
return jobs.filter(j => {
|
||||
if (filter.status && j.status !== filter.status) return false;
|
||||
if (filter.eventId && j.eventId !== filter.eventId) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function getDueJobs(now = new Date()) {
|
||||
const jobs = loadAll();
|
||||
const t = now instanceof Date ? now : new Date(now);
|
||||
return jobs.filter(j => j.status === 'queued' && new Date(j.scheduledAt).getTime() <= t.getTime());
|
||||
}
|
||||
|
||||
function updateJob(id, patch) {
|
||||
const jobs = loadAll();
|
||||
const idx = jobs.findIndex(j => j.id === id);
|
||||
if (idx === -1) return null;
|
||||
jobs[idx] = { ...jobs[idx], ...patch };
|
||||
saveAll(jobs);
|
||||
return jobs[idx];
|
||||
}
|
||||
|
||||
function getJob(id) {
|
||||
const jobs = loadAll();
|
||||
return jobs.find(j => j.id === id) || null;
|
||||
}
|
||||
|
||||
function deleteJob(id) {
|
||||
const jobs = loadAll();
|
||||
const filtered = jobs.filter(j => j.id !== id);
|
||||
if (filtered.length === jobs.length) return false;
|
||||
saveAll(filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addJob,
|
||||
listJobs,
|
||||
getDueJobs,
|
||||
updateJob,
|
||||
getJob,
|
||||
deleteJob,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Lightweight in-process cache for AppSetting values.
|
||||
* Refreshes every 60 seconds so changes made in the admin settings page
|
||||
* take effect without a server restart.
|
||||
* Falls back to environment variables when a key is not found in the DB.
|
||||
*
|
||||
* Encrypted keys (smtp_user, smtp_pass) are automatically decrypted on read.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { decrypt, isEncrypted } = require('./encryption');
|
||||
|
||||
// Keys stored encrypted in the DB — decrypted transparently on read.
|
||||
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
|
||||
|
||||
let _cache = {};
|
||||
let _lastFetch = 0;
|
||||
const TTL_MS = 60 * 1000;
|
||||
|
||||
async function refreshIfStale() {
|
||||
const now = Date.now();
|
||||
if (now - _lastFetch < TTL_MS && _lastFetch > 0) return;
|
||||
try {
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
const fresh = {};
|
||||
for (const r of rows) fresh[r.key] = r.value;
|
||||
_cache = fresh;
|
||||
_lastFetch = now;
|
||||
} catch {
|
||||
// DB unreachable — keep existing cache; will retry next call
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal getter that optionally decrypts encrypted keys.
|
||||
* @param {string} key
|
||||
* @param {string} [envFallback]
|
||||
* @returns {string}
|
||||
*/
|
||||
function _getSync(key, envFallback = '') {
|
||||
const v = _cache[key];
|
||||
const raw = (v !== undefined && v !== null && v !== '') ? v : (envFallback || '');
|
||||
if (ENCRYPTED_KEYS.has(key) && isEncrypted(raw)) {
|
||||
try { return decrypt(raw); } catch { return ''; }
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async getter — always checks for a stale cache before returning.
|
||||
* @param {string} key
|
||||
* @param {string} [envFallback]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function getSetting(key, envFallback = '') {
|
||||
await refreshIfStale();
|
||||
return _getSync(key, envFallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync wrapper for use in synchronous functions.
|
||||
* Uses the current in-memory snapshot; relies on the cache being warmed at startup.
|
||||
*/
|
||||
const getSettingSync = _getSync;
|
||||
|
||||
/**
|
||||
* Call once at startup to pre-warm the cache so synchronous callers get DB values.
|
||||
*/
|
||||
async function warmCache() {
|
||||
await refreshIfStale();
|
||||
}
|
||||
|
||||
/** Invalidate the cache and immediately re-warm it in the background. */
|
||||
function invalidate() {
|
||||
_lastFetch = 0;
|
||||
refreshIfStale().catch(() => {});
|
||||
}
|
||||
|
||||
module.exports = { getSetting, getSettingSync, warmCache, invalidate, ENCRYPTED_KEYS };
|
||||
@@ -0,0 +1,145 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* Generate tickets for a registration when payment status is "paid".
|
||||
*
|
||||
* Rules:
|
||||
* - Exactly ONE ticket per registrationOption (keyed by eventOptionId).
|
||||
* - If duplicate registrationOptions exist for the same eventOptionId (old data),
|
||||
* consolidate them: merge quantities, keep the one with scan history, delete the rest.
|
||||
* - If duplicate ticket records exist for the same registrationOption (old data),
|
||||
* keep the primary (scanned one, or oldest), update its quantity, delete the rest.
|
||||
*
|
||||
* @param {string} registrationId
|
||||
* @returns {Promise<Array>} newly-created ticket records (empty when all already existed)
|
||||
*/
|
||||
const generateTicketsForRegistration = async (registrationId) => {
|
||||
try {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: true,
|
||||
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
user: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!registration) throw new Error('Registration not found');
|
||||
if (registration.status !== 'paid') return [];
|
||||
|
||||
// If event has a required form, ensure sufficient responses exist
|
||||
try {
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
|
||||
if (form && form.isRequired) {
|
||||
const requiredCount = (registration.registrationOptions || [])
|
||||
.filter(ro => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
const responsesCount = await prisma.formResponse.count({ where: { registrationId } });
|
||||
if (responsesCount < requiredCount) return [];
|
||||
}
|
||||
} catch (_) { /* form models unavailable — don't block */ }
|
||||
|
||||
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ──
|
||||
// Key on both fields so that different variants of the same option are never merged
|
||||
const byOption = new Map();
|
||||
for (const ro of registration.registrationOptions) {
|
||||
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
|
||||
if (!byOption.has(key)) byOption.set(key, []);
|
||||
byOption.get(key).push(ro);
|
||||
}
|
||||
|
||||
// For each group with duplicates, merge into the one that has tickets (or the first)
|
||||
for (const [, group] of byOption) {
|
||||
if (group.length <= 1) continue;
|
||||
|
||||
// Prefer the option that already has tickets
|
||||
const withTickets = group.filter(ro => (ro.tickets || []).length > 0);
|
||||
const primary = withTickets.length > 0 ? withTickets[0] : group[0];
|
||||
const duplicates = group.filter(ro => ro.id !== primary.id);
|
||||
|
||||
// Move all tickets from duplicates to primary, then delete duplicate options
|
||||
for (const dup of duplicates) {
|
||||
for (const t of (dup.tickets || [])) {
|
||||
await prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } });
|
||||
}
|
||||
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
|
||||
await prisma.registrationOption.update({
|
||||
where: { id: primary.id },
|
||||
data: { quantity: (primary.quantity || 0) + totalMergedQty, }
|
||||
});
|
||||
await prisma.registrationOption.delete({ where: { id: dup.id } });
|
||||
}
|
||||
|
||||
// Re-load the primary's current quantity after merge
|
||||
const updated = await prisma.registrationOption.findUnique({ where: { id: primary.id } });
|
||||
primary.quantity = updated?.quantity ?? primary.quantity;
|
||||
// Reload tickets
|
||||
primary.tickets = await prisma.ticket.findMany({
|
||||
where: { registrationOptionId: primary.id },
|
||||
include: { usages: true },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
|
||||
const generatedTickets = [];
|
||||
|
||||
// Re-read fresh list (some options may have been deleted above)
|
||||
const freshOptions = await prisma.registrationOption.findMany({
|
||||
where: { registrationId },
|
||||
include: {
|
||||
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
|
||||
}
|
||||
});
|
||||
|
||||
for (const option of freshOptions) {
|
||||
const targetQty = option.quantity || 1;
|
||||
const existingTickets = option.tickets || [];
|
||||
|
||||
if (existingTickets.length === 0) {
|
||||
// Create one ticket
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
qrCode: uuidv4(),
|
||||
registrationOptionId: option.id,
|
||||
userId: registration.userId,
|
||||
eventId: registration.eventId,
|
||||
quantity: targetQty,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
generatedTickets.push(ticket);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pick primary: prefer scanned, otherwise oldest
|
||||
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
|
||||
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
|
||||
|
||||
// Update quantity on primary if needed
|
||||
if (primary.quantity !== targetQty) {
|
||||
await prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
// Delete unscanned duplicates
|
||||
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0);
|
||||
if (dups.length > 0) {
|
||||
await prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } });
|
||||
}
|
||||
}
|
||||
|
||||
return generatedTickets;
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { generateTicketsForRegistration };
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* WhatsApp-formatted message builders.
|
||||
*
|
||||
* Styling reference: https://faq.whatsapp.com/539178204879377/
|
||||
* *bold* _italic_ ~strikethrough~ ```monospace```
|
||||
* > blockquote - unordered list 1. numbered list
|
||||
* # Heading 1 ## Heading 2 ### Heading 3
|
||||
*/
|
||||
|
||||
function fmtAmount(amt) {
|
||||
return `R${Number(amt || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function fmtDateShort(d) {
|
||||
try {
|
||||
return new Date(d).toLocaleDateString('en-GB', {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||
});
|
||||
} catch { return String(d); }
|
||||
}
|
||||
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
function getOrg() {
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||||
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
|
||||
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Registration confirmation ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {object} reg - full registration from loadRegistrationFull
|
||||
* @param {{ isNew?: boolean, balance?: number, totalDue?: number, totalPaid?: number }} opts
|
||||
*/
|
||||
function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid } = {}) {
|
||||
const org = getOrg();
|
||||
const eventTitle = reg.event?.title || 'the event';
|
||||
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
|
||||
const name = reg.user?.name || 'there';
|
||||
|
||||
const heading = isNew ? '🎉 *Registration Confirmed!*' : '✏️ *Registration Updated*';
|
||||
const intro = isNew
|
||||
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
|
||||
: `Your registration for *${eventTitle}* has been updated.`;
|
||||
|
||||
const items = (reg.registrationOptions || [])
|
||||
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`)
|
||||
.join('\n');
|
||||
|
||||
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const due = totalDue ?? 0;
|
||||
const bal = balance ?? Math.max(due - paid, 0);
|
||||
|
||||
const finLine = bal <= 0
|
||||
? `✅ *Fully paid — you're all set!*`
|
||||
: `*Balance due:* ${fmtAmount(bal)}\n_Pay at ${org.url} or at the door._`;
|
||||
|
||||
return [
|
||||
heading,
|
||||
'',
|
||||
`Hi ${name},`,
|
||||
'',
|
||||
intro,
|
||||
'',
|
||||
'*Your selections:*',
|
||||
items || '—',
|
||||
'',
|
||||
`*Total:* ${fmtAmount(due)} *Paid:* ${fmtAmount(paid)}`,
|
||||
finLine,
|
||||
'',
|
||||
`_${org.name}_ | ${org.url}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Payment receipt ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildWAPayment(payment) {
|
||||
const org = getOrg();
|
||||
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
|
||||
const name = (payment.registration?.user || payment.user)?.name || 'there';
|
||||
const amount = fmtAmount(payment.amount);
|
||||
|
||||
const reg = payment.registration;
|
||||
let balLine = '';
|
||||
if (reg) {
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
balLine = balance <= 0
|
||||
? `\n✅ *Fully paid!* Your tickets have been sent.`
|
||||
: `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`;
|
||||
}
|
||||
|
||||
return [
|
||||
`✅ *Payment Received*`,
|
||||
'',
|
||||
`Hi ${name},`,
|
||||
'',
|
||||
`We've received your payment of *${amount}* for *${eventTitle}*.`,
|
||||
balLine,
|
||||
'',
|
||||
`_${org.name}_ | ${org.url}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Login notification ───────────────────────────────────────────────────────
|
||||
|
||||
function buildWALogin({ name, when, location, userAgent }) {
|
||||
const org = getOrg();
|
||||
return [
|
||||
`🔐 *New Login Detected*`,
|
||||
'',
|
||||
`Hi ${name || 'there'},`,
|
||||
'',
|
||||
`A new login to your *${org.name}* account was detected.`,
|
||||
'',
|
||||
`*Time:* ${when}`,
|
||||
`*Location:* ${location}`,
|
||||
`*Device:* ${userAgent}`,
|
||||
'',
|
||||
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
|
||||
'',
|
||||
`_If this was you, no action is needed._`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Welcome ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildWAWelcome({ name, events }) {
|
||||
const org = getOrg();
|
||||
const hasEvents = Array.isArray(events) && events.length > 0;
|
||||
|
||||
const eventsBlock = hasEvents
|
||||
? [
|
||||
'*Upcoming events:*',
|
||||
...events.map(e =>
|
||||
`- *${e.title}* — ${new Date(e.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`
|
||||
),
|
||||
].join('\n')
|
||||
: 'Keep an eye on our website for upcoming events.';
|
||||
|
||||
return [
|
||||
`🎉 *Welcome to ${org.name}!*`,
|
||||
'',
|
||||
`Hi ${name || 'there'},`,
|
||||
'',
|
||||
`Your account is set up and ready. Use it to register for events, manage your bookings, and access your tickets.`,
|
||||
'',
|
||||
eventsBlock,
|
||||
'',
|
||||
`${org.url}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Account closed ───────────────────────────────────────────────────────────
|
||||
|
||||
function buildWAAccountClosed({ name, dataDeleted }) {
|
||||
const org = getOrg();
|
||||
const detail = dataDeleted
|
||||
? 'All personal data associated with your account has been permanently deleted.'
|
||||
: `Your account has been deactivated. To also delete your personal data, contact ${org.email}.`;
|
||||
|
||||
return [
|
||||
`🔒 *Account Closed*`,
|
||||
'',
|
||||
`Hi ${name || 'there'},`,
|
||||
'',
|
||||
`Your *${org.name}* account has been successfully closed.`,
|
||||
'',
|
||||
detail,
|
||||
'',
|
||||
`_${org.name}_ | ${org.email}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Ticket delivery caption ──────────────────────────────────────────────────
|
||||
|
||||
function buildWATicketCaption({ name, eventTitle, eventDate }) {
|
||||
return [
|
||||
`🎟️ *Your tickets for ${eventTitle}*`,
|
||||
'',
|
||||
`Hi ${name || 'there'}! Your tickets${eventDate ? ` for *${eventDate}*` : ''} are attached.`,
|
||||
'Please show this PDF (printed or on your phone) at the event entrance.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Refund notification ──────────────────────────────────────────────────────
|
||||
|
||||
function buildWARefund(payment) {
|
||||
const org = getOrg();
|
||||
const user = payment.user;
|
||||
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
|
||||
const amt = fmtAmount(Math.abs(payment.amount || 0));
|
||||
const name = user?.name || 'there';
|
||||
|
||||
return [
|
||||
`💸 *Refund Processed*`,
|
||||
'',
|
||||
`Hi ${name},`,
|
||||
'',
|
||||
`A refund of *${amt}* for *${eventTitle}* has been processed.`,
|
||||
'',
|
||||
`Refunds may take a few business days to appear depending on your bank.`,
|
||||
'',
|
||||
`_${org.name}_ | ${org.email}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Donation applied to a registration ────────────────────────────────────────
|
||||
//
|
||||
// Distinct from buildWAPayment: sent to the REGISTRANT when staff apply someone else's
|
||||
// donation to their registration — anonymous (no donor name), and never "payment received"
|
||||
// wording since they didn't pay anything themselves.
|
||||
|
||||
function buildWADonationAppliedToRegistrant(payment) {
|
||||
const org = getOrg();
|
||||
const reg = payment.registration;
|
||||
const eventTitle = reg?.event?.title || 'the event';
|
||||
const name = reg?.user?.name || 'there';
|
||||
const amount = fmtAmount(payment.amount);
|
||||
|
||||
let balLine = '';
|
||||
if (reg) {
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
balLine = balance <= 0
|
||||
? `\n✅ *Fully paid!* Your tickets have been sent.`
|
||||
: `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`;
|
||||
}
|
||||
|
||||
return [
|
||||
`🎁 *Donation Applied*`,
|
||||
'',
|
||||
`Hi ${name},`,
|
||||
'',
|
||||
`A donation of *${amount}* was applied to your registration for *${eventTitle}*.`,
|
||||
balLine,
|
||||
'',
|
||||
`_${org.name}_ | ${org.url}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildWARegistration,
|
||||
buildWAPayment,
|
||||
buildWARefund,
|
||||
buildWADonationAppliedToRegistrant,
|
||||
buildWALogin,
|
||||
buildWAWelcome,
|
||||
buildWAAccountClosed,
|
||||
buildWATicketCaption,
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
const axios = require('axios');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const BASE = 'https://api.wawp.net/v2';
|
||||
|
||||
// ─── Config cache (DB-backed, env fallback) ───────────────────────────────────
|
||||
|
||||
let _configCache = null;
|
||||
let _configCacheAt = 0;
|
||||
const CONFIG_TTL_MS = 30_000; // 30 s
|
||||
|
||||
/**
|
||||
* Load WAWP credentials from DB (AppSetting table), falling back to .env.
|
||||
* Result is cached for 30 seconds so repeated calls don't hit the DB.
|
||||
*/
|
||||
async function getConfig() {
|
||||
const now = Date.now();
|
||||
if (_configCache && (now - _configCacheAt) < CONFIG_TTL_MS) return _configCache;
|
||||
|
||||
try {
|
||||
const prisma = require('../config/db');
|
||||
const rows = await prisma.appSetting.findMany({
|
||||
where: { key: { in: ['WAWP_ACCESS_TOKEN', 'WAWP_INSTANCE_ID'] } },
|
||||
});
|
||||
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
_configCache = {
|
||||
token: map.WAWP_ACCESS_TOKEN || process.env.WAWP_ACCESS_TOKEN || '',
|
||||
instanceId: map.WAWP_INSTANCE_ID || process.env.WAWP_INSTANCE_ID || '',
|
||||
};
|
||||
} catch {
|
||||
// DB unavailable — fall back to env vars
|
||||
_configCache = {
|
||||
token: process.env.WAWP_ACCESS_TOKEN || '',
|
||||
instanceId: process.env.WAWP_INSTANCE_ID || '',
|
||||
};
|
||||
}
|
||||
_configCacheAt = now;
|
||||
return _configCache;
|
||||
}
|
||||
|
||||
/** Save WAWP credentials to DB and invalidate the cache. */
|
||||
async function setConfig(token, instanceId) {
|
||||
const prisma = require('../config/db');
|
||||
await prisma.$transaction([
|
||||
prisma.appSetting.upsert({
|
||||
where: { key: 'WAWP_ACCESS_TOKEN' },
|
||||
update: { value: token },
|
||||
create: { key: 'WAWP_ACCESS_TOKEN', value: token },
|
||||
}),
|
||||
prisma.appSetting.upsert({
|
||||
where: { key: 'WAWP_INSTANCE_ID' },
|
||||
update: { value: instanceId },
|
||||
create: { key: 'WAWP_INSTANCE_ID', value: instanceId },
|
||||
}),
|
||||
]);
|
||||
_configCache = null; // force reload on next getConfig()
|
||||
}
|
||||
|
||||
async function isConfigured() {
|
||||
const { token, instanceId } = await getConfig();
|
||||
return !!(token && instanceId);
|
||||
}
|
||||
|
||||
// ─── SA phone normalisation ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalises any common South African phone format to 27xxxxxxxxx (11 digits).
|
||||
* Handles: 0821234567 / 082 123 4567 / +27821234567 / +27 82 123 4567
|
||||
* Returns null when the number cannot be resolved to a valid SA mobile.
|
||||
*/
|
||||
function normalizeZAPhone(raw) {
|
||||
if (!raw) return null;
|
||||
let digits = String(raw).replace(/\D/g, '');
|
||||
|
||||
// Local format: leading 0 + 9 digits (total 10)
|
||||
if (digits.startsWith('0') && digits.length === 10) {
|
||||
digits = '27' + digits.slice(1);
|
||||
}
|
||||
|
||||
// Must now be exactly 11 digits starting with 27
|
||||
if (/^27\d{9}$/.test(digits)) return digits;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns true when raw is a valid SA mobile number. */
|
||||
function isValidZAPhone(raw) {
|
||||
return normalizeZAPhone(raw) !== null;
|
||||
}
|
||||
|
||||
/** Converts a phone number to the WAWP chatId format (e.g. 27821234567@c.us). */
|
||||
function toChatId(raw) {
|
||||
const n = normalizeZAPhone(raw);
|
||||
return n ? `${n}@c.us` : null;
|
||||
}
|
||||
|
||||
// ─── "Session not found" auto-recovery ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true when the WAWP error message indicates the session doesn't exist.
|
||||
*/
|
||||
function isSessionNotFound(e) {
|
||||
const msg = (e?.response?.data?.message || e?.message || '').toLowerCase();
|
||||
return msg.includes('session not found') || msg.includes('instance not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* If the WAWP API reports "Session not found", clear the stale instance ID
|
||||
* from the DB so the admin UI drops back to the Session Instance setup step.
|
||||
*
|
||||
* @param {Error} e - The error thrown by a WAWP API call
|
||||
*/
|
||||
async function handleSessionNotFound(e) {
|
||||
if (!isSessionNotFound(e)) throw e; // not our problem — re-throw
|
||||
|
||||
console.warn('[whatsapp] Session not found — clearing instance ID from DB...');
|
||||
const { token, instanceId } = await getConfig();
|
||||
|
||||
// Try to delete the stale session on WAWP (may fail — that's okay)
|
||||
try {
|
||||
await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId });
|
||||
} catch (delErr) {
|
||||
console.warn('[whatsapp] Delete stale session failed (ignored):', delErr?.response?.data?.message || delErr.message);
|
||||
}
|
||||
|
||||
// Clear the instance ID from DB so the frontend goes back to Step 2
|
||||
await setConfig(token, '');
|
||||
console.info('[whatsapp] Instance ID cleared — admin must set up a new session instance.');
|
||||
|
||||
throw new Error('SESSION_NOT_FOUND');
|
||||
}
|
||||
|
||||
// ─── Session management ───────────────────────────────────────────────────────
|
||||
|
||||
async function getStatus() {
|
||||
const { token, instanceId } = await getConfig();
|
||||
try {
|
||||
const res = await axios.post(`${BASE}/session/info`, { access_token: token, instance_id: instanceId });
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
await handleSessionNotFound(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function startSession() {
|
||||
const { token, instanceId } = await getConfig();
|
||||
try {
|
||||
const res = await axios.post(`${BASE}/session/start`, { access_token: token, instance_id: instanceId });
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
await handleSessionNotFound(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function restartSession() {
|
||||
const { token, instanceId } = await getConfig();
|
||||
try {
|
||||
const res = await axios.post(`${BASE}/session/restart`, { access_token: token, instance_id: instanceId });
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
await handleSessionNotFound(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutSession() {
|
||||
const { token, instanceId } = await getConfig();
|
||||
const res = await axios.post(`${BASE}/session/logout`, { access_token: token, instance_id: instanceId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function createInstance(name) {
|
||||
const { token } = await getConfig();
|
||||
const label = name || `hope-events-${Date.now()}`;
|
||||
const res = await axios.post(`${BASE}/session/create`, { access_token: token, name: label });
|
||||
const newInstanceId = res.data?.instance_id || res.data?.id;
|
||||
if (!newInstanceId) throw new Error('Create instance returned no instance_id');
|
||||
await setConfig(token, newInstanceId);
|
||||
return { ...res.data, instance_id: newInstanceId };
|
||||
}
|
||||
|
||||
async function deleteInstance() {
|
||||
const { token, instanceId } = await getConfig();
|
||||
if (!instanceId) throw new Error('No instance configured');
|
||||
const res = await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId });
|
||||
// Clear instance_id from DB after deletion
|
||||
await setConfig(token, '');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function getQr() {
|
||||
const { token, instanceId } = await getConfig();
|
||||
try {
|
||||
const res = await axios.post(`${BASE}/auth/qr-image`, { access_token: token, instance_id: instanceId });
|
||||
return res.data; // { qr: 'data:image/png;base64,...' }
|
||||
} catch (e) {
|
||||
await handleSessionNotFound(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPairingCode(phoneNumber) {
|
||||
const { token, instanceId } = await getConfig();
|
||||
const normalized = normalizeZAPhone(phoneNumber);
|
||||
if (!normalized) throw new Error('Invalid South African phone number');
|
||||
try {
|
||||
const res = await axios.post(`${BASE}/auth/request-code`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
phone_number: normalized,
|
||||
});
|
||||
return res.data; // { code: 'ABCD-1234' }
|
||||
} catch (e) {
|
||||
await handleSessionNotFound(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Messaging ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function sendText(toPhone, message) {
|
||||
if (!(await isConfigured())) return;
|
||||
const chatId = toChatId(toPhone);
|
||||
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; }
|
||||
const { token, instanceId } = await getConfig();
|
||||
await axios.post(`${BASE}/send/text`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a local PDF to a temporary public URL, sends it via WAWP,
|
||||
* then schedules the temp file for deletion after 5 minutes.
|
||||
*/
|
||||
async function sendPdf(toPhone, localPdfPath, filename, caption) {
|
||||
if (!(await isConfigured())) return;
|
||||
const chatId = toChatId(toPhone);
|
||||
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping PDF:', toPhone); return; }
|
||||
|
||||
const tempDir = path.join(__dirname, '../../public/uploads/tickets-temp');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const tempName = `${uuidv4()}.pdf`;
|
||||
const tempPath = path.join(tempDir, tempName);
|
||||
fs.copyFileSync(localPdfPath, tempPath);
|
||||
|
||||
const backendUrl = (process.env.BACKEND_URL || '').replace(/\/$/, '');
|
||||
const isLocalhost = !backendUrl || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(backendUrl);
|
||||
if (isLocalhost) {
|
||||
try { fs.unlinkSync(tempPath); } catch {}
|
||||
throw new Error(
|
||||
`WhatsApp PDF delivery requires a publicly accessible backend URL. ` +
|
||||
`BACKEND_URL is currently "${backendUrl || '(not set)'}". ` +
|
||||
`Set BACKEND_URL to your public backend URL (e.g. https://api.yourdomain.com) in your .env file.`
|
||||
);
|
||||
}
|
||||
const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`;
|
||||
|
||||
const { token, instanceId } = await getConfig();
|
||||
await axios.post(`${BASE}/send/pdf`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
file: {
|
||||
url: pdfUrl,
|
||||
filename: filename || 'tickets.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
},
|
||||
caption: caption || '',
|
||||
});
|
||||
|
||||
// Clean up after 5 minutes — WAWP will have fetched the file by then
|
||||
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfig,
|
||||
setConfig,
|
||||
normalizeZAPhone,
|
||||
isValidZAPhone,
|
||||
toChatId,
|
||||
isConfigured,
|
||||
getStatus,
|
||||
startSession,
|
||||
restartSession,
|
||||
logoutSession,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
getQr,
|
||||
requestPairingCode,
|
||||
sendText,
|
||||
sendPdf,
|
||||
};
|
||||
Reference in New Issue
Block a user