Add ticketed events, Jitsi meeting integration, social features, and calendar system
- Ticketed events: full CRUD, ticket tiers (free/paid/donation), Stripe checkout, QR-based check-in scanner, public event pages, ticket confirmation emails - Event formats: IN_PERSON/ONLINE/HYBRID with auto Jitsi meeting room lifecycle, ticket-gated meeting access, moderator JWT tokens, feature-flag guarded - Social engagement: challenges with scoring/leaderboards, referral tracking, volunteer spotlight, impact stories, campaign celebrations, wall of fame - Social calendar: personal calendar layers, shared calendar items with recurrence, scheduling polls, mobile day view - MCP server: events tool pack with full admin CRUD + meeting token generation - Unified calendar: eventFormat-aware tags, online event indicators - Updated docs site, pangolin configs, and various admin UI improvements Bunker Admin
This commit is contained in:
parent
62fc116c06
commit
08d8066157
556
SOCIAL_CALENDAR_PLAN.md
Normal file
556
SOCIAL_CALENDAR_PLAN.md
Normal file
@ -0,0 +1,556 @@
|
||||
# Social Calendar Feature Plan
|
||||
|
||||
**Created:** 2026-03-06
|
||||
**Status:** Planning Complete — Ready for Phase A Implementation
|
||||
**Branch:** v2
|
||||
**Feature Flag:** `enableSocialCalendar` (new, under SiteSettings)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A layered personal and social calendar system. Each user gets their own calendar with multiple layers (system-populated, user-created, external feeds). Calendars can be shared between users at the item, category (layer), or whole-calendar level. Shared views allow multiple users' events to appear on a merged, color-coded calendar. Admin shared views can auto-include users by role.
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Layers are the core abstraction** — every event belongs to a layer, layers control visibility and sharing
|
||||
- **System layers are virtual** — shifts, tickets, polls are queried live from source tables, not duplicated
|
||||
- **Recurrence uses materialization** — consistent with existing ShiftSeries pattern (generate DB rows, allow exceptions)
|
||||
- **Social-first** — friend relationships gate sharing; admin views are separate and only expose system data
|
||||
- **Privacy by default** — layers default to PRIVATE; users explicitly opt into sharing
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### CalendarLayer
|
||||
|
||||
Each user has multiple layers. System layers are auto-created on first calendar access.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | String (cuid) | PK |
|
||||
| userId | String | FK to User |
|
||||
| name | String | "Personal", "Gym", "Google Cal", etc. |
|
||||
| layerType | Enum | SYSTEM, USER, EXTERNAL |
|
||||
| systemType | Enum? | SHIFTS, TICKETS, POLLS, PUBLIC_EVENTS (for SYSTEM layers only) |
|
||||
| color | String | Hex color (#1890ff) |
|
||||
| visibility | Enum | PRIVATE, FRIENDS, PUBLIC |
|
||||
| isEnabled | Boolean | User can toggle layers on/off for themselves |
|
||||
| sortOrder | Int | Display ordering |
|
||||
| createdAt | DateTime | |
|
||||
| updatedAt | DateTime | |
|
||||
|
||||
**System layers (auto-created per user):**
|
||||
- My Shifts — from ShiftSignup records
|
||||
- My Tickets — from EventTicket records
|
||||
- My Polls — from SchedulingPollVote records
|
||||
- Public Events — the existing Gancio/platform feed (togglable)
|
||||
|
||||
System layers are **virtual** — no CalendarItem rows are created. The API queries source tables directly and maps to the CalendarItem shape at response time.
|
||||
|
||||
### CalendarItem
|
||||
|
||||
User-created events, time blocks, and cached .ics feed entries.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | String (cuid) | PK |
|
||||
| userId | String | FK to User (owner) |
|
||||
| layerId | String | FK to CalendarLayer |
|
||||
| title | String | |
|
||||
| description | String? | Text |
|
||||
| date | DateTime | Date of this occurrence |
|
||||
| startTime | String | HH:MM |
|
||||
| endTime | String | HH:MM |
|
||||
| isAllDay | Boolean | Default false |
|
||||
| itemType | Enum | EVENT, TIME_BLOCK, REMINDER |
|
||||
| location | String? | |
|
||||
| color | String? | Override (null = inherit layer color) |
|
||||
| visibility | Enum? | PRIVATE, FRIENDS, PUBLIC (null = inherit from layer) |
|
||||
| busyStatus | Enum | BUSY, TENTATIVE, FREE (default BUSY) |
|
||||
| showDetailsTo | Enum | NOBODY, FRIENDS, EVERYONE (default FRIENDS) |
|
||||
| recurrenceRule | Json? | See Recurrence section |
|
||||
| recurrenceEnd | DateTime? | When series stops |
|
||||
| seriesId | String? | Groups recurring instances |
|
||||
| isException | Boolean | Edited instance that broke from pattern |
|
||||
| sourceType | Enum | MANUAL, ICS_FEED |
|
||||
| sourceId | String? | External reference (ics UID, etc.) |
|
||||
| createdAt | DateTime | |
|
||||
| updatedAt | DateTime | |
|
||||
|
||||
**Notes:**
|
||||
- System-layer items (shifts, tickets, polls) are NOT stored as CalendarItem rows — they're virtual
|
||||
- .ics feed items ARE stored as CalendarItem rows (cached from external source, read-only to user)
|
||||
- MANUAL items are user-created freeform events
|
||||
|
||||
### CalendarFeed (.ics import)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | String (cuid) | PK |
|
||||
| userId | String | FK to User |
|
||||
| name | String | "Google Calendar", "Work" |
|
||||
| url | String | .ics URL |
|
||||
| layerId | String | FK to auto-created CalendarLayer |
|
||||
| refreshInterval | Enum | FIFTEEN_MIN, HOURLY, SIX_HOUR, DAILY |
|
||||
| lastFetchedAt | DateTime? | |
|
||||
| lastStatus | Enum | OK, ERROR, PENDING |
|
||||
| lastError | String? | Error message if failed |
|
||||
| itemCount | Int | How many items imported |
|
||||
| createdAt | DateTime | |
|
||||
| updatedAt | DateTime | |
|
||||
|
||||
### SharedCalendarView
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | String (cuid) | PK |
|
||||
| name | String | "Weekend Crew", "All Shift Admins" |
|
||||
| description | String? | |
|
||||
| ownerId | String | FK to User (creator) |
|
||||
| viewType | Enum | MANUAL, ROLE_BASED |
|
||||
| autoIncludeRoles | Json? | ["MAP_ADMIN", "USER"] (for ROLE_BASED) |
|
||||
| includedLayerTypes | Json | ["shifts", "tickets", "personal-public"] |
|
||||
| shareScope | Enum | MEMBERS, PUBLIC |
|
||||
| shareToken | String? | Unique token for public share URL |
|
||||
| createdAt | DateTime | |
|
||||
| updatedAt | DateTime | |
|
||||
|
||||
**ROLE_BASED views:**
|
||||
- Auto-include users matching specified roles
|
||||
- Only pull system layers (shifts, tickets, polls) — never personal layers
|
||||
- No notifications sent to included users (admin operational tool)
|
||||
- Created/managed by SUPER_ADMIN or MAP_ADMIN
|
||||
|
||||
**MANUAL views:**
|
||||
- Members are explicitly invited via notification system
|
||||
- Can include personal layers (with member consent)
|
||||
- Members can decline/leave
|
||||
|
||||
### SharedCalendarMember
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | String (cuid) | PK |
|
||||
| viewId | String | FK to SharedCalendarView |
|
||||
| userId | String | FK to User |
|
||||
| status | Enum | INVITED, ACCEPTED, DECLINED |
|
||||
| color | String | Auto-assigned from palette |
|
||||
| joinedAt | DateTime? | |
|
||||
| @@unique | [viewId, userId] | |
|
||||
|
||||
**Auto-color palette:**
|
||||
```
|
||||
#1890ff (blue), #52c41a (green), #fa8c16 (orange), #722ed1 (purple),
|
||||
#eb2f96 (pink), #13c2c2 (cyan), #faad14 (gold), #f5222d (red),
|
||||
#2f54eb (geekblue), #a0d911 (lime)
|
||||
```
|
||||
Assigned sequentially as members join: `PALETTE[memberIndex % length]`.
|
||||
Users can override their assigned color per shared view.
|
||||
|
||||
### SharedViewComment
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | String (cuid) | PK |
|
||||
| viewId | String | FK to SharedCalendarView |
|
||||
| userId | String | FK to User |
|
||||
| itemDate | String | YYYY-MM-DD (which date this comment is about) |
|
||||
| itemId | String? | Optional: specific CalendarItem or source item ID |
|
||||
| content | String | Text |
|
||||
| createdAt | DateTime | |
|
||||
|
||||
### SharedViewReaction
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | String (cuid) | PK |
|
||||
| viewId | String | FK to SharedCalendarView |
|
||||
| userId | String | FK to User |
|
||||
| itemId | String | CalendarItem or source item ID (e.g., "shift-abc123") |
|
||||
| emoji | String | Single emoji or shortcode |
|
||||
| createdAt | DateTime | |
|
||||
| @@unique | [viewId, userId, itemId, emoji] | One reaction type per user per item |
|
||||
|
||||
### CalendarExportToken (.ics export)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | String (cuid) | PK |
|
||||
| userId | String | FK to User |
|
||||
| token | String | Unique, random (for URL auth) |
|
||||
| includePersonal | Boolean | Whether personal events are exported |
|
||||
| includeLayers | Json? | Array of layer IDs (null = all enabled) |
|
||||
| createdAt | DateTime | |
|
||||
|
||||
Export URL: `GET /api/calendar/feed/:userId/:token.ics`
|
||||
|
||||
---
|
||||
|
||||
## Recurrence Model
|
||||
|
||||
Uses **materialization** (consistent with existing ShiftSeries pattern):
|
||||
|
||||
1. User creates a recurring event with a recurrence rule
|
||||
2. System generates CalendarItem rows for the next 3 months
|
||||
3. Background job (BullMQ, daily) extends series forward by 1 month
|
||||
4. Individual instances can be edited (becomes `isException: true`) or deleted
|
||||
5. Editing the series template updates all non-exception future instances
|
||||
|
||||
### Recurrence Rule JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"frequency": "DAILY | WEEKLY | BIWEEKLY | MONTHLY",
|
||||
"daysOfWeek": [1, 3, 5],
|
||||
"dayOfMonth": 15,
|
||||
"interval": 1
|
||||
}
|
||||
```
|
||||
|
||||
- `WEEKLY` + `daysOfWeek: [1,3,5]` = every Mon/Wed/Fri
|
||||
- `MONTHLY` + `dayOfMonth: 15` = 15th of every month
|
||||
- `BIWEEKLY` + `daysOfWeek: [2,4]` = every other Tue/Thu
|
||||
- `interval` for skip patterns (every 2 weeks, every 3 months)
|
||||
|
||||
### Recurrence Edit Options (UI)
|
||||
|
||||
When editing a recurring event instance:
|
||||
- "This event only" — marks as exception, edits the single instance
|
||||
- "This and future events" — updates template + regenerates future non-exception instances
|
||||
- "All events in series" — updates template + all instances (including past, excluding exceptions)
|
||||
|
||||
When deleting:
|
||||
- "This event only" — soft-delete the single instance
|
||||
- "This and future events" — delete future instances, set recurrenceEnd on template
|
||||
- "All events" — delete entire series
|
||||
|
||||
---
|
||||
|
||||
## Time Block Visibility (Configurable per item)
|
||||
|
||||
| `showDetailsTo` | Friends see | Public sees |
|
||||
|-----------------|-------------|-------------|
|
||||
| NOBODY | "Busy 2-4pm" | "Busy 2-4pm" |
|
||||
| FRIENDS | "Dentist 2-4pm" | "Busy 2-4pm" |
|
||||
| EVERYONE | "Dentist 2-4pm" | "Dentist 2-4pm" |
|
||||
|
||||
Combined with `busyStatus`:
|
||||
- **BUSY** — solid color block
|
||||
- **TENTATIVE** — dashed/lighter block
|
||||
- **FREE** — no block shown (informational only, e.g., "Available for meetings")
|
||||
|
||||
---
|
||||
|
||||
## Notification Types (reusing existing system)
|
||||
|
||||
| Type | Message | Trigger |
|
||||
|------|---------|---------|
|
||||
| SHARED_VIEW_INVITE | "Alice invited you to 'Weekend Crew' calendar" | Manual shared view invite |
|
||||
| SHARED_VIEW_ACCEPTED | "Bob accepted your invite to 'Weekend Crew'" | Member accepts |
|
||||
| CALENDAR_EVENT_INVITE | "Alice added you to 'Planning Meeting' on Mar 10" | Phase B: event-level sharing |
|
||||
| CALENDAR_REMINDER | "Reminder: Team standup in 15 minutes" | Future: optional reminders |
|
||||
|
||||
Role-based admin views do NOT trigger notifications (admin operational tool using only system data).
|
||||
|
||||
---
|
||||
|
||||
## Availability Finder (Phase B)
|
||||
|
||||
A dedicated mode within shared calendar views:
|
||||
|
||||
1. Toggle "Find Available Time" on a shared view
|
||||
2. System overlays all members' BUSY/TENTATIVE time blocks
|
||||
3. Highlights gaps where ALL members are free
|
||||
4. Optional: filter by time range ("only show weekday 9am-5pm slots")
|
||||
5. Click a free slot to create an event and auto-invite all members
|
||||
|
||||
Visual: green highlight on free slots, red/orange on conflicts, member avatars on busy blocks.
|
||||
|
||||
---
|
||||
|
||||
## API Routes
|
||||
|
||||
### Phase A (Personal Calendar)
|
||||
|
||||
```
|
||||
# Layers
|
||||
GET /api/calendar/layers — list user's layers
|
||||
POST /api/calendar/layers — create custom layer
|
||||
PATCH /api/calendar/layers/:id — update layer (name, color, visibility, enabled)
|
||||
DELETE /api/calendar/layers/:id — delete custom layer (+ its items)
|
||||
|
||||
# Calendar Items
|
||||
GET /api/calendar/items — list items in date range (all enabled layers merged)
|
||||
POST /api/calendar/items — create item (event, time block, reminder)
|
||||
PATCH /api/calendar/items/:id — update item
|
||||
DELETE /api/calendar/items/:id — delete item
|
||||
|
||||
# Recurrence
|
||||
POST /api/calendar/items/:id/series — edit series (this-only, this-and-future, all)
|
||||
DELETE /api/calendar/items/:id/series — delete series (this-only, this-and-future, all)
|
||||
|
||||
# Unified personal view (merges system layers + user items)
|
||||
GET /api/calendar/my — personal calendar (date range, layer filters)
|
||||
```
|
||||
|
||||
### Phase B (Sharing + Social)
|
||||
|
||||
```
|
||||
# Shared Views
|
||||
GET /api/calendar/shared — list shared views I own or am a member of
|
||||
POST /api/calendar/shared — create shared view
|
||||
PATCH /api/calendar/shared/:id — update shared view
|
||||
DELETE /api/calendar/shared/:id — delete shared view (owner only)
|
||||
|
||||
# Members
|
||||
POST /api/calendar/shared/:id/invite — invite user(s) to shared view
|
||||
PATCH /api/calendar/shared/:id/respond — accept/decline invite
|
||||
DELETE /api/calendar/shared/:id/leave — leave a shared view
|
||||
GET /api/calendar/shared/:id/members — list members + colors
|
||||
|
||||
# Merged calendar data
|
||||
GET /api/calendar/shared/:id/items — merged items from all members
|
||||
|
||||
# Event-level sharing
|
||||
POST /api/calendar/items/:id/share — share specific item with friend(s)
|
||||
|
||||
# Comments & Reactions (on shared views)
|
||||
GET /api/calendar/shared/:id/comments?date=YYYY-MM-DD
|
||||
POST /api/calendar/shared/:id/comments
|
||||
DELETE /api/calendar/shared/:id/comments/:commentId
|
||||
POST /api/calendar/shared/:id/reactions
|
||||
DELETE /api/calendar/shared/:id/reactions/:reactionId
|
||||
|
||||
# Availability finder
|
||||
GET /api/calendar/shared/:id/availability?start=&end=&dayStart=09:00&dayEnd=17:00
|
||||
|
||||
# Friend's public calendar
|
||||
GET /api/calendar/user/:userId — view a friend's public items
|
||||
```
|
||||
|
||||
### Phase C (.ics Integration)
|
||||
|
||||
```
|
||||
# Feeds (import)
|
||||
GET /api/calendar/feeds — list user's subscribed feeds
|
||||
POST /api/calendar/feeds — subscribe to .ics URL
|
||||
PATCH /api/calendar/feeds/:id — update feed settings
|
||||
DELETE /api/calendar/feeds/:id — unsubscribe (deletes layer + cached items)
|
||||
POST /api/calendar/feeds/:id/refresh — force refresh now
|
||||
|
||||
# Export
|
||||
GET /api/calendar/export/token — get or create export token
|
||||
DELETE /api/calendar/export/token — revoke export token
|
||||
GET /api/calendar/feed/:userId/:token.ics — public .ics feed (no auth, token in URL)
|
||||
```
|
||||
|
||||
### Phase D (Admin Shared Views)
|
||||
|
||||
```
|
||||
# Admin role-based views (requireRole: SUPER_ADMIN, MAP_ADMIN)
|
||||
POST /api/admin/calendar/shared — create role-based shared view
|
||||
PATCH /api/admin/calendar/shared/:id — update
|
||||
DELETE /api/admin/calendar/shared/:id — delete
|
||||
GET /api/admin/calendar/shared/:id/items — merged system-layer data for matching users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Pages & Components
|
||||
|
||||
### Phase A
|
||||
|
||||
| Component | Location | Description |
|
||||
|-----------|----------|-------------|
|
||||
| MyCalendarPage | `volunteer/MyCalendarPage.tsx` | Personal calendar (main view) |
|
||||
| CalendarLayerPanel | `components/calendar/CalendarLayerPanel.tsx` | Sidebar: layer list with toggles, colors, visibility |
|
||||
| CalendarItemModal | `components/calendar/CalendarItemModal.tsx` | Create/edit event, time block, or reminder |
|
||||
| RecurrenceEditor | `components/calendar/RecurrenceEditor.tsx` | Recurrence rule builder (frequency, days, end date) |
|
||||
| PersonalCalendarView | `components/calendar/PersonalCalendarView.tsx` | Month/week/day calendar with layer color-coding |
|
||||
| MobileDayView | `components/calendar/MobileDayView.tsx` | Day/3-day swipeable view for mobile |
|
||||
|
||||
**Mobile UX:** Day or 3-day swipeable view (not full month grid). Swipe left/right to navigate days. Layer toggles in a collapsible bottom sheet.
|
||||
|
||||
### Phase B
|
||||
|
||||
| Component | Location | Description |
|
||||
|-----------|----------|-------------|
|
||||
| SharedCalendarsPage | `volunteer/SharedCalendarsPage.tsx` | List of shared views I'm in |
|
||||
| SharedCalendarView | `components/calendar/SharedCalendarView.tsx` | Merged multi-user calendar with member colors |
|
||||
| SharedViewMembersPanel | `components/calendar/SharedViewMembersPanel.tsx` | Member list, color overrides, invite button |
|
||||
| AvailabilityFinder | `components/calendar/AvailabilityFinder.tsx` | Free/busy overlay with slot highlighting |
|
||||
| CalendarComments | `components/calendar/CalendarComments.tsx` | Comment thread for a date in shared view |
|
||||
| CalendarReactions | `components/calendar/CalendarReactions.tsx` | Emoji reactions on items |
|
||||
| FriendCalendarPage | `volunteer/FriendCalendarPage.tsx` | View a friend's public calendar |
|
||||
|
||||
### Phase C
|
||||
|
||||
| Component | Location | Description |
|
||||
|-----------|----------|-------------|
|
||||
| CalendarFeedsPanel | `components/calendar/CalendarFeedsPanel.tsx` | Manage .ics subscriptions |
|
||||
| CalendarExportPanel | `components/calendar/CalendarExportPanel.tsx` | Export token management, copy URL |
|
||||
|
||||
### Phase D
|
||||
|
||||
| Component | Location | Description |
|
||||
|-----------|----------|-------------|
|
||||
| AdminSharedViewsPage | `pages/AdminSharedViewsPage.tsx` | Admin: create/manage role-based views |
|
||||
| AdminCalendarOverview | `components/calendar/AdminCalendarOverview.tsx` | Big shift/event overview for admins |
|
||||
|
||||
---
|
||||
|
||||
## Navigation & Routing
|
||||
|
||||
### Volunteer Portal
|
||||
- Footer nav: add "Calendar" tab (CalendarOutlined icon)
|
||||
- `/volunteer/calendar` — MyCalendarPage
|
||||
- `/volunteer/calendar/shared` — SharedCalendarsPage
|
||||
- `/volunteer/calendar/shared/:id` — SharedCalendarView
|
||||
- `/volunteer/calendar/friend/:userId` — FriendCalendarPage
|
||||
|
||||
### Admin
|
||||
- Sidebar under existing section: "Calendar Overview"
|
||||
- `/app/calendar/shared` — AdminSharedViewsPage
|
||||
|
||||
---
|
||||
|
||||
## Phase Breakdown
|
||||
|
||||
### Phase A: Personal Calendar + Layers + Freeform Events
|
||||
**Scope:**
|
||||
- [x] Prisma models: CalendarLayer, CalendarItem, CalendarFeed, SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction, CalendarExportToken (+ 12 enums)
|
||||
- [x] Auto-create system layers on first calendar access (ensureSystemLayers)
|
||||
- [x] CalendarItem CRUD (create, read, update, delete)
|
||||
- [x] Recurrence: create series (materialize 3 months), edit/delete with scope options (THIS_ONLY/THIS_AND_FUTURE/ALL)
|
||||
- [ ] BullMQ job: extend recurring series daily (add 1 month of future instances)
|
||||
- [x] Personal calendar API: GET /api/calendar/my (merge system layers + user items)
|
||||
- [x] System layer queries: shifts (from ShiftSignup), tickets (from Ticket), polls (from SchedulingPollVote)
|
||||
- [x] Layer CRUD: create custom layers, toggle on/off, set color
|
||||
- [x] Layer visibility settings (PRIVATE/FRIENDS/PUBLIC) — stored but not enforced until Phase B
|
||||
- [x] MyCalendarPage: month view (desktop), day/3-day view (mobile)
|
||||
- [x] CalendarLayerPanel: sidebar with layer toggles, color pickers, inline editing, grouped by type
|
||||
- [x] CalendarItemModal: create/edit form with item type, recurrence, time block settings, scope selector
|
||||
- [x] RecurrenceEditor: frequency/days/interval/end-date with preview text
|
||||
- [x] PersonalCalendarView: desktop month view with layer-colored items
|
||||
- [x] MobileDayView: day view with time grid, current time indicator, floating add button
|
||||
- [x] Volunteer footer nav: "Calendar" tab (gated behind enableSocialCalendar)
|
||||
- [x] Feature flag: enableSocialCalendar in SiteSettings, Zod schema, frontend types, FeatureGate
|
||||
- [x] Settings page toggle added ("Social Calendar" in People & Engagement section)
|
||||
|
||||
### Phase B: Sharing + Social
|
||||
**Scope:**
|
||||
- [ ] Prisma models: SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction
|
||||
- [ ] SharedCalendarView CRUD
|
||||
- [ ] Invite flow: send invite via notification system, accept/decline/leave
|
||||
- [ ] Merged calendar API: query all members' items with layer type filtering
|
||||
- [ ] Auto-color assignment for members
|
||||
- [ ] Layer visibility enforcement (PRIVATE/FRIENDS/PUBLIC filtering based on relationship)
|
||||
- [ ] Event-level sharing: share a specific item with friend(s) via notification
|
||||
- [ ] Comments on shared view dates/items
|
||||
- [ ] Emoji reactions on shared view items
|
||||
- [ ] Availability finder: free/busy overlay, slot highlighting, time range filter
|
||||
- [ ] Friend's public calendar view
|
||||
- [ ] SharedCalendarsPage, SharedCalendarView components
|
||||
- [ ] AvailabilityFinder component
|
||||
- [ ] CalendarComments, CalendarReactions components
|
||||
- [ ] Public share URL (shareToken for unauthenticated view)
|
||||
|
||||
### Phase C: .ics Integration
|
||||
**Scope:**
|
||||
- [ ] Prisma models: CalendarFeed, CalendarExportToken
|
||||
- [ ] .ics feed parser (node-ical or similar)
|
||||
- [ ] BullMQ job: refresh feeds on configured intervals
|
||||
- [ ] Feed CRUD: subscribe, update, delete, force refresh
|
||||
- [ ] Auto-create layer per feed, cache items as CalendarItem rows
|
||||
- [ ] .ics export: generate feed from user's calendar, token-authenticated URL
|
||||
- [ ] Export token management (create, revoke)
|
||||
- [ ] CalendarFeedsPanel, CalendarExportPanel components
|
||||
|
||||
### Phase D: Admin Shared Views
|
||||
**Scope:**
|
||||
- [ ] Role-based SharedCalendarView (viewType: ROLE_BASED)
|
||||
- [ ] Auto-include users by role(s) — query live, no member rows needed
|
||||
- [ ] Only expose system layers (shifts, tickets, polls) — no personal data
|
||||
- [ ] No notifications to included users
|
||||
- [ ] Admin routes (requireRole: SUPER_ADMIN, MAP_ADMIN)
|
||||
- [ ] AdminSharedViewsPage
|
||||
- [ ] AdminCalendarOverview (big shift/event dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Extending UnifiedCalendar
|
||||
|
||||
The existing `UnifiedCalendar` component and `unified-calendar.service.ts` remain as the **public** calendar. The new personal calendar service (`calendar.service.ts`) reuses the same source queries (shifts, Gancio, polls, ticketed events) but filters to the user's own records and merges with their CalendarItem rows.
|
||||
|
||||
### Recurrence Background Job
|
||||
|
||||
```typescript
|
||||
// jobs/calendar-recurrence.job.ts
|
||||
// Runs daily via BullMQ repeatable job
|
||||
// 1. Find all CalendarItems with recurrenceRule where latest materialized date < now + 3 months
|
||||
// 2. Generate new instances up to 3 months ahead
|
||||
// 3. Skip dates that already have an instance (idempotent)
|
||||
```
|
||||
|
||||
### .ics Feed Refresh Job
|
||||
|
||||
```typescript
|
||||
// jobs/calendar-feed-refresh.job.ts
|
||||
// Runs every 15 minutes via BullMQ repeatable job
|
||||
// 1. Find feeds where lastFetchedAt + refreshInterval < now
|
||||
// 2. Fetch .ics URL, parse events
|
||||
// 3. Upsert CalendarItem rows (match on sourceId = ics UID)
|
||||
// 4. Delete items no longer in feed
|
||||
// 5. Update feed status
|
||||
```
|
||||
|
||||
### Privacy Boundaries
|
||||
|
||||
| Scenario | What's visible |
|
||||
|----------|---------------|
|
||||
| Viewing own calendar | Everything (all layers, all items) |
|
||||
| Friend views your calendar | Items on FRIENDS or PUBLIC visibility layers, plus items with individual FRIENDS/PUBLIC override |
|
||||
| Public profile calendar | Only PUBLIC visibility layers and PUBLIC override items |
|
||||
| Admin role-based view | Only system layers (shifts, tickets, polls) for users matching role filter |
|
||||
| Shared view (MANUAL) | Items from includedLayerTypes on layers with appropriate visibility for the viewer |
|
||||
| Time blocks (BUSY) | Title shown per showDetailsTo setting, always shows busy bar |
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- CalendarItem table will grow with materialized recurrence — add indexes on (userId, date), (layerId, date), (seriesId)
|
||||
- System layers query source tables directly — leverage existing indexes on ShiftSignup, EventTicket, etc.
|
||||
- .ics feed items are cached — only re-parsed on refresh interval
|
||||
- Shared view queries can be expensive (N members x M layers) — cache merged results in Redis (2min TTL, bust on member change)
|
||||
- Availability finder operates on time blocks only — narrow query scope
|
||||
|
||||
---
|
||||
|
||||
## Tracking Log
|
||||
|
||||
### 2026-03-06 — Planning Complete
|
||||
- Brainstormed feature across 3 rounds of refinement
|
||||
- Decided on layer-based architecture (system, user, external layers)
|
||||
- Recurrence uses materialization (consistent with ShiftSeries pattern)
|
||||
- Time block visibility is configurable per item (showDetailsTo: NOBODY/FRIENDS/EVERYONE)
|
||||
- Shared views support manual (invite-based) and role-based (admin, system data only)
|
||||
- Availability finder included in Phase B
|
||||
- Comments and reactions on shared view items included in Phase B
|
||||
- .ics import and export in Phase C
|
||||
- Admin role-based views in Phase D (no personal data, no notifications)
|
||||
- Reuse existing notification system for invites
|
||||
- Auto-color assignment for shared view members with user override option
|
||||
- Mobile UX: day/3-day swipeable view instead of month grid
|
||||
|
||||
### 2026-03-06 — Phase A Implementation Complete
|
||||
- Schema: 8 models, 12 enums, migration `20260306203326_social_calendar_layers_items` applied
|
||||
- Fixed pre-existing migration ordering issue (ticketed_events create must come before alter)
|
||||
- Backend: calendar.service.ts (layer mgmt, item CRUD, recurrence materialization, personal calendar merge), calendar.routes.ts (9 endpoints), calendar.schemas.ts (Zod validation)
|
||||
- Frontend: 5 new components (CalendarLayerPanel, CalendarItemModal, RecurrenceEditor, PersonalCalendarView, MobileDayView), MyCalendarPage
|
||||
- Navigation: VolunteerFooterNav Calendar tab, App.tsx route, SettingsPage toggle
|
||||
- Smoke tested: layers auto-create, item CRUD works, recurring events materialize correctly (Weekly Mon/Wed/Fri generated 11 instances through June)
|
||||
- Both API and Admin compile with zero TypeScript errors
|
||||
- Remaining Phase A item: BullMQ job for extending recurring series (not critical for launch, series materializes 3 months on creation)
|
||||
6
admin/package-lock.json
generated
6
admin/package-lock.json
generated
@ -32,6 +32,7 @@
|
||||
"grapesjs-tabs": "^1.0.6",
|
||||
"grapesjs-touch": "^0.1.1",
|
||||
"grapesjs-typed": "^2.0.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"minisearch": "^7.2.0",
|
||||
@ -2606,6 +2607,11 @@
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
|
||||
"integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA=="
|
||||
},
|
||||
"node_modules/html5-qrcode": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
|
||||
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
"grapesjs-tabs": "^1.0.6",
|
||||
"grapesjs-touch": "^0.1.1",
|
||||
"grapesjs-typed": "^2.0.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"minisearch": "^7.2.0",
|
||||
|
||||
@ -114,11 +114,27 @@ import ContactProfilePage from '@/pages/public/ContactProfilePage';
|
||||
import SocialDashboardPage from '@/pages/social/SocialDashboardPage';
|
||||
import SocialGraphPage from '@/pages/social/SocialGraphPage';
|
||||
import SocialModerationPage from '@/pages/social/SocialModerationPage';
|
||||
import ReferralAdminPage from '@/pages/social/ReferralAdminPage';
|
||||
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
|
||||
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
|
||||
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
|
||||
import ReferralsPage from '@/pages/volunteer/ReferralsPage';
|
||||
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
|
||||
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
|
||||
import WallOfFamePage from '@/pages/public/WallOfFamePage';
|
||||
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
|
||||
import MeetingPlannerPage from '@/pages/MeetingPlannerPage';
|
||||
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
|
||||
import PollsListPage from '@/pages/public/PollsListPage';
|
||||
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
||||
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
|
||||
import TicketedEventsPage from '@/pages/events/TicketedEventsPage';
|
||||
import EventDetailPage from '@/pages/events/EventDetailPage';
|
||||
import CheckInScannerPage from '@/pages/events/CheckInScannerPage';
|
||||
import TicketedEventDetailPage from '@/pages/public/TicketedEventDetailPage';
|
||||
import TicketConfirmationPage from '@/pages/public/TicketConfirmationPage';
|
||||
import MyTicketsPage from '@/pages/volunteer/MyTicketsPage';
|
||||
import MyCalendarPage from '@/pages/volunteer/MyCalendarPage';
|
||||
import NotFoundPage from '@/pages/NotFoundPage';
|
||||
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||
|
||||
@ -228,6 +244,9 @@ export default function App() {
|
||||
<Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<EventsPage />} />
|
||||
</Route>
|
||||
<Route path="/wall-of-fame" element={<FeatureGate feature="enableSocial"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<WallOfFamePage />} />
|
||||
</Route>
|
||||
{/* Scheduling polls — feature-gated */}
|
||||
<Route path="/polls" element={<FeatureGate feature="enableMeetingPlanner"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<PollsListPage />} />
|
||||
@ -236,6 +255,14 @@ export default function App() {
|
||||
<Route index element={<SchedulingPollPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Public ticketed event pages — feature-gated */}
|
||||
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<TicketedEventDetailPage />} />
|
||||
</Route>
|
||||
<Route path="/event/:slug/ticket/:ticketCode" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<TicketConfirmationPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Public meeting join page — feature-gated */}
|
||||
<Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<MeetingJoinPage />} />
|
||||
@ -318,6 +345,11 @@ export default function App() {
|
||||
<Route path="/volunteer/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/volunteer/groups/:id" element={<GroupDetailPage />} />
|
||||
<Route path="/volunteer/achievements" element={<AchievementsPage />} />
|
||||
<Route path="/volunteer/referrals" element={<ReferralsPage />} />
|
||||
<Route path="/volunteer/challenges" element={<ChallengesPage />} />
|
||||
<Route path="/volunteer/challenges/:id" element={<ChallengeDetailPage />} />
|
||||
<Route path="/volunteer/tickets" element={<MyTicketsPage />} />
|
||||
<Route path="/volunteer/calendar" element={<MyCalendarPage />} />
|
||||
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
|
||||
<Route path="/volunteer/*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
@ -328,6 +360,18 @@ export default function App() {
|
||||
element={<NavigateToCutMap />}
|
||||
/>
|
||||
|
||||
{/* Full-screen check-in scanner (outside AppLayout) */}
|
||||
<Route
|
||||
path="/app/events/:id/checkin"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<FeatureGate feature="enableTicketedEvents">
|
||||
<CheckInScannerPage />
|
||||
</FeatureGate>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/join" element={<QuickJoinPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/jitsi-auth/:room" element={<JitsiAuthPage />} />
|
||||
@ -388,6 +432,36 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="social/referrals"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<FeatureGate feature="enableSocial">
|
||||
<ReferralAdminPage />
|
||||
</FeatureGate>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="social/spotlights"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<FeatureGate feature="enableSocial">
|
||||
<SpotlightAdminPage />
|
||||
</FeatureGate>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="social/challenges"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<FeatureGate feature="enableSocial">
|
||||
<ChallengesAdminPage />
|
||||
</FeatureGate>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="campaigns"
|
||||
element={
|
||||
@ -444,6 +518,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="influence/stories"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<ImpactStoriesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="listmonk"
|
||||
element={
|
||||
@ -693,6 +775,34 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduling/calendar"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<SchedulingCalendarPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="events"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<FeatureGate feature="enableTicketedEvents">
|
||||
<TicketedEventsPage />
|
||||
</FeatureGate>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="events/:id"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<FeatureGate feature="enableTicketedEvents">
|
||||
<EventDetailPage />
|
||||
</FeatureGate>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="map/cuts"
|
||||
element={
|
||||
|
||||
@ -52,6 +52,9 @@ import {
|
||||
SafetyOutlined,
|
||||
StarFilled,
|
||||
StarOutlined,
|
||||
TrophyOutlined,
|
||||
FlagOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { api } from '@/lib/api';
|
||||
@ -171,6 +174,9 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
||||
{ key: '/app/social', icon: <HeartOutlined />, label: 'Social Dashboard' },
|
||||
{ key: '/app/social/graph', icon: <ApartmentOutlined />, label: 'Social Graph' },
|
||||
{ key: '/app/social/moderation', icon: <SafetyOutlined />, label: 'Social Moderation' },
|
||||
{ key: '/app/social/referrals', icon: <UserAddOutlined />, label: 'Referrals' },
|
||||
{ key: '/app/social/spotlights', icon: <StarOutlined />, label: 'Spotlights' },
|
||||
{ key: '/app/social/challenges', icon: <FlagOutlined />, label: 'Challenges' },
|
||||
);
|
||||
}
|
||||
items.push({
|
||||
@ -193,6 +199,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
||||
{ key: '/app/email-queue', icon: <MailOutlined />, label: badges?.pendingEmails ? <Badge count={badges.pendingEmails} size="small" offset={[8, 0]}>Outgoing Emails</Badge> : 'Outgoing Emails' },
|
||||
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
|
||||
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
|
||||
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
|
||||
],
|
||||
});
|
||||
}
|
||||
@ -257,8 +264,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
||||
});
|
||||
}
|
||||
|
||||
// Scheduling submenu — visible if either Shifts (enableMap) or Meeting Planner is enabled
|
||||
if (settings?.enableMap !== false || settings?.enableMeetingPlanner) {
|
||||
// Scheduling submenu — visible if Shifts, Meeting Planner, or Ticketed Events is enabled
|
||||
if (settings?.enableMap !== false || settings?.enableMeetingPlanner || settings?.enableTicketedEvents) {
|
||||
const schedulingChildren: any[] = [];
|
||||
if (settings?.enableMap !== false) {
|
||||
schedulingChildren.push({ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' });
|
||||
@ -266,6 +273,11 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
||||
if (settings?.enableMeetingPlanner) {
|
||||
schedulingChildren.push({ key: '/app/meeting-planner', icon: <CalendarOutlined />, label: 'Meeting Planner' });
|
||||
}
|
||||
if (settings?.enableTicketedEvents) {
|
||||
schedulingChildren.push({ key: '/app/events', icon: <TagOutlined />, label: 'Events' });
|
||||
}
|
||||
// Always add Calendar as the last item in scheduling
|
||||
schedulingChildren.push({ key: '/app/scheduling/calendar', icon: <CalendarOutlined />, label: 'Calendar' });
|
||||
if (schedulingChildren.length > 0) {
|
||||
items.push({
|
||||
key: 'scheduling-submenu',
|
||||
|
||||
@ -20,10 +20,12 @@ const FEATURE_LABELS: Record<string, string> = {
|
||||
enableSocial: 'Social Connections',
|
||||
enableMeet: 'Video Meetings',
|
||||
enableMeetingPlanner: 'Meeting Planner',
|
||||
enableTicketedEvents: 'Ticketed Events',
|
||||
enableSocialCalendar: 'Social Calendar',
|
||||
};
|
||||
|
||||
interface FeatureGateProps {
|
||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner'>;
|
||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar'>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ export default function PublicLayout() {
|
||||
}
|
||||
if (settings?.enableMediaFeatures !== false) links.push({ label: 'Gallery', path: '/gallery' });
|
||||
if (settings?.enablePayments) links.push({ label: 'Donate', path: '/donate' });
|
||||
if (settings?.enableSocial) links.push({ label: 'Wall of Fame', path: '/wall-of-fame' });
|
||||
return links;
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ import {
|
||||
NodeIndexOutlined,
|
||||
MessageOutlined,
|
||||
TeamOutlined,
|
||||
TagOutlined,
|
||||
CalendarOutlined,
|
||||
MenuOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
@ -33,6 +35,12 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
|
||||
|
||||
const NAV_ITEMS = useMemo(() => {
|
||||
const items = [...BASE_NAV_ITEMS];
|
||||
if (settings?.enableSocialCalendar) {
|
||||
items.push({ key: '/volunteer/calendar', icon: CalendarOutlined, label: 'Calendar' });
|
||||
}
|
||||
if (settings?.enableTicketedEvents) {
|
||||
items.push({ key: '/volunteer/tickets', icon: TagOutlined, label: 'Tickets' });
|
||||
}
|
||||
if (settings?.enableSocial) {
|
||||
items.push({ key: '/volunteer/feed', icon: TeamOutlined, label: 'Social' });
|
||||
}
|
||||
@ -40,7 +48,7 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
|
||||
items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' });
|
||||
}
|
||||
return items;
|
||||
}, [settings?.enableChat, settings?.enableSocial]);
|
||||
}, [settings?.enableChat, settings?.enableSocial, settings?.enableSocialCalendar, settings?.enableTicketedEvents]);
|
||||
|
||||
const activeKey = (() => {
|
||||
const path = location.pathname;
|
||||
|
||||
479
admin/src/components/calendar/CalendarItemModal.tsx
Normal file
479
admin/src/components/calendar/CalendarItemModal.tsx
Normal file
@ -0,0 +1,479 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
Select,
|
||||
Switch,
|
||||
Collapse,
|
||||
Radio,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
BellOutlined,
|
||||
BlockOutlined,
|
||||
EnvironmentOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import RecurrenceEditor from './RecurrenceEditor';
|
||||
import type {
|
||||
CalendarLayer,
|
||||
PersonalCalendarItem,
|
||||
CalendarItemType,
|
||||
CalendarVisibility,
|
||||
CalendarBusyStatus,
|
||||
CalendarShowDetailsTo,
|
||||
CalendarRecurrenceRule,
|
||||
SeriesEditScope,
|
||||
} from '@/types/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#1890ff', '#52c41a', '#fa8c16', '#722ed1', '#eb2f96',
|
||||
'#13c2c2', '#f5222d', '#faad14', '#2f54eb', '#a0d911',
|
||||
];
|
||||
|
||||
const ITEM_TYPE_OPTIONS: { value: CalendarItemType; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'EVENT', label: 'Event', icon: <CalendarOutlined /> },
|
||||
{ value: 'TIME_BLOCK', label: 'Time Block', icon: <BlockOutlined /> },
|
||||
{ value: 'REMINDER', label: 'Reminder', icon: <BellOutlined /> },
|
||||
];
|
||||
|
||||
export interface CalendarItemFormData {
|
||||
layerId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isAllDay: boolean;
|
||||
itemType: CalendarItemType;
|
||||
location?: string;
|
||||
color?: string;
|
||||
visibility?: CalendarVisibility | null;
|
||||
busyStatus: CalendarBusyStatus;
|
||||
showDetailsTo: CalendarShowDetailsTo;
|
||||
recurrenceRule?: CalendarRecurrenceRule | null;
|
||||
recurrenceEnd?: string | null;
|
||||
}
|
||||
|
||||
interface CalendarItemModalProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: (data: CalendarItemFormData, scope?: SeriesEditScope) => void;
|
||||
onDelete?: () => void;
|
||||
item?: PersonalCalendarItem | null;
|
||||
defaultDate?: string | null;
|
||||
layers: CalendarLayer[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function CalendarItemModal({
|
||||
open,
|
||||
onCancel,
|
||||
onSave,
|
||||
onDelete,
|
||||
item,
|
||||
defaultDate,
|
||||
layers,
|
||||
loading,
|
||||
}: CalendarItemModalProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [itemType, setItemType] = useState<CalendarItemType>('EVENT');
|
||||
const [isAllDay, setIsAllDay] = useState(false);
|
||||
const [colorOverride, setColorOverride] = useState<string | undefined>(undefined);
|
||||
const [recurrenceRule, setRecurrenceRule] = useState<CalendarRecurrenceRule | null>(null);
|
||||
const [recurrenceEnd, setRecurrenceEnd] = useState<string | null>(null);
|
||||
const [editScope, setEditScope] = useState<SeriesEditScope>('THIS_ONLY');
|
||||
|
||||
const isEditing = !!item;
|
||||
const isRecurringEdit = isEditing && !!item?.seriesId;
|
||||
|
||||
const userLayers = useMemo(
|
||||
() => layers.filter((l) => l.layerType === 'USER'),
|
||||
[layers],
|
||||
);
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (item) {
|
||||
form.setFieldsValue({
|
||||
title: item.title ?? '',
|
||||
layerId: item.layerId ?? userLayers[0]?.id,
|
||||
date: item.date ? dayjs(item.date) : dayjs(),
|
||||
startTime: item.startTime ? dayjs(item.startTime, 'HH:mm') : dayjs('09:00', 'HH:mm'),
|
||||
endTime: item.endTime ? dayjs(item.endTime, 'HH:mm') : dayjs('10:00', 'HH:mm'),
|
||||
description: '',
|
||||
location: item.location ?? '',
|
||||
visibility: null,
|
||||
busyStatus: item.busyStatus ?? 'BUSY',
|
||||
showDetailsTo: item.showDetailsTo ?? 'FRIENDS',
|
||||
});
|
||||
setItemType(item.itemType ?? 'EVENT');
|
||||
setIsAllDay(item.isAllDay ?? false);
|
||||
setColorOverride(item.color !== '#1890ff' ? item.color : undefined);
|
||||
setRecurrenceRule(null);
|
||||
setRecurrenceEnd(null);
|
||||
} else {
|
||||
form.resetFields();
|
||||
const date = defaultDate ? dayjs(defaultDate) : dayjs();
|
||||
form.setFieldsValue({
|
||||
date,
|
||||
startTime: dayjs('09:00', 'HH:mm'),
|
||||
endTime: dayjs('10:00', 'HH:mm'),
|
||||
layerId: userLayers[0]?.id,
|
||||
busyStatus: 'BUSY',
|
||||
showDetailsTo: 'FRIENDS',
|
||||
visibility: null,
|
||||
});
|
||||
setItemType('EVENT');
|
||||
setIsAllDay(false);
|
||||
setColorOverride(undefined);
|
||||
setRecurrenceRule(null);
|
||||
setRecurrenceEnd(null);
|
||||
}
|
||||
setEditScope('THIS_ONLY');
|
||||
}, [open, item, defaultDate, userLayers, form]);
|
||||
|
||||
const handleFinish = (values: Record<string, unknown>) => {
|
||||
const data: CalendarItemFormData = {
|
||||
layerId: values.layerId as string,
|
||||
title: values.title as string,
|
||||
description: (values.description as string) || undefined,
|
||||
date: (values.date as dayjs.Dayjs).format('YYYY-MM-DD'),
|
||||
startTime: isAllDay ? '00:00' : (values.startTime as dayjs.Dayjs).format('HH:mm'),
|
||||
endTime: isAllDay ? '23:59' : (values.endTime as dayjs.Dayjs).format('HH:mm'),
|
||||
isAllDay,
|
||||
itemType,
|
||||
location: (values.location as string) || undefined,
|
||||
color: colorOverride,
|
||||
visibility: (values.visibility as CalendarVisibility | null) ?? null,
|
||||
busyStatus: (values.busyStatus as CalendarBusyStatus) ?? 'BUSY',
|
||||
showDetailsTo: (values.showDetailsTo as CalendarShowDetailsTo) ?? 'FRIENDS',
|
||||
recurrenceRule: recurrenceRule ?? undefined,
|
||||
recurrenceEnd: recurrenceEnd ?? undefined,
|
||||
};
|
||||
|
||||
onSave(data, isRecurringEdit ? editScope : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
title={isEditing ? 'Edit Calendar Item' : 'New Calendar Item'}
|
||||
footer={null}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{/* Item type selector */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
{ITEM_TYPE_OPTIONS.map((opt) => {
|
||||
const selected = itemType === opt.value;
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
onClick={() => setItemType(opt.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? 600 : 400,
|
||||
background: selected ? 'rgba(24, 144, 255, 0.15)' : 'rgba(255,255,255,0.04)',
|
||||
border: selected
|
||||
? '1px solid rgba(24, 144, 255, 0.4)'
|
||||
: '1px solid rgba(255,255,255,0.1)',
|
||||
color: selected ? '#1890ff' : 'rgba(255,255,255,0.65)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="title"
|
||||
rules={[{ required: true, message: 'Title is required' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder={
|
||||
itemType === 'REMINDER'
|
||||
? 'Reminder title...'
|
||||
: itemType === 'TIME_BLOCK'
|
||||
? 'Block title (e.g. "Focus time")...'
|
||||
: 'Event title...'
|
||||
}
|
||||
size="large"
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="layerId"
|
||||
label="Layer"
|
||||
rules={[{ required: true, message: 'Select a layer' }]}
|
||||
>
|
||||
<Select
|
||||
options={userLayers.map((l) => ({
|
||||
value: l.id,
|
||||
label: (
|
||||
<Space>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: l.color,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
{l.name}
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
placeholder="Select layer"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<Form.Item
|
||||
name="date"
|
||||
label="Date"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label=" " style={{ paddingTop: 4 }}>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={isAllDay}
|
||||
onChange={setIsAllDay}
|
||||
size="small"
|
||||
/>
|
||||
<Text style={{ fontSize: 13, color: 'rgba(255,255,255,0.65)' }}>All day</Text>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{!isAllDay && (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Form.Item
|
||||
name="startTime"
|
||||
label="Start"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<TimePicker format="HH:mm" minuteStep={5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
label="End"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<TimePicker format="HH:mm" minuteStep={5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{itemType === 'EVENT' && (
|
||||
<Form.Item name="location" label="Location">
|
||||
<Input prefix={<EnvironmentOutlined />} placeholder="Optional location" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="description" label="Description">
|
||||
<TextArea rows={2} placeholder="Optional description" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Time block specific fields */}
|
||||
{itemType === 'TIME_BLOCK' && (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Form.Item name="busyStatus" label="Status" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'BUSY', label: 'Busy' },
|
||||
{ value: 'TENTATIVE', label: 'Tentative' },
|
||||
{ value: 'FREE', label: 'Free' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="showDetailsTo" label="Show details to" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'NOBODY', label: 'Nobody' },
|
||||
{ value: 'FRIENDS', label: 'Friends' },
|
||||
{ value: 'EVERYONE', label: 'Everyone' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visibility override */}
|
||||
<Form.Item name="visibility" label="Visibility override">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="Inherit from layer"
|
||||
options={[
|
||||
{ value: 'PRIVATE', label: 'Private' },
|
||||
{ value: 'FRIENDS', label: 'Friends only' },
|
||||
{ value: 'PUBLIC', label: 'Public' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Color override */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 6 }}>
|
||||
Color override
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<div
|
||||
onClick={() => setColorOverride(undefined)}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
cursor: 'pointer',
|
||||
border: !colorOverride ? '2px solid #1890ff' : '2px solid transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
}}
|
||||
title="Use layer color"
|
||||
>
|
||||
Auto
|
||||
</div>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<div
|
||||
key={c}
|
||||
onClick={() => setColorOverride(c)}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
background: c,
|
||||
cursor: 'pointer',
|
||||
border: colorOverride === c ? '2px solid #fff' : '2px solid transparent',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'recurrence',
|
||||
label: (
|
||||
<Text style={{ fontSize: 13, color: 'rgba(255,255,255,0.65)' }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 6 }} />
|
||||
Recurrence
|
||||
{recurrenceRule && (
|
||||
<span style={{ color: '#1890ff', marginLeft: 8, fontSize: 12 }}>
|
||||
(configured)
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
children: (
|
||||
<RecurrenceEditor
|
||||
value={recurrenceRule}
|
||||
endDate={recurrenceEnd}
|
||||
onChange={(rule, end) => {
|
||||
setRecurrenceRule(rule);
|
||||
setRecurrenceEnd(end);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
style={{ marginBottom: 16, background: 'rgba(255,255,255,0.02)', borderRadius: 8 }}
|
||||
/>
|
||||
|
||||
{/* Edit scope for recurring items */}
|
||||
{isRecurringEdit && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: 'rgba(250, 140, 22, 0.08)',
|
||||
border: '1px solid rgba(250, 140, 22, 0.2)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 13, color: 'rgba(255,255,255,0.85)', display: 'block', marginBottom: 8 }}>
|
||||
This is a recurring event. Apply changes to:
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={editScope}
|
||||
onChange={(e) => setEditScope(e.target.value)}
|
||||
>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Radio value="THIS_ONLY">This event only</Radio>
|
||||
<Radio value="THIS_AND_FUTURE">This and future events</Radio>
|
||||
<Radio value="ALL">All events in the series</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
|
||||
<div>
|
||||
{isEditing && onDelete && (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
{isEditing ? 'Save Changes' : 'Create'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
435
admin/src/components/calendar/CalendarLayerPanel.tsx
Normal file
435
admin/src/components/calendar/CalendarLayerPanel.tsx
Normal file
@ -0,0 +1,435 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Switch,
|
||||
Button,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Space,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
TeamOutlined,
|
||||
GlobalOutlined,
|
||||
LockOutlined,
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
ThunderboltOutlined,
|
||||
UserOutlined,
|
||||
CloudOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
CalendarLayer,
|
||||
CalendarLayerType,
|
||||
CalendarVisibility,
|
||||
} from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#1890ff', '#52c41a', '#fa8c16', '#722ed1', '#eb2f96',
|
||||
'#13c2c2', '#f5222d', '#faad14', '#2f54eb', '#a0d911',
|
||||
];
|
||||
|
||||
const VISIBILITY_ICONS: Record<CalendarVisibility, React.ReactNode> = {
|
||||
PRIVATE: <LockOutlined />,
|
||||
FRIENDS: <TeamOutlined />,
|
||||
PUBLIC: <GlobalOutlined />,
|
||||
};
|
||||
|
||||
const VISIBILITY_LABELS: Record<CalendarVisibility, string> = {
|
||||
PRIVATE: 'Private',
|
||||
FRIENDS: 'Friends only',
|
||||
PUBLIC: 'Public',
|
||||
};
|
||||
|
||||
const GROUP_ICONS: Record<CalendarLayerType, React.ReactNode> = {
|
||||
SYSTEM: <ThunderboltOutlined />,
|
||||
USER: <UserOutlined />,
|
||||
EXTERNAL: <CloudOutlined />,
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<CalendarLayerType, string> = {
|
||||
SYSTEM: 'System',
|
||||
USER: 'Personal',
|
||||
EXTERNAL: 'External',
|
||||
};
|
||||
|
||||
interface CalendarLayerPanelProps {
|
||||
layers: CalendarLayer[];
|
||||
compact?: boolean;
|
||||
onToggle: (layerId: string, enabled: boolean) => void;
|
||||
onCreate: (name: string, color: string) => void;
|
||||
onUpdate: (layerId: string, data: Partial<CalendarLayer>) => void;
|
||||
onDelete: (layerId: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function CalendarLayerPanel({
|
||||
layers,
|
||||
compact,
|
||||
onToggle,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
loading,
|
||||
}: CalendarLayerPanelProps) {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newColor, setNewColor] = useState(PRESET_COLORS[0]!);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [colorPickerLayerId, setColorPickerLayerId] = useState<string | null>(null);
|
||||
|
||||
const grouped = (['SYSTEM', 'USER', 'EXTERNAL'] as CalendarLayerType[]).map((type) => ({
|
||||
type,
|
||||
layers: layers.filter((l) => l.layerType === type).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
})).filter((g) => g.layers.length > 0 || g.type === 'USER');
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!newName.trim()) return;
|
||||
onCreate(newName.trim(), newColor);
|
||||
setNewName('');
|
||||
setNewColor(PRESET_COLORS[0]!);
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const handleEditSubmit = (layerId: string) => {
|
||||
if (editName.trim()) {
|
||||
onUpdate(layerId, { name: editName.trim() });
|
||||
}
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleColorChange = (layerId: string, color: string) => {
|
||||
onUpdate(layerId, { color });
|
||||
setColorPickerLayerId(null);
|
||||
};
|
||||
|
||||
// Compact mode: horizontal strip with just color dots + toggle
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
padding: '8px 0',
|
||||
marginBottom: 8,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
}}
|
||||
>
|
||||
{layers.map((layer) => (
|
||||
<Tooltip key={layer.id} title={layer.name}>
|
||||
<div
|
||||
onClick={() => onToggle(layer.id, !layer.isEnabled)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
background: layer.isEnabled ? 'rgba(255,255,255,0.06)' : 'transparent',
|
||||
opacity: layer.isEnabled ? 1 : 0.4,
|
||||
border: `1px solid ${layer.isEnabled ? layer.color : 'rgba(255,255,255,0.1)'}`,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: layer.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.75)' }}>
|
||||
{layer.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderColorDot = (layer: CalendarLayer) => {
|
||||
const isOpen = colorPickerLayerId === layer.id;
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (layer.layerType === 'SYSTEM') return;
|
||||
setColorPickerLayerId(isOpen ? null : layer.id);
|
||||
}}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
background: layer.color,
|
||||
cursor: layer.layerType === 'SYSTEM' ? 'default' : 'pointer',
|
||||
flexShrink: 0,
|
||||
border: '2px solid rgba(255,255,255,0.15)',
|
||||
transition: 'transform 0.15s',
|
||||
}}
|
||||
/>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
left: 0,
|
||||
zIndex: 10,
|
||||
background: '#1b2838',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
width: 160,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<div
|
||||
key={c}
|
||||
onClick={() => handleColorChange(layer.id, c)}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
background: c,
|
||||
cursor: 'pointer',
|
||||
border: c === layer.color ? '2px solid #fff' : '2px solid transparent',
|
||||
transition: 'transform 0.15s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLayerRow = (layer: CalendarLayer) => {
|
||||
const isEditing = editingId === layer.id;
|
||||
const vis = layer.visibility;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 0',
|
||||
opacity: layer.isEnabled ? 1 : 0.5,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
>
|
||||
{renderColorDot(layer)}
|
||||
|
||||
{isEditing ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onPressEnter={() => handleEditSubmit(layer.id)}
|
||||
onBlur={() => handleEditSubmit(layer.id)}
|
||||
autoFocus
|
||||
style={{ flex: 1, fontSize: 13 }}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
cursor: layer.layerType !== 'SYSTEM' ? 'pointer' : 'default',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (layer.layerType !== 'SYSTEM') {
|
||||
setEditingId(layer.id);
|
||||
setEditName(layer.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{layer.name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Tooltip title={VISIBILITY_LABELS[vis]}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: vis === 'PUBLIC' ? 'rgba(82,196,26,0.7)' : 'rgba(255,255,255,0.35)',
|
||||
cursor: layer.layerType !== 'SYSTEM' ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (layer.layerType === 'SYSTEM') return;
|
||||
const cycle: CalendarVisibility[] = ['PRIVATE', 'FRIENDS', 'PUBLIC'];
|
||||
const next = cycle[(cycle.indexOf(vis) + 1) % cycle.length];
|
||||
onUpdate(layer.id, { visibility: next });
|
||||
}}
|
||||
>
|
||||
{VISIBILITY_ICONS[vis]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Switch
|
||||
size="small"
|
||||
checked={layer.isEnabled}
|
||||
onChange={(checked) => onToggle(layer.id, checked)}
|
||||
/>
|
||||
|
||||
{layer.layerType !== 'SYSTEM' && (
|
||||
<Popconfirm
|
||||
title="Delete this layer?"
|
||||
description="All items in this layer will be deleted."
|
||||
onConfirm={() => onDelete(layer.id)}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ color: 'rgba(255,255,255,0.25)', padding: '0 4px' }}
|
||||
/>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ color: 'rgba(255,255,255,0.85)', fontSize: 14, marginBottom: 4 }}>
|
||||
Layers
|
||||
</Text>
|
||||
|
||||
{loading && (
|
||||
<Text type="secondary" style={{ fontSize: 12, padding: '8px 0' }}>
|
||||
Loading layers...
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{grouped.map((group) => (
|
||||
<div key={group.type}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 0 2px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'rgba(255,255,255,0.35)', fontSize: 11 }}>
|
||||
{GROUP_ICONS[group.type]}
|
||||
</span>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
||||
>
|
||||
{GROUP_LABELS[group.type]}
|
||||
</Text>
|
||||
</div>
|
||||
{group.layers.map(renderLayerRow)}
|
||||
{group.layers.length === 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 12, padding: '4px 0 4px 22px', display: 'block' }}>
|
||||
No layers
|
||||
</Text>
|
||||
)}
|
||||
<Divider style={{ margin: '6px 0' }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showAddForm ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: '8px 0',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="Layer name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onPressEnter={handleCreate}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<div
|
||||
key={c}
|
||||
onClick={() => setNewColor(c)}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: c,
|
||||
cursor: 'pointer',
|
||||
border: c === newColor ? '2px solid #fff' : '2px solid transparent',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => setShowAddForm(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setShowAddForm(true)}
|
||||
block
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
Add Layer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
424
admin/src/components/calendar/MobileDayView.tsx
Normal file
424
admin/src/components/calendar/MobileDayView.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Typography, Empty, theme } from 'antd';
|
||||
import {
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
PlusOutlined,
|
||||
BellOutlined,
|
||||
EnvironmentOutlined,
|
||||
LockOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { PersonalCalendarItem, CalendarLayer } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { useToken } = theme;
|
||||
|
||||
interface MobileDayViewProps {
|
||||
items: PersonalCalendarItem[];
|
||||
layers?: CalendarLayer[];
|
||||
currentMonth: Dayjs;
|
||||
selectedDate: string | null;
|
||||
onDateSelect: (date: string) => void;
|
||||
onMonthChange: (month: Dayjs) => void;
|
||||
onItemClick: (item: PersonalCalendarItem) => void;
|
||||
onAddItem: (date: string) => void;
|
||||
}
|
||||
|
||||
// Time slots from 6am to 10pm
|
||||
const START_HOUR = 6;
|
||||
const END_HOUR = 22;
|
||||
const SLOT_HEIGHT = 60; // px per hour
|
||||
|
||||
export default function MobileDayView({
|
||||
items,
|
||||
selectedDate,
|
||||
onDateSelect,
|
||||
onItemClick,
|
||||
onAddItem,
|
||||
}: MobileDayViewProps) {
|
||||
const { token } = useToken();
|
||||
|
||||
const currentDate = selectedDate ? dayjs(selectedDate) : dayjs();
|
||||
const dateKey = currentDate.format('YYYY-MM-DD');
|
||||
const isToday = dateKey === dayjs().format('YYYY-MM-DD');
|
||||
|
||||
// Filter items for the selected date
|
||||
const dayItems = useMemo(() => {
|
||||
return items
|
||||
.filter((item) => item.date === dateKey)
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||
}, [items, dateKey]);
|
||||
|
||||
const allDayItems = dayItems.filter((item) => item.isAllDay);
|
||||
const timedItems = dayItems.filter((item) => !item.isAllDay);
|
||||
|
||||
// Calculate position and height of timed items
|
||||
const positionedItems = useMemo(() => {
|
||||
return timedItems.map((item) => {
|
||||
const parts = item.startTime.split(':');
|
||||
const sh = parseInt(parts[0] ?? '0', 10);
|
||||
const sm = parseInt(parts[1] ?? '0', 10);
|
||||
const endParts = item.endTime.split(':');
|
||||
const eh = parseInt(endParts[0] ?? '0', 10);
|
||||
const em = parseInt(endParts[1] ?? '0', 10);
|
||||
const startMinutes = sh * 60 + sm;
|
||||
const endMinutes = eh * 60 + em;
|
||||
const topMinutes = startMinutes - START_HOUR * 60;
|
||||
const durationMinutes = Math.max(endMinutes - startMinutes, 15); // min 15min display
|
||||
|
||||
return {
|
||||
item,
|
||||
top: Math.max(0, (topMinutes / 60) * SLOT_HEIGHT),
|
||||
height: Math.max(20, (durationMinutes / 60) * SLOT_HEIGHT),
|
||||
};
|
||||
});
|
||||
}, [timedItems]);
|
||||
|
||||
const totalHeight = (END_HOUR - START_HOUR) * SLOT_HEIGHT;
|
||||
|
||||
// Current time indicator position
|
||||
const nowIndicatorTop = useMemo(() => {
|
||||
if (!isToday) return null;
|
||||
const now = dayjs();
|
||||
const minutes = now.hour() * 60 + now.minute();
|
||||
const top = ((minutes - START_HOUR * 60) / 60) * SLOT_HEIGHT;
|
||||
if (top < 0 || top > totalHeight) return null;
|
||||
return top;
|
||||
}, [isToday, totalHeight]);
|
||||
|
||||
const handlePrev = () => {
|
||||
onDateSelect(currentDate.subtract(1, 'day').format('YYYY-MM-DD'));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
onDateSelect(currentDate.add(1, 'day').format('YYYY-MM-DD'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Date header with navigation */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePrev}
|
||||
style={{ color: 'rgba(255,255,255,0.65)' }}
|
||||
/>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
color: isToday ? token.colorPrimary : 'rgba(255,255,255,0.85)',
|
||||
fontSize: 16,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{currentDate.format('dddd')}
|
||||
</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}>
|
||||
{currentDate.format('MMMM D, YYYY')}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNext}
|
||||
style={{ color: 'rgba(255,255,255,0.65)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Today button */}
|
||||
{!isToday && (
|
||||
<div style={{ textAlign: 'center', padding: '6px 0' }}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onDateSelect(dayjs().format('YYYY-MM-DD'))}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
Go to today
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All-day items section */}
|
||||
{allDayItems.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
All Day
|
||||
</Text>
|
||||
{allDayItems.map((item) => (
|
||||
<ItemPill
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => onItemClick(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable time grid */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{dayItems.length === 0 && (
|
||||
<div style={{ padding: '60px 20px', textAlign: 'center' }}>
|
||||
<Empty
|
||||
description="Nothing scheduled"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(timedItems.length > 0 || allDayItems.length > 0) && (
|
||||
<div style={{ position: 'relative', height: totalHeight, margin: '0 16px' }}>
|
||||
{/* Hour lines */}
|
||||
{Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => {
|
||||
const hour = START_HOUR + i;
|
||||
const top = i * SLOT_HEIGHT;
|
||||
return (
|
||||
<div
|
||||
key={hour}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
width: 44,
|
||||
fontSize: 11,
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
textAlign: 'right',
|
||||
paddingRight: 8,
|
||||
marginTop: -7,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
height: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Current time indicator */}
|
||||
{nowIndicatorTop !== null && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: nowIndicatorTop,
|
||||
left: 44,
|
||||
right: 0,
|
||||
height: 2,
|
||||
background: token.colorError,
|
||||
zIndex: 5,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -4,
|
||||
top: -3,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: token.colorError,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Positioned items */}
|
||||
{positionedItems.map(({ item, top, height }) => {
|
||||
const isTimeBlock = item.itemType === 'TIME_BLOCK';
|
||||
const isReminder = item.itemType === 'REMINDER';
|
||||
const color = item.color || token.colorPrimary;
|
||||
const isBusyHidden = isTimeBlock && item.showDetailsTo === 'NOBODY';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => onItemClick(item)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: top + 1,
|
||||
left: 50,
|
||||
right: 4,
|
||||
height: height - 2,
|
||||
background: isTimeBlock
|
||||
? hexToRgba(color, 0.1)
|
||||
: hexToRgba(color, 0.2),
|
||||
border: isTimeBlock
|
||||
? `1px dashed ${hexToRgba(color, 0.35)}`
|
||||
: `1px solid ${hexToRgba(color, 0.4)}`,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
zIndex: 2,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{isBusyHidden ? (
|
||||
<Text style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>
|
||||
<LockOutlined style={{ marginRight: 4, fontSize: 10 }} />
|
||||
Busy
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isReminder && <BellOutlined style={{ marginRight: 4, fontSize: 10 }} />}
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>
|
||||
{item.startTime} - {item.endTime}
|
||||
</Text>
|
||||
{item.location && height > 45 && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<EnvironmentOutlined style={{ marginRight: 3 }} />
|
||||
{item.location}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating add button */}
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
size="large"
|
||||
onClick={() => onAddItem(dateKey)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
width: 48,
|
||||
height: 48,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact item pill for all-day items */
|
||||
function ItemPill({
|
||||
item,
|
||||
onClick,
|
||||
}: {
|
||||
item: PersonalCalendarItem;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isTimeBlock = item.itemType === 'TIME_BLOCK';
|
||||
const isReminder = item.itemType === 'REMINDER';
|
||||
const color = item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: hexToRgba(color, isTimeBlock ? 0.1 : 0.2),
|
||||
border: isTimeBlock
|
||||
? `1px dashed ${hexToRgba(color, 0.35)}`
|
||||
: `1px solid ${hexToRgba(color, 0.4)}`,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
borderRadius: 6,
|
||||
padding: '6px 10px',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
fontSize: 13,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
}}
|
||||
>
|
||||
{isReminder && <BellOutlined style={{ marginRight: 4, fontSize: 11 }} />}
|
||||
{item.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert a hex color to rgba string */
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const cleaned = hex.replace('#', '');
|
||||
if (cleaned.length !== 6) return `rgba(24, 144, 255, ${alpha})`;
|
||||
const r = parseInt(cleaned.slice(0, 2), 16);
|
||||
const g = parseInt(cleaned.slice(2, 4), 16);
|
||||
const b = parseInt(cleaned.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
173
admin/src/components/calendar/PersonalCalendarView.tsx
Normal file
173
admin/src/components/calendar/PersonalCalendarView.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Calendar, Spin, Empty, theme } from 'antd';
|
||||
import { BellOutlined } from '@ant-design/icons';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { PersonalCalendarItem, CalendarLayer } from '@/types/api';
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
interface PersonalCalendarViewProps {
|
||||
items: PersonalCalendarItem[];
|
||||
layers?: CalendarLayer[];
|
||||
loading?: boolean;
|
||||
currentMonth: Dayjs;
|
||||
selectedDate: string | null;
|
||||
onDateSelect: (date: string) => void;
|
||||
onItemClick: (item: PersonalCalendarItem) => void;
|
||||
onMonthChange: (month: Dayjs) => void;
|
||||
}
|
||||
|
||||
const MAX_CELL_ITEMS = 3;
|
||||
|
||||
export default function PersonalCalendarView({
|
||||
items,
|
||||
loading,
|
||||
onDateSelect,
|
||||
onItemClick,
|
||||
onMonthChange,
|
||||
}: PersonalCalendarViewProps) {
|
||||
useToken();
|
||||
|
||||
// Group items by date
|
||||
const itemsByDate = useMemo(() => {
|
||||
const map: Record<string, PersonalCalendarItem[]> = {};
|
||||
for (const item of items) {
|
||||
const arr = map[item.date];
|
||||
if (arr) {
|
||||
arr.push(item);
|
||||
} else {
|
||||
map[item.date] = [item];
|
||||
}
|
||||
}
|
||||
// Sort items within each date by startTime
|
||||
for (const key of Object.keys(map)) {
|
||||
map[key]!.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||
}
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
const handleSelect = (date: Dayjs) => {
|
||||
onDateSelect(date.format('YYYY-MM-DD'));
|
||||
};
|
||||
|
||||
const handlePanelChange = (date: Dayjs) => {
|
||||
onMonthChange(date);
|
||||
};
|
||||
|
||||
const cellRender = (date: Dayjs) => {
|
||||
const dateKey = date.format('YYYY-MM-DD');
|
||||
const dayItems = itemsByDate[dateKey];
|
||||
if (!dayItems || dayItems.length === 0) return null;
|
||||
|
||||
const visible = dayItems.slice(0, MAX_CELL_ITEMS);
|
||||
const overflow = dayItems.length - MAX_CELL_ITEMS;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 3,
|
||||
padding: '0 2px',
|
||||
}}
|
||||
>
|
||||
{visible.map((item) => {
|
||||
const isTimeBlock = item.itemType === 'TIME_BLOCK';
|
||||
const isReminder = item.itemType === 'REMINDER';
|
||||
const color = item.color || '#1890ff';
|
||||
const bgAlpha = isTimeBlock ? 0.1 : 0.2;
|
||||
const borderAlpha = isTimeBlock ? 0.3 : 0.5;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onItemClick(item);
|
||||
}}
|
||||
style={{
|
||||
background: hexToRgba(color, bgAlpha),
|
||||
border: isTimeBlock
|
||||
? `1px dashed ${hexToRgba(color, borderAlpha)}`
|
||||
: `1px solid ${hexToRgba(color, borderAlpha)}`,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
borderRadius: 4,
|
||||
padding: '2px 5px',
|
||||
fontSize: 11,
|
||||
lineHeight: '15px',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
cursor: 'pointer',
|
||||
opacity: isTimeBlock ? 0.75 : 1,
|
||||
}}
|
||||
>
|
||||
{!item.isAllDay && (
|
||||
<span style={{ color: 'rgba(255,255,255,0.5)', marginRight: 3, fontSize: 10 }}>
|
||||
{item.startTime}
|
||||
</span>
|
||||
)}
|
||||
{isReminder && (
|
||||
<BellOutlined style={{ fontSize: 9, marginRight: 3, color: 'rgba(255,255,255,0.5)' }} />
|
||||
)}
|
||||
{item.title}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.45)', textAlign: 'center' }}>
|
||||
+{overflow} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 12 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
<Calendar
|
||||
fullscreen
|
||||
cellRender={(date) => cellRender(date)}
|
||||
onSelect={handleSelect}
|
||||
onPanelChange={handlePanelChange}
|
||||
/>
|
||||
{items.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Empty description="No calendar items. Click a date to add one." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert a hex color to rgba string */
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const cleaned = hex.replace('#', '');
|
||||
if (cleaned.length !== 6) return `rgba(24, 144, 255, ${alpha})`;
|
||||
const r = parseInt(cleaned.slice(0, 2), 16);
|
||||
const g = parseInt(cleaned.slice(2, 4), 16);
|
||||
const b = parseInt(cleaned.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
237
admin/src/components/calendar/RecurrenceEditor.tsx
Normal file
237
admin/src/components/calendar/RecurrenceEditor.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, InputNumber, DatePicker, Space, Typography } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import type { CalendarRecurrenceRule, CalendarRecurrenceFrequency } from '@/types/api';
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const DAY_VALUES = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
interface RecurrenceEditorProps {
|
||||
value: CalendarRecurrenceRule | null;
|
||||
endDate: string | null;
|
||||
onChange: (rule: CalendarRecurrenceRule | null, endDate: string | null) => void;
|
||||
}
|
||||
|
||||
export default function RecurrenceEditor({ value, endDate, onChange }: RecurrenceEditorProps) {
|
||||
const frequency = value?.frequency ?? null;
|
||||
const interval = value?.interval ?? 1;
|
||||
const daysOfWeek = value?.daysOfWeek ?? [];
|
||||
const dayOfMonth = value?.dayOfMonth ?? 1;
|
||||
|
||||
const handleFrequencyChange = (freq: CalendarRecurrenceFrequency | 'NONE') => {
|
||||
if (freq === 'NONE') {
|
||||
onChange(null, null);
|
||||
return;
|
||||
}
|
||||
const rule: CalendarRecurrenceRule = { frequency: freq, interval: 1 };
|
||||
if (freq === 'WEEKLY' || freq === 'BIWEEKLY') {
|
||||
rule.daysOfWeek = daysOfWeek.length > 0 ? daysOfWeek : [dayjs().isoWeekday()];
|
||||
}
|
||||
if (freq === 'MONTHLY') {
|
||||
rule.dayOfMonth = dayOfMonth || dayjs().date();
|
||||
}
|
||||
onChange(rule, endDate);
|
||||
};
|
||||
|
||||
const handleIntervalChange = (val: number | null) => {
|
||||
if (!value) return;
|
||||
onChange({ ...value, interval: val ?? 1 }, endDate);
|
||||
};
|
||||
|
||||
const handleDaysOfWeekToggle = (day: number) => {
|
||||
if (!value) return;
|
||||
const current = value.daysOfWeek ?? [];
|
||||
const next = current.includes(day)
|
||||
? current.filter(d => d !== day)
|
||||
: [...current, day].sort((a, b) => a - b);
|
||||
// Must have at least one day selected
|
||||
if (next.length === 0) return;
|
||||
onChange({ ...value, daysOfWeek: next }, endDate);
|
||||
};
|
||||
|
||||
const handleDayOfMonthChange = (val: number | null) => {
|
||||
if (!value) return;
|
||||
onChange({ ...value, dayOfMonth: val ?? 1 }, endDate);
|
||||
};
|
||||
|
||||
const handleEndDateChange = (date: dayjs.Dayjs | null) => {
|
||||
onChange(value, date ? date.format('YYYY-MM-DD') : null);
|
||||
};
|
||||
|
||||
const previewText = useMemo(() => {
|
||||
if (!value) return '';
|
||||
const { frequency: freq } = value;
|
||||
const intv = value.interval ?? 1;
|
||||
|
||||
if (freq === 'DAILY') {
|
||||
return intv === 1 ? 'Every day' : `Every ${intv} days`;
|
||||
}
|
||||
|
||||
if (freq === 'WEEKLY' || freq === 'BIWEEKLY') {
|
||||
const days = (value.daysOfWeek ?? [])
|
||||
.map(d => DAY_LABELS[d - 1])
|
||||
.join(', ');
|
||||
const base = freq === 'BIWEEKLY'
|
||||
? 'Every 2 weeks'
|
||||
: intv === 1
|
||||
? 'Every week'
|
||||
: `Every ${intv} weeks`;
|
||||
return days ? `${base} on ${days}` : base;
|
||||
}
|
||||
|
||||
if (freq === 'MONTHLY') {
|
||||
const dom = value.dayOfMonth ?? 1;
|
||||
const suffix = dom === 1 ? 'st' : dom === 2 ? 'nd' : dom === 3 ? 'rd' : 'th';
|
||||
return intv === 1
|
||||
? `Every month on the ${dom}${suffix}`
|
||||
: `Every ${intv} months on the ${dom}${suffix}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
Repeats
|
||||
</Text>
|
||||
<Select
|
||||
value={frequency ?? 'NONE'}
|
||||
onChange={handleFrequencyChange}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'NONE', label: 'Does not repeat' },
|
||||
{ value: 'DAILY', label: 'Daily' },
|
||||
{ value: 'WEEKLY', label: 'Weekly' },
|
||||
{ value: 'BIWEEKLY', label: 'Bi-weekly' },
|
||||
{ value: 'MONTHLY', label: 'Monthly' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{frequency && frequency !== 'BIWEEKLY' && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
Every
|
||||
</Text>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={52}
|
||||
value={interval}
|
||||
onChange={handleIntervalChange}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<Text type="secondary">
|
||||
{frequency === 'DAILY' ? 'day(s)' : frequency === 'MONTHLY' ? 'month(s)' : 'week(s)'}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(frequency === 'WEEKLY' || frequency === 'BIWEEKLY') && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
On days
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{DAY_VALUES.map((day, idx) => {
|
||||
const selected = daysOfWeek.includes(day);
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
onClick={() => handleDaysOfWeekToggle(day)}
|
||||
style={{
|
||||
width: 38,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 15,
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
background: selected ? 'rgba(24, 144, 255, 0.3)' : 'rgba(255,255,255,0.06)',
|
||||
border: selected ? '1px solid rgba(24, 144, 255, 0.6)' : '1px solid rgba(255,255,255,0.12)',
|
||||
color: selected ? '#1890ff' : 'rgba(255,255,255,0.65)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{DAY_LABELS[idx]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency === 'MONTHLY' && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
Day of month
|
||||
</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={31}
|
||||
value={dayOfMonth}
|
||||
onChange={handleDayOfMonthChange}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
Ends
|
||||
</Text>
|
||||
<Space>
|
||||
<Select
|
||||
value={endDate ? 'on_date' : 'never'}
|
||||
onChange={(val) => {
|
||||
if (val === 'never') {
|
||||
onChange(value, null);
|
||||
} else {
|
||||
onChange(value, dayjs().add(3, 'month').format('YYYY-MM-DD'));
|
||||
}
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: 'on_date', label: 'On date' },
|
||||
]}
|
||||
/>
|
||||
{endDate && (
|
||||
<DatePicker
|
||||
value={dayjs(endDate)}
|
||||
onChange={handleEndDateChange}
|
||||
disabledDate={(d) => d.isBefore(dayjs(), 'day')}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency && previewText && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
fontStyle: 'italic',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
{previewText}
|
||||
{endDate ? `, until ${dayjs(endDate).format('MMM D, YYYY')}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -155,9 +155,10 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
{visible.map(item => {
|
||||
const isPoll = item.type === 'poll';
|
||||
const isShift = item.type === 'shift';
|
||||
const bg = isPoll ? 'rgba(250, 140, 22, 0.2)' : isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)';
|
||||
const border = isPoll ? 'rgba(250, 140, 22, 0.5)' : isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)';
|
||||
const accent = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
|
||||
const isTicketed = item.type === 'ticketed_event';
|
||||
const bg = isTicketed ? 'rgba(114, 46, 209, 0.2)' : isPoll ? 'rgba(250, 140, 22, 0.2)' : isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)';
|
||||
const border = isTicketed ? 'rgba(114, 46, 209, 0.5)' : isPoll ? 'rgba(250, 140, 22, 0.5)' : isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)';
|
||||
const accent = isTicketed ? '#722ed1' : isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
@ -178,7 +179,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
<span style={{ color: 'rgba(255,255,255,0.5)', marginRight: 3, fontSize: 10 }}>
|
||||
{item.startTime}
|
||||
</span>
|
||||
{item.tags?.includes('video-meeting') && (
|
||||
{(item.tags?.includes('video-meeting') || item.eventFormat === 'ONLINE' || item.eventFormat === 'HYBRID') && (
|
||||
<VideoCameraOutlined style={{ fontSize: 9, marginRight: 3, color: 'rgba(255,255,255,0.5)' }} />
|
||||
)}
|
||||
{item.title}
|
||||
@ -197,6 +198,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
const renderItemCard = (item: UnifiedCalendarItem) => {
|
||||
const isShift = item.type === 'shift';
|
||||
const isPoll = item.type === 'poll';
|
||||
const isTicketed = item.type === 'ticketed_event';
|
||||
const spotsLeft = isShift && item.maxVolunteers
|
||||
? item.maxVolunteers - (item.currentVolunteers || 0)
|
||||
: null;
|
||||
@ -204,9 +206,9 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0
|
||||
? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100)
|
||||
: 0;
|
||||
const borderColor = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
|
||||
const tagColor = isPoll ? 'orange' : isShift ? 'blue' : 'green';
|
||||
const tagLabel = isPoll ? 'Poll' : isShift ? 'Shift' : 'Event';
|
||||
const borderColor = isTicketed ? '#722ed1' : isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
|
||||
const tagColor = isTicketed ? 'purple' : isPoll ? 'orange' : isShift ? 'blue' : 'green';
|
||||
const tagLabel = isTicketed ? 'Ticketed' : isPoll ? 'Poll' : isShift ? 'Shift' : 'Event';
|
||||
|
||||
return (
|
||||
<Card
|
||||
@ -220,9 +222,9 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 4 }}>
|
||||
<Text strong style={{ color: '#fff', fontSize: 14 }}>
|
||||
{item.title}
|
||||
{item.tags?.includes('video-meeting') && (
|
||||
<Tooltip title="Video Meeting">
|
||||
<VideoCameraOutlined style={{ marginLeft: 6, fontSize: 13, color: '#52c41a' }} />
|
||||
{(item.tags?.includes('video-meeting') || item.eventFormat === 'ONLINE' || item.eventFormat === 'HYBRID') && (
|
||||
<Tooltip title={item.eventFormat === 'HYBRID' ? 'Hybrid (Online + In-Person)' : 'Online Event'}>
|
||||
<VideoCameraOutlined style={{ marginLeft: 6, fontSize: 13, color: '#722ed1' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Text>
|
||||
@ -252,6 +254,27 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticketed event: capacity bar */}
|
||||
{isTicketed && item.maxAttendees != null && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<TeamOutlined style={{ marginRight: 4 }} />
|
||||
Attendees
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.currentAttendees || 0}/{item.maxAttendees}
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(((item.currentAttendees || 0) / item.maxAttendees) * 100)}
|
||||
size="small"
|
||||
status={item.isSoldOut ? 'exception' : 'active'}
|
||||
showInfo={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shift-specific: capacity bar */}
|
||||
{isShift && item.maxVolunteers != null && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
@ -316,6 +339,16 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
|
||||
Vote ({item.pollVoteCount ?? 0})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isTicketed && item.eventSlug && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
href={`/event/${item.eventSlug}`}
|
||||
>
|
||||
{item.isSoldOut ? 'Sold Out' : 'Get Tickets'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
|
||||
63
admin/src/components/social/CampaignCelebration.tsx
Normal file
63
admin/src/components/social/CampaignCelebration.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Tag, Space } from 'antd';
|
||||
import { TrophyOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Story {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
milestoneValue: number | null;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
interface CampaignCelebrationProps {
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
export default function CampaignCelebration({ campaignId }: CampaignCelebrationProps) {
|
||||
const [stories, setStories] = useState<Story[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignId) return;
|
||||
axios.get(`/api/social/stories/campaign/${campaignId}`, { params: { limit: 5 } })
|
||||
.then(({ data }) => {
|
||||
const published = (data.stories || []).filter(
|
||||
(s: Story) => s.type === 'MILESTONE' || s.type === 'VICTORY',
|
||||
);
|
||||
setStories(published);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [campaignId]);
|
||||
|
||||
if (stories.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #fffbe6 0%, #fff1b8 100%)',
|
||||
borderColor: '#d4a017',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<TrophyOutlined style={{ color: '#d4a017', fontSize: 18 }} />
|
||||
<Text strong style={{ color: '#874d00' }}>Campaign Milestones</Text>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
{stories.map((s) => (
|
||||
<Tag key={s.id} color="gold">
|
||||
{s.milestoneValue
|
||||
? `${s.milestoneValue.toLocaleString()} emails`
|
||||
: s.title}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
114
admin/src/components/social/ChallengeCard.tsx
Normal file
114
admin/src/components/social/ChallengeCard.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { Card, Tag, Typography, Space, Badge } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
MailOutlined,
|
||||
ScheduleOutlined,
|
||||
MessageOutlined,
|
||||
UserAddOutlined,
|
||||
ClockCircleOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export interface ChallengeMetricInfo {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const METRIC_MAP: Record<string, ChallengeMetricInfo> = {
|
||||
DOORS_KNOCKED: { icon: <HomeOutlined />, label: 'Doors Knocked', color: '#52c41a' },
|
||||
EMAILS_SENT: { icon: <MailOutlined />, label: 'Emails Sent', color: '#1890ff' },
|
||||
SHIFTS_ATTENDED: { icon: <ScheduleOutlined />, label: 'Shifts Attended', color: '#fa8c16' },
|
||||
RESPONSES_SUBMITTED: { icon: <MessageOutlined />, label: 'Responses', color: '#722ed1' },
|
||||
REFERRALS_MADE: { icon: <UserAddOutlined />, label: 'Referrals', color: '#eb2f96' },
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: 'default',
|
||||
UPCOMING: 'blue',
|
||||
ACTIVE: 'green',
|
||||
COMPLETED: 'gold',
|
||||
CANCELLED: 'red',
|
||||
};
|
||||
|
||||
interface ChallengeCardProps {
|
||||
challenge: {
|
||||
id: string;
|
||||
title: string;
|
||||
metric: string;
|
||||
status: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
_count?: { teams: number };
|
||||
teams?: unknown[];
|
||||
};
|
||||
myTeam?: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
function CountdownTimer({ target, label }: { target: string; label: string }) {
|
||||
const diff = dayjs(target).diff(dayjs(), 'second');
|
||||
if (diff <= 0) return null;
|
||||
|
||||
const days = Math.floor(diff / 86400);
|
||||
const hours = Math.floor((diff % 86400) / 3600);
|
||||
|
||||
return (
|
||||
<span style={{ fontSize: 12, opacity: 0.8 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||
{label}: {days > 0 ? `${days}d ` : ''}{hours}h
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChallengeCard({ challenge, myTeam }: ChallengeCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const metric = (METRIC_MAP[challenge.metric] || METRIC_MAP.DOORS_KNOCKED)!;
|
||||
const teamCount = challenge._count?.teams ?? challenge.teams?.length ?? 0;
|
||||
|
||||
const isActive = challenge.status === 'ACTIVE';
|
||||
const isUpcoming = challenge.status === 'UPCOMING';
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
onClick={() => navigate(`/volunteer/challenges/${challenge.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
{challenge.title}
|
||||
</Typography.Text>
|
||||
<Tag color={STATUS_COLORS[challenge.status]}>{challenge.status}</Tag>
|
||||
</div>
|
||||
|
||||
<Space size={8} wrap>
|
||||
<Tag icon={metric.icon} color={metric.color}>
|
||||
{metric.label}
|
||||
</Tag>
|
||||
<span style={{ fontSize: 12, opacity: 0.7 }}>
|
||||
<TeamOutlined style={{ marginRight: 4 }} />
|
||||
{teamCount} team{teamCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{dayjs(challenge.startsAt).format('MMM D')} - {dayjs(challenge.endsAt).format('MMM D, YYYY')}
|
||||
</Typography.Text>
|
||||
|
||||
{isUpcoming && <CountdownTimer target={challenge.startsAt} label="Starts in" />}
|
||||
{isActive && <CountdownTimer target={challenge.endsAt} label="Ends in" />}
|
||||
|
||||
{myTeam && (
|
||||
<Badge
|
||||
count={`My Team: ${myTeam.name}`}
|
||||
style={{ backgroundColor: '#1890ff', fontSize: 11 }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
82
admin/src/components/social/ChallengeLeaderboard.tsx
Normal file
82
admin/src/components/social/ChallengeLeaderboard.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { List, Typography, Tag, Space, Collapse } from 'antd';
|
||||
import { TrophyOutlined, UserOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
|
||||
interface TeamMember {
|
||||
id: number;
|
||||
score: number;
|
||||
user: { id: string; name: string | null; email: string };
|
||||
}
|
||||
|
||||
interface LeaderboardTeam {
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
captainUserId: string;
|
||||
members: TeamMember[];
|
||||
}
|
||||
|
||||
interface ChallengeLeaderboardProps {
|
||||
teams: LeaderboardTeam[];
|
||||
myTeamId?: string | null;
|
||||
}
|
||||
|
||||
const MEDAL_COLORS = ['#ffd700', '#c0c0c0', '#cd7f32'];
|
||||
|
||||
export default function ChallengeLeaderboard({ teams, myTeamId }: ChallengeLeaderboardProps) {
|
||||
if (teams.length === 0) {
|
||||
return <Typography.Text type="secondary">No teams yet</Typography.Text>;
|
||||
}
|
||||
|
||||
const items = teams.map((team, idx) => ({
|
||||
key: team.id,
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
fontWeight: team.id === myTeamId ? 700 : 400,
|
||||
color: team.id === myTeamId ? '#1890ff' : undefined,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{idx < 3 ? (
|
||||
<TrophyOutlined style={{ color: MEDAL_COLORS[idx], fontSize: 16 }} />
|
||||
) : (
|
||||
<span style={{ display: 'inline-block', width: 16, textAlign: 'center', opacity: 0.5 }}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
)}
|
||||
<span>{team.name}</span>
|
||||
{team.id === myTeamId && <Tag color="blue" style={{ marginLeft: 4 }}>You</Tag>}
|
||||
</Space>
|
||||
<Space>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<UserOutlined /> {team.members.length}
|
||||
</Typography.Text>
|
||||
<Typography.Text strong>{team.score}</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={team.members}
|
||||
renderItem={(m: TeamMember) => (
|
||||
<List.Item style={{ padding: '4px 0' }}>
|
||||
<Space>
|
||||
{m.user.id === team.captainUserId && (
|
||||
<CrownOutlined style={{ color: '#faad14' }} />
|
||||
)}
|
||||
<span>{m.user.name || m.user.email}</span>
|
||||
</Space>
|
||||
<Typography.Text strong>{m.score}</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
return <Collapse items={items} ghost size="small" />;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Typography, theme } from 'antd';
|
||||
import { ScheduleOutlined, MailOutlined, EnvironmentOutlined, MessageOutlined } from '@ant-design/icons';
|
||||
import { ScheduleOutlined, MailOutlined, EnvironmentOutlined, MessageOutlined, TrophyOutlined, StarOutlined, UserAddOutlined, FlagOutlined } from '@ant-design/icons';
|
||||
import UserAvatar from './UserAvatar';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
@ -27,6 +27,24 @@ const TYPE_CONFIG: Record<string, { icon: React.ReactNode; color: string; getLin
|
||||
color: '#722ed1',
|
||||
getLink: (meta) => `/campaigns/${meta.campaignId as string}`,
|
||||
},
|
||||
impact_story: {
|
||||
icon: <TrophyOutlined />,
|
||||
color: '#faad14',
|
||||
getLink: (meta) => `/campaign/${meta.campaignSlug as string}`,
|
||||
},
|
||||
volunteer_featured: {
|
||||
icon: <StarOutlined />,
|
||||
color: '#eb2f96',
|
||||
getLink: () => `/wall-of-fame`,
|
||||
},
|
||||
referral_completed: {
|
||||
icon: <UserAddOutlined />,
|
||||
color: '#13c2c2',
|
||||
},
|
||||
challenge_completed: {
|
||||
icon: <FlagOutlined />,
|
||||
color: '#52c41a',
|
||||
},
|
||||
};
|
||||
|
||||
interface FeedItem {
|
||||
|
||||
74
admin/src/components/social/ImpactStoryCard.tsx
Normal file
74
admin/src/components/social/ImpactStoryCard.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { Card, Tag, Typography } from 'antd';
|
||||
import { TrophyOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface ImpactStoryCardProps {
|
||||
story: {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
type: 'MILESTONE' | 'VICTORY' | 'RESPONSE' | 'CUSTOM';
|
||||
campaignTitle?: string;
|
||||
milestoneValue?: number | null;
|
||||
milestoneMetric?: string | null;
|
||||
publishedAt?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
MILESTONE: 'Milestone',
|
||||
VICTORY: 'Victory',
|
||||
RESPONSE: 'Response',
|
||||
CUSTOM: 'Story',
|
||||
};
|
||||
|
||||
export default function ImpactStoryCard({ story }: ImpactStoryCardProps) {
|
||||
const isMilestone = story.type === 'MILESTONE';
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderColor: isMilestone ? '#d4a017' : undefined,
|
||||
borderWidth: isMilestone ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<div>
|
||||
{story.campaignTitle && (
|
||||
<Tag color="blue" style={{ marginBottom: 4 }}>{story.campaignTitle}</Tag>
|
||||
)}
|
||||
<Tag color={isMilestone ? 'gold' : story.type === 'VICTORY' ? 'green' : 'default'}>
|
||||
{isMilestone && <TrophyOutlined style={{ marginRight: 4 }} />}
|
||||
{typeLabels[story.type]}
|
||||
</Tag>
|
||||
</div>
|
||||
{story.milestoneValue && (
|
||||
<Tag color="gold" style={{ fontWeight: 'bold', fontSize: 14 }}>
|
||||
{story.milestoneValue.toLocaleString()}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text strong style={{ fontSize: 15, display: 'block', marginBottom: 4 }}>
|
||||
{story.title}
|
||||
</Text>
|
||||
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 3 }}
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
{story.body}
|
||||
</Paragraph>
|
||||
|
||||
{story.publishedAt && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{dayjs(story.publishedAt).format('MMM D, YYYY')}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
131
admin/src/components/social/InviteCodeCard.tsx
Normal file
131
admin/src/components/social/InviteCodeCard.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { Card, Button, Typography, Space, Tag, Progress, Tooltip, App } from 'antd';
|
||||
import { CopyOutlined, ShareAltOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import { theme } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface InviteCodeData {
|
||||
id: string;
|
||||
code: string;
|
||||
maxUses: number;
|
||||
usedCount: number;
|
||||
expiresAt: string | null;
|
||||
isActive: boolean;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
_count?: { referrals: number };
|
||||
}
|
||||
|
||||
interface InviteCodeCardProps {
|
||||
code: InviteCodeData;
|
||||
onDeactivate: (id: string) => void;
|
||||
deactivating?: boolean;
|
||||
}
|
||||
|
||||
export default function InviteCodeCard({ code, onDeactivate, deactivating }: InviteCodeCardProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const isExpired = code.expiresAt ? dayjs(code.expiresAt).isBefore(dayjs()) : false;
|
||||
const isMaxed = code.maxUses > 0 && code.usedCount >= code.maxUses;
|
||||
const isUsable = code.isActive && !isExpired && !isMaxed;
|
||||
|
||||
const shareUrl = `${window.location.origin}/register?ref=${code.code}`;
|
||||
|
||||
const handleCopyCode = () => {
|
||||
navigator.clipboard.writeText(code.code).then(() => {
|
||||
message.success('Code copied');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
message.success('Share link copied');
|
||||
});
|
||||
};
|
||||
|
||||
const usagePercent = code.maxUses > 0
|
||||
? Math.round((code.usedCount / code.maxUses) * 100)
|
||||
: 0;
|
||||
|
||||
let statusTag: React.ReactNode;
|
||||
if (!code.isActive) {
|
||||
statusTag = <Tag color="default">Deactivated</Tag>;
|
||||
} else if (isExpired) {
|
||||
statusTag = <Tag color="red">Expired</Tag>;
|
||||
} else if (isMaxed) {
|
||||
statusTag = <Tag color="orange">Max Uses Reached</Tag>;
|
||||
} else {
|
||||
statusTag = <Tag color="green">Active</Tag>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderColor: isUsable ? token.colorPrimary : token.colorBorderSecondary,
|
||||
opacity: isUsable ? 1 : 0.7,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text
|
||||
strong
|
||||
code
|
||||
style={{ fontSize: 18, letterSpacing: 2 }}
|
||||
>
|
||||
{code.code}
|
||||
</Typography.Text>
|
||||
{statusTag}
|
||||
</div>
|
||||
|
||||
{code.note && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{code.note}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Used: {code.usedCount}{code.maxUses > 0 ? ` / ${code.maxUses}` : ' (unlimited)'}
|
||||
</Typography.Text>
|
||||
{code.expiresAt && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{isExpired ? 'Expired' : `Expires ${dayjs(code.expiresAt).format('MMM D, YYYY')}`}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Created {dayjs(code.createdAt).format('MMM D, YYYY')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{code.maxUses > 0 && (
|
||||
<Progress percent={usagePercent} size="small" status={isMaxed ? 'exception' : 'active'} />
|
||||
)}
|
||||
|
||||
<Space size="small" wrap>
|
||||
<Tooltip title="Copy code">
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyCode}>
|
||||
Copy Code
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Copy share link">
|
||||
<Button size="small" icon={<ShareAltOutlined />} onClick={handleCopyLink}>
|
||||
Share Link
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{code.isActive && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => onDeactivate(code.id)}
|
||||
loading={deactivating}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
118
admin/src/components/social/PublicLeaderboard.tsx
Normal file
118
admin/src/components/social/PublicLeaderboard.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { List, Typography, Switch, Space, Spin, Empty } from 'antd';
|
||||
import { TrophyOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import UserAvatar from '@/components/social/UserAvatar';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface PublicLeaderboardProps {
|
||||
type: 'canvass' | 'shifts' | 'campaigns';
|
||||
showOptIn?: boolean;
|
||||
optedIn?: boolean;
|
||||
onOptInChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const SCORE_LABELS: Record<string, string> = {
|
||||
canvass: 'doors',
|
||||
shifts: 'shifts',
|
||||
campaigns: 'campaigns',
|
||||
};
|
||||
|
||||
function getMedalColor(rank: number): string | undefined {
|
||||
if (rank === 1) return '#FFD700';
|
||||
if (rank === 2) return '#C0C0C0';
|
||||
if (rank === 3) return '#CD7F32';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function PublicLeaderboard({ type, showOptIn, optedIn, onOptInChange }: PublicLeaderboardProps) {
|
||||
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchLeaderboard = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data } = await api.get('/social/spotlight/leaderboard', {
|
||||
params: { type, limit: 10 },
|
||||
});
|
||||
setEntries(data.leaderboard || []);
|
||||
} catch {
|
||||
// Silently fail for unauthenticated users
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLeaderboard();
|
||||
}, [fetchLeaderboard]);
|
||||
|
||||
if (loading) return <Spin style={{ display: 'block', margin: '24px auto' }} />;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <Empty description="No leaderboard data yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<List
|
||||
dataSource={entries}
|
||||
renderItem={(entry) => {
|
||||
const medalColor = getMedalColor(entry.rank);
|
||||
return (
|
||||
<List.Item style={{ padding: '10px 0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%' }}>
|
||||
<div style={{ width: 36, textAlign: 'center', flexShrink: 0 }}>
|
||||
{medalColor ? (
|
||||
<CrownOutlined style={{ fontSize: 22, color: medalColor }} />
|
||||
) : (
|
||||
<Text strong style={{ fontSize: 16 }}>#{entry.rank}</Text>
|
||||
)}
|
||||
</div>
|
||||
<UserAvatar
|
||||
userId={entry.userId}
|
||||
name={entry.name}
|
||||
email={entry.email}
|
||||
size={36}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong ellipsis>
|
||||
{entry.name || entry.email.split('@')[0]}
|
||||
</Text>
|
||||
</div>
|
||||
<Text style={{ flexShrink: 0 }}>
|
||||
<Text strong>{entry.score}</Text>{' '}
|
||||
<Text type="secondary">{SCORE_LABELS[type]}</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{showOptIn && onOptInChange && (
|
||||
<div style={{ marginTop: 16, padding: '12px 0', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Space>
|
||||
<TrophyOutlined />
|
||||
<Text>Show me on leaderboard</Text>
|
||||
<Switch
|
||||
checked={optedIn}
|
||||
onChange={onOptInChange}
|
||||
size="small"
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
admin/src/components/social/SpotlightCard.tsx
Normal file
93
admin/src/components/social/SpotlightCard.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { Card, Typography, Tag } from 'antd';
|
||||
import { StarFilled } from '@ant-design/icons';
|
||||
import UserAvatar from '@/components/social/UserAvatar';
|
||||
|
||||
const { Text, Paragraph, Title } = Typography;
|
||||
|
||||
interface SpotlightData {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName?: string | null;
|
||||
headline?: string | null;
|
||||
story?: string | null;
|
||||
featuredMonth?: string | null;
|
||||
stats?: { canvassVisits?: number; shiftSignups?: number; campaignEmails?: number } | null;
|
||||
}
|
||||
|
||||
interface SpotlightCardProps {
|
||||
spotlight: SpotlightData;
|
||||
}
|
||||
|
||||
export default function SpotlightCard({ spotlight }: SpotlightCardProps) {
|
||||
const monthLabel = spotlight.featuredMonth
|
||||
? new Date(spotlight.featuredMonth + '-01').toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
border: '2px solid #d4a017',
|
||||
borderRadius: 12,
|
||||
background: 'linear-gradient(135deg, rgba(212, 160, 23, 0.08), transparent)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
|
||||
<UserAvatar
|
||||
userId={spotlight.userId}
|
||||
name={spotlight.userName}
|
||||
size={64}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{spotlight.userName || 'Anonymous Volunteer'}
|
||||
</Title>
|
||||
<Tag color="gold" icon={<StarFilled />}>Featured</Tag>
|
||||
</div>
|
||||
|
||||
{monthLabel && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{monthLabel}</Text>
|
||||
)}
|
||||
|
||||
{spotlight.headline && (
|
||||
<Title level={5} style={{ margin: '8px 0 4px', fontSize: 15 }}>
|
||||
{spotlight.headline}
|
||||
</Title>
|
||||
)}
|
||||
|
||||
{spotlight.story && (
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 3, expandable: true, symbol: 'Read more' }}
|
||||
style={{ margin: 0, marginTop: 4 }}
|
||||
>
|
||||
{spotlight.story}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{spotlight.stats && (
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
{spotlight.stats.canvassVisits != null && spotlight.stats.canvassVisits > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{spotlight.stats.canvassVisits} doors knocked
|
||||
</Text>
|
||||
)}
|
||||
{spotlight.stats.shiftSignups != null && spotlight.stats.shiftSignups > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{spotlight.stats.shiftSignups} shifts
|
||||
</Text>
|
||||
)}
|
||||
{spotlight.stats.campaignEmails != null && spotlight.stats.campaignEmails > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{spotlight.stats.campaignEmails} emails sent
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
133
admin/src/components/social/TeamJoinCard.tsx
Normal file
133
admin/src/components/social/TeamJoinCard.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Input, Button, List, Space, Typography, Tag, App } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, CrownOutlined, LoginOutlined } from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface TeamInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
captainUserId: string;
|
||||
captain: { id: string; name: string | null; email: string };
|
||||
members: { id: number; userId: string }[];
|
||||
}
|
||||
|
||||
interface TeamJoinCardProps {
|
||||
challengeId: string;
|
||||
teams: TeamInfo[];
|
||||
maxTeamSize: number;
|
||||
onTeamCreated: () => void;
|
||||
onTeamJoined: () => void;
|
||||
}
|
||||
|
||||
export default function TeamJoinCard({
|
||||
challengeId,
|
||||
teams,
|
||||
maxTeamSize,
|
||||
onTeamCreated,
|
||||
onTeamJoined,
|
||||
}: TeamJoinCardProps) {
|
||||
const { message } = App.useApp();
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [joiningId, setJoiningId] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!teamName.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post(`/social/challenges/${challengeId}/teams`, { name: teamName.trim() });
|
||||
message.success('Team created');
|
||||
setTeamName('');
|
||||
onTeamCreated();
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.error?.message || 'Failed to create team');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoin = async (teamId: string) => {
|
||||
setJoiningId(teamId);
|
||||
try {
|
||||
await api.post(`/social/challenges/${challengeId}/teams/${teamId}/join`);
|
||||
message.success('Joined team');
|
||||
onTeamJoined();
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.error?.message || 'Failed to join team');
|
||||
} finally {
|
||||
setJoiningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card size="small" title="Create a Team">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Team name"
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
onPressEnter={handleCreate}
|
||||
maxLength={100}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
loading={creating}
|
||||
onClick={handleCreate}
|
||||
disabled={!teamName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Card>
|
||||
|
||||
{teams.length > 0 && (
|
||||
<Card size="small" title="Join an Existing Team">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={teams}
|
||||
renderItem={(team: TeamInfo) => {
|
||||
const isFull = team.members.length >= maxTeamSize;
|
||||
return (
|
||||
<List.Item
|
||||
actions={[
|
||||
isFull ? (
|
||||
<Tag color="red" key="full">Full</Tag>
|
||||
) : (
|
||||
<Button
|
||||
key="join"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<LoginOutlined />}
|
||||
loading={joiningId === team.id}
|
||||
onClick={() => handleJoin(team.id)}
|
||||
>
|
||||
Join
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<TeamOutlined style={{ fontSize: 18 }} />}
|
||||
title={team.name}
|
||||
description={
|
||||
<Space size={8}>
|
||||
<span>{team.members.length}/{maxTeamSize} members</span>
|
||||
<span>
|
||||
<CrownOutlined style={{ color: '#faad14', marginRight: 2 }} />
|
||||
{team.captain.name || team.captain.email}
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
locale={{ emptyText: <Typography.Text type="secondary">No teams yet</Typography.Text> }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@ -29,11 +29,14 @@ import {
|
||||
QuestionCircleOutlined,
|
||||
ExportOutlined,
|
||||
QrcodeOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useOutletContext, useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { buildServiceUrl } from '@/lib/service-url';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type {
|
||||
Campaign,
|
||||
@ -44,6 +47,7 @@ import type {
|
||||
CreateCampaignPayload,
|
||||
UpdateCampaignPayload,
|
||||
Cut,
|
||||
ServicesConfig,
|
||||
} from '@/types/api';
|
||||
import CampaignEmailsDrawer from '@/pages/CampaignEmailsDrawer';
|
||||
import ExportContactsModal from '@/components/canvass/ExportContactsModal';
|
||||
@ -123,6 +127,9 @@ export default function CampaignsPage() {
|
||||
const [createSelectedVideo, setCreateSelectedVideo] = useState<Video | null>(null);
|
||||
const [editSelectedVideo, setEditSelectedVideo] = useState<Video | null>(null);
|
||||
const { settings: siteSettings } = useSettingsStore();
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
|
||||
const [nocodbUrl, setNocodbUrl] = useState<string | null>(null);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
@ -491,14 +498,31 @@ export default function CampaignsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
api.get<ServicesConfig>('/services/config')
|
||||
.then(({ data }) => setNocodbUrl(buildServiceUrl(data.nocodbSubdomain, data.domain, data.nocodbPort)))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Button
|
||||
icon={<MailOutlined />}
|
||||
onClick={() => navigate('/app/email-queue')}
|
||||
>
|
||||
Email Queue
|
||||
</Button>
|
||||
), [navigate]);
|
||||
<Space>
|
||||
{isSuperAdmin && nocodbUrl && (
|
||||
<Tooltip title="Browse campaigns in NocoDB for advanced filtering & export">
|
||||
<Button icon={<DatabaseOutlined />} href={nocodbUrl} target="_blank" size="small">
|
||||
Browse in NocoDB
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
icon={<MailOutlined />}
|
||||
onClick={() => navigate('/app/email-queue')}
|
||||
>
|
||||
Email Queue
|
||||
</Button>
|
||||
</Space>
|
||||
), [navigate, isSuperAdmin, nocodbUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Campaigns', actions: headerActions });
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
LockOutlined,
|
||||
MessageOutlined,
|
||||
CalendarOutlined,
|
||||
LineChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
|
||||
@ -196,6 +197,7 @@ export default function DashboardPage() {
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'homepage'>('dashboard');
|
||||
const [homepageUrl, setHomepageUrl] = useState<string | null>(null);
|
||||
const [grafanaUrl, setGrafanaUrl] = useState<string | null>(null);
|
||||
const [onboardingDismissed, setOnboardingDismissed] = useState(() =>
|
||||
localStorage.getItem('cml-onboarding-dismissed') === 'true'
|
||||
);
|
||||
@ -216,6 +218,7 @@ export default function DashboardPage() {
|
||||
api.get<ServicesStatus>('/services/status').then(({ data }) => setServices(data)).catch(() => {}),
|
||||
api.get<ServicesConfig>('/services/config').then(({ data }) => {
|
||||
setHomepageUrl(buildServiceUrl(data.homepageSubdomain, data.domain, data.homepagePort));
|
||||
setGrafanaUrl(buildServiceUrl(data.grafanaSubdomain, data.domain, data.grafanaPort));
|
||||
}).catch(() => {}),
|
||||
api.get<SystemInfo>('/dashboard/system').then(({ data }) => setSystemInfo(data)).catch(() => {}),
|
||||
api.get<ContainerInfo[]>('/dashboard/containers').then(({ data }) => setContainers(data)).catch(() => {}),
|
||||
@ -439,6 +442,9 @@ export default function DashboardPage() {
|
||||
{showInfluence && <QuickStat icon={<MailOutlined />} color="#faad14" value={summary.emails.sent} label="sent" onClick={() => navigate('/app/email-queue')} />}
|
||||
{showMedia && <QuickStat icon={<VideoCameraOutlined />} color="#13c2c2" value={summary.videos.published} label={`of ${summary.videos.total}`} onClick={() => navigate('/app/media/library')} />}
|
||||
{showMap && <QuickStat icon={<ScheduleOutlined />} color="#eb2f96" value={summary.shifts.upcoming} label={`${summary.shifts.open} open`} onClick={() => navigate('/app/map/shifts')} />}
|
||||
{isSuperAdmin && grafanaUrl && (
|
||||
<QuickStat icon={<BarChartOutlined />} color="#f5222d" value="Metrics" label="" onClick={() => window.open(`${grafanaUrl}/d/changemaker-overview?from=now-24h&to=now`, '_blank')} />
|
||||
)}
|
||||
{/* Pending action tags */}
|
||||
{summary.responses.pending > 0 && (
|
||||
<Tag color="orange" style={{ cursor: 'pointer', margin: '0 0 0 4px' }} onClick={() => navigate('/app/responses')}>
|
||||
@ -515,7 +521,16 @@ export default function DashboardPage() {
|
||||
</Flex>
|
||||
}
|
||||
size="small"
|
||||
extra={<Button type="link" onClick={() => navigate('/app/campaigns')}>View</Button>}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
{grafanaUrl && (
|
||||
<Tooltip title="View email trends in Grafana">
|
||||
<Button type="text" size="small" icon={<LineChartOutlined />} onClick={() => window.open(`${grafanaUrl}/d/changemaker-overview?viewPanel=2&from=now-7d&to=now`, '_blank')} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button type="link" onClick={() => navigate('/app/campaigns')}>View</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{summary && (
|
||||
<Flex gap={8} align="flex-start">
|
||||
@ -562,7 +577,16 @@ export default function DashboardPage() {
|
||||
</Flex>
|
||||
}
|
||||
size="small"
|
||||
extra={<Button type="link" onClick={() => navigate('/app/map')}>View</Button>}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
{grafanaUrl && (
|
||||
<Tooltip title="View canvass trends in Grafana">
|
||||
<Button type="text" size="small" icon={<LineChartOutlined />} onClick={() => window.open(`${grafanaUrl}/d/changemaker-overview?viewPanel=10&from=now-7d&to=now`, '_blank')} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button type="link" onClick={() => navigate('/app/map')}>View</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{summary && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={6}>
|
||||
@ -599,7 +623,16 @@ export default function DashboardPage() {
|
||||
</Flex>
|
||||
}
|
||||
size="small"
|
||||
extra={<Button type="link" onClick={() => navigate('/app/users')}>Manage</Button>}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
{grafanaUrl && (
|
||||
<Tooltip title="View login trends in Grafana">
|
||||
<Button type="text" size="small" icon={<LineChartOutlined />} onClick={() => window.open(`${grafanaUrl}/d/changemaker-overview?viewPanel=5&from=now-7d&to=now`, '_blank')} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button type="link" onClick={() => navigate('/app/users')}>Manage</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{summary && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={6}>
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
InputNumber,
|
||||
Tabs,
|
||||
Grid,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@ -41,12 +42,15 @@ import {
|
||||
ClockCircleOutlined,
|
||||
ScissorOutlined,
|
||||
EyeOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { buildServiceUrl } from '@/lib/service-url';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type {
|
||||
Location,
|
||||
@ -66,6 +70,7 @@ import type {
|
||||
NarImportProgress,
|
||||
LocationHistory,
|
||||
LocationHistoryResponse,
|
||||
ServicesConfig,
|
||||
} from '@/types/api';
|
||||
import {
|
||||
LOCATION_HISTORY_ACTION_LABELS,
|
||||
@ -101,6 +106,9 @@ function formatNarSize(bytes: number): string {
|
||||
|
||||
export default function LocationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
|
||||
const [nocodbUrl, setNocodbUrl] = useState<string | null>(null);
|
||||
const [locations, setLocations] = useState<Location[]>([]);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
@ -981,9 +989,26 @@ export default function LocationsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Map Locations' });
|
||||
if (isSuperAdmin) {
|
||||
api.get<ServicesConfig>('/services/config')
|
||||
.then(({ data }) => setNocodbUrl(buildServiceUrl(data.nocodbSubdomain, data.domain, data.nocodbPort)))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({
|
||||
title: 'Map Locations',
|
||||
actions: isSuperAdmin && nocodbUrl ? (
|
||||
<Tooltip title="Browse locations in NocoDB for advanced filtering & export">
|
||||
<Button icon={<DatabaseOutlined />} href={nocodbUrl} target="_blank" size="small">
|
||||
Browse in NocoDB
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined,
|
||||
});
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
}, [setPageHeader, isSuperAdmin, nocodbUrl]);
|
||||
|
||||
const anyDrawerOpen = createDrawerOpen || editDrawerOpen || importDrawerOpen || bulkGeocodeDrawerOpen;
|
||||
const activeDrawerWidth = isMobile ? 0 : (createDrawerOpen ? 600 : editDrawerOpen ? 700 : importDrawerOpen ? 700 : bulkGeocodeDrawerOpen ? 600 : 0);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Card, Form, Input, Button, Alert, Typography, Segmented, Modal, App } from 'antd';
|
||||
import { MailOutlined, LockOutlined, UserOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { MailOutlined, LockOutlined, UserOutlined, CheckCircleOutlined, GiftOutlined } from '@ant-design/icons';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { isAdmin } from '@/utils/roles';
|
||||
@ -31,8 +31,17 @@ export default function LoginPage() {
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
|
||||
const redirectTo = searchParams.get('redirect');
|
||||
const refCode = searchParams.get('ref') || '';
|
||||
const showRegister = settings?.enablePublicRegistration !== false;
|
||||
|
||||
// Auto-switch to register mode when ref code is present
|
||||
useEffect(() => {
|
||||
if (refCode && showRegister) {
|
||||
setMode('register');
|
||||
registerForm.setFieldValue('inviteCode', refCode);
|
||||
}
|
||||
}, [refCode, showRegister]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
if (redirectTo) {
|
||||
@ -62,9 +71,9 @@ export default function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (values: { name: string; email: string; password: string }) => {
|
||||
const handleRegister = async (values: { name: string; email: string; password: string; inviteCode?: string }) => {
|
||||
try {
|
||||
const result = await register(values.name, values.email, values.password);
|
||||
const result = await register(values.name, values.email, values.password, values.inviteCode);
|
||||
if (result?.requiresVerification) {
|
||||
// Don't navigate — show the verification message
|
||||
return;
|
||||
@ -264,6 +273,13 @@ export default function LoginPage() {
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="Password (12+ chars)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="inviteCode"
|
||||
extra="Have an invite code? Enter it here"
|
||||
>
|
||||
<Input prefix={<GiftOutlined />} placeholder="Invite Code (optional)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
dependencies={['password']}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
55
admin/src/pages/SchedulingCalendarPage.tsx
Normal file
55
admin/src/pages/SchedulingCalendarPage.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { useRef } from 'react';
|
||||
import { Typography, Space } from 'antd';
|
||||
import { CalendarOutlined } from '@ant-design/icons';
|
||||
import UnifiedCalendar from '@/components/calendar/UnifiedCalendar';
|
||||
import type { UnifiedCalendarItem } from '@/types/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function SchedulingCalendarPage() {
|
||||
const navigate = useNavigate();
|
||||
const addEventRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const handleShiftClick = (item: UnifiedCalendarItem) => {
|
||||
if (item.shiftId) {
|
||||
navigate('/app/map/shifts');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
Scheduling Calendar
|
||||
</Title>
|
||||
|
||||
{/* Legend */}
|
||||
<Space size={12} wrap>
|
||||
<Space size={4}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#1890ff' }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Shifts</Text>
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#fa8c16' }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Polls</Text>
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#722ed1' }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Ticketed Events</Text>
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#52c41a' }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Community Events</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<UnifiedCalendar
|
||||
onShiftSignup={handleShiftClick}
|
||||
onAddEvent={addEventRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -528,6 +528,9 @@ export default function SettingsPage() {
|
||||
<Form.Item label="Social Connections" name="enableSocial" valuePropName="checked" extra="Volunteer friend connections, activity feeds, and discovery" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Social Calendar" name="enableSocialCalendar" valuePropName="checked" extra="Personal calendar with layers, shared views, and .ics integration" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Auto-sync People to Map" name="autoSyncPeopleToMap" valuePropName="checked" extra="Adding a contact address auto-creates a map location with geocoding" style={{ marginBottom: 0 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
@ -540,7 +543,13 @@ export default function SettingsPage() {
|
||||
size="small"
|
||||
title={<Space><DollarOutlined /> Commerce</Space>}
|
||||
>
|
||||
<Form.Item label="Payments (Stripe)" name="enablePayments" valuePropName="checked" extra="Subscriptions, products, and donations" style={{ marginBottom: 0 }}>
|
||||
<Form.Item label="Payments (Stripe)" name="enablePayments" valuePropName="checked" extra="Subscriptions, products, and donations" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Ticketed Events" name="enableTicketedEvents" valuePropName="checked" extra="Create events with tiered tickets, QR check-in, and Stripe payments" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Require Event Approval" name="requireEventApproval" valuePropName="checked" extra="Non-admin users need admin approval before publishing events" style={{ marginBottom: 0 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
@ -41,10 +41,13 @@ import {
|
||||
SettingOutlined,
|
||||
UserAddOutlined,
|
||||
ContactsOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import { buildServiceUrl } from '@/lib/service-url';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { getUserRoles } from '@/utils/roles';
|
||||
import type {
|
||||
User,
|
||||
@ -62,6 +65,7 @@ import type {
|
||||
LinkedContactResponse,
|
||||
Contact,
|
||||
SupportLevel,
|
||||
ServicesConfig,
|
||||
} from '@/types/api';
|
||||
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS, CONTACT_SOURCE_LABELS, CONTACT_SOURCE_COLORS } from '@/types/api';
|
||||
|
||||
@ -102,6 +106,9 @@ const statusOptions: { value: UserStatus; label: string }[] = [
|
||||
|
||||
export default function UsersPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
|
||||
const [nocodbUrl, setNocodbUrl] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -129,9 +136,26 @@ export default function UsersPage() {
|
||||
const isMobile = !screens.md;
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Users' });
|
||||
if (isSuperAdmin) {
|
||||
api.get<ServicesConfig>('/services/config')
|
||||
.then(({ data }) => setNocodbUrl(buildServiceUrl(data.nocodbSubdomain, data.domain, data.nocodbPort)))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({
|
||||
title: 'Users',
|
||||
actions: isSuperAdmin && nocodbUrl ? (
|
||||
<Tooltip title="Browse users in NocoDB for advanced filtering & export">
|
||||
<Button icon={<DatabaseOutlined />} href={nocodbUrl} target="_blank" size="small">
|
||||
Browse in NocoDB
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined,
|
||||
});
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
}, [setPageHeader, isSuperAdmin, nocodbUrl]);
|
||||
|
||||
const getActiveDrawerWidth = () => {
|
||||
if (createDrawerOpen) return 520;
|
||||
|
||||
308
admin/src/pages/events/CheckInScannerPage.tsx
Normal file
308
admin/src/pages/events/CheckInScannerPage.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Typography, Button, Input, Card, Space, message, Result, Statistic,
|
||||
Row, Col, Divider, Spin,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined, CameraOutlined, KeyOutlined,
|
||||
CheckCircleOutlined, CloseCircleOutlined, WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface ScanResult {
|
||||
type: 'success' | 'warning' | 'error';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
ticket?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function CheckInScannerPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [event, setEvent] = useState<Record<string, unknown> | null>(null);
|
||||
const [stats, setStats] = useState<{ totalTickets: number; checkedIn: number; remaining: number } | null>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [manualCode, setManualCode] = useState('');
|
||||
const [manualEmail, setManualEmail] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [mode, setMode] = useState<'camera' | 'manual'>('manual');
|
||||
const scannerRef = useRef<unknown>(null);
|
||||
const scannerContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchEvent = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await api.get(`/api/ticketed-events/admin/${id}`);
|
||||
setEvent(data);
|
||||
} catch {
|
||||
message.error('Failed to load event');
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await api.get(`/api/ticketed-events/checkin/event/${id}/stats`);
|
||||
setStats(data);
|
||||
} catch { /* silent */ }
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvent();
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 5000); // Refresh stats every 5s
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchEvent, fetchStats]);
|
||||
|
||||
const handleQrScan = useCallback(async (decodedText: string) => {
|
||||
if (processing) return;
|
||||
setProcessing(true);
|
||||
setScanResult(null);
|
||||
|
||||
try {
|
||||
// Extract token from URL or use raw text
|
||||
let token = decodedText;
|
||||
try {
|
||||
const url = new URL(decodedText);
|
||||
const t = url.searchParams.get('token');
|
||||
if (t) token = t;
|
||||
} catch { /* not a URL, use raw text */ }
|
||||
|
||||
// Validate first
|
||||
const { data: validation } = await api.post('/api/ticketed-events/checkin/validate', { token });
|
||||
|
||||
if (!validation.valid) {
|
||||
setScanResult({
|
||||
type: validation.error === 'Already checked in' ? 'warning' : 'error',
|
||||
title: validation.error,
|
||||
subtitle: validation.ticket
|
||||
? `${validation.ticket.ticketCode} — ${validation.ticket.holderName || validation.ticket.holderEmail}`
|
||||
: '',
|
||||
ticket: validation.ticket,
|
||||
});
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm check-in
|
||||
const { data: result } = await api.post('/api/ticketed-events/checkin/confirm', { token });
|
||||
setScanResult({
|
||||
type: 'success',
|
||||
title: 'Checked In!',
|
||||
subtitle: `${result.ticket.ticketCode} — ${result.ticket.holderName || result.ticket.holderEmail}`,
|
||||
ticket: result.ticket,
|
||||
});
|
||||
fetchStats();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Scan failed';
|
||||
setScanResult({ type: 'error', title: msg, subtitle: '' });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [processing, fetchStats]);
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
if (!scannerContainerRef.current) return;
|
||||
try {
|
||||
const { Html5QrcodeScanner } = await import('html5-qrcode');
|
||||
const scanner = new Html5QrcodeScanner('qr-reader', {
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 },
|
||||
}, false);
|
||||
scanner.render(
|
||||
(decodedText: string) => handleQrScan(decodedText),
|
||||
() => { /* error callback — ignore decode failures */ },
|
||||
);
|
||||
scannerRef.current = scanner;
|
||||
} catch {
|
||||
message.error('Failed to start camera. Make sure html5-qrcode is installed.');
|
||||
}
|
||||
}, [handleQrScan]);
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
if (scannerRef.current) {
|
||||
(scannerRef.current as { clear: () => Promise<void> }).clear().catch(() => {});
|
||||
scannerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'camera') {
|
||||
startCamera();
|
||||
} else {
|
||||
stopCamera();
|
||||
}
|
||||
return () => stopCamera();
|
||||
}, [mode, startCamera, stopCamera]);
|
||||
|
||||
const handleManualCheckin = async (type: 'code' | 'email') => {
|
||||
if (!id) return;
|
||||
setProcessing(true);
|
||||
setScanResult(null);
|
||||
|
||||
try {
|
||||
const body: Record<string, string> = { eventId: id };
|
||||
if (type === 'code') {
|
||||
body.ticketCode = manualCode.trim().toUpperCase();
|
||||
} else {
|
||||
body.holderEmail = manualEmail.trim();
|
||||
}
|
||||
|
||||
const { data } = await api.post('/api/ticketed-events/checkin/manual', body);
|
||||
setScanResult({
|
||||
type: 'success',
|
||||
title: 'Checked In!',
|
||||
subtitle: `${data.ticket.ticketCode} — ${data.ticket.holderName || data.ticket.holderEmail}`,
|
||||
ticket: data.ticket,
|
||||
});
|
||||
setManualCode('');
|
||||
setManualEmail('');
|
||||
fetchStats();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Check-in failed';
|
||||
setScanResult({ type: 'error', title: msg, subtitle: '' });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resultColors: Record<string, string> = {
|
||||
success: '#52c41a',
|
||||
warning: '#faad14',
|
||||
error: '#ff4d4f',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#0d1117',
|
||||
color: '#fff',
|
||||
padding: '16px',
|
||||
}}>
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(`/app/events/${id}`)}>Back</Button>
|
||||
<Title level={4} style={{ margin: 0, color: '#fff' }}>
|
||||
{(event as Record<string, unknown>)?.title as string || 'Check-in Scanner'}
|
||||
</Title>
|
||||
</Space>
|
||||
|
||||
{/* Live stats */}
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ background: '#161b22', border: '1px solid #30363d' }}>
|
||||
<Statistic title={<span style={{ color: '#8b949e' }}>Checked In</span>} value={stats.checkedIn} valueStyle={{ color: '#58a6ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ background: '#161b22', border: '1px solid #30363d' }}>
|
||||
<Statistic title={<span style={{ color: '#8b949e' }}>Remaining</span>} value={stats.remaining} valueStyle={{ color: '#f0883e' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ background: '#161b22', border: '1px solid #30363d' }}>
|
||||
<Statistic title={<span style={{ color: '#8b949e' }}>Total</span>} value={stats.totalTickets} valueStyle={{ color: '#8b949e' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Scan result */}
|
||||
{scanResult && (
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
background: '#161b22',
|
||||
borderColor: resultColors[scanResult.type],
|
||||
borderWidth: 2,
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
icon={
|
||||
scanResult.type === 'success' ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> :
|
||||
scanResult.type === 'warning' ? <WarningOutlined style={{ color: '#faad14' }} /> :
|
||||
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||
}
|
||||
title={<span style={{ color: resultColors[scanResult.type] }}>{scanResult.title}</span>}
|
||||
subTitle={<span style={{ color: '#8b949e' }}>{scanResult.subtitle}</span>}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Mode toggle */}
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type={mode === 'camera' ? 'primary' : 'default'}
|
||||
icon={<CameraOutlined />}
|
||||
onClick={() => setMode('camera')}
|
||||
>
|
||||
Camera
|
||||
</Button>
|
||||
<Button
|
||||
type={mode === 'manual' ? 'primary' : 'default'}
|
||||
icon={<KeyOutlined />}
|
||||
onClick={() => setMode('manual')}
|
||||
>
|
||||
Manual
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{mode === 'camera' && (
|
||||
<Card style={{ background: '#161b22', border: '1px solid #30363d', marginBottom: 16 }}>
|
||||
<div id="qr-reader" ref={scannerContainerRef} style={{ width: '100%' }} />
|
||||
{processing && <div style={{ textAlign: 'center', marginTop: 16 }}><Spin tip="Processing..." /></div>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{mode === 'manual' && (
|
||||
<Card style={{ background: '#161b22', border: '1px solid #30363d' }}>
|
||||
<Title level={5} style={{ color: '#c9d1d9' }}>Enter Ticket Code</Title>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="ABCD-1234"
|
||||
value={manualCode}
|
||||
onChange={e => setManualCode(e.target.value)}
|
||||
onPressEnter={() => manualCode && handleManualCheckin('code')}
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleManualCheckin('code')}
|
||||
loading={processing}
|
||||
disabled={!manualCode.trim()}
|
||||
>
|
||||
Check In
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
<Divider style={{ borderColor: '#30363d' }}>
|
||||
<Text style={{ color: '#8b949e' }}>or</Text>
|
||||
</Divider>
|
||||
|
||||
<Title level={5} style={{ color: '#c9d1d9' }}>Look up by Email</Title>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="attendee@example.com"
|
||||
value={manualEmail}
|
||||
onChange={e => setManualEmail(e.target.value)}
|
||||
onPressEnter={() => manualEmail && handleManualCheckin('email')}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleManualCheckin('email')}
|
||||
loading={processing}
|
||||
disabled={!manualEmail.trim()}
|
||||
>
|
||||
Check In
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
admin/src/pages/events/EventDetailPage.tsx
Normal file
295
admin/src/pages/events/EventDetailPage.tsx
Normal file
@ -0,0 +1,295 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Typography, Card, Row, Col, Statistic, Table, Tag, Button, Space, message,
|
||||
Tabs, Input, Popconfirm, Descriptions, Badge,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined, CheckCircleOutlined, UserOutlined, DollarOutlined,
|
||||
MailOutlined, ScanOutlined, CopyOutlined, TagOutlined, VideoCameraOutlined,
|
||||
EnvironmentOutlined, LinkOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [event, setEvent] = useState<Record<string, unknown> | null>(null);
|
||||
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
|
||||
const [tickets, setTickets] = useState<Record<string, unknown>[]>([]);
|
||||
const [ticketPagination, setTicketPagination] = useState({ page: 1, limit: 20, total: 0 });
|
||||
const [checkIns, setCheckIns] = useState<Record<string, unknown>[]>([]);
|
||||
const [ticketSearch, setTicketSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [joiningMeeting, setJoiningMeeting] = useState(false);
|
||||
|
||||
const fetchEvent = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const [eventRes, statsRes] = await Promise.all([
|
||||
api.get(`/api/ticketed-events/admin/${id}`),
|
||||
api.get(`/api/ticketed-events/admin/${id}/stats`),
|
||||
]);
|
||||
setEvent(eventRes.data);
|
||||
setStats(statsRes.data);
|
||||
} catch {
|
||||
message.error('Failed to load event');
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchTickets = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
page: ticketPagination.page,
|
||||
limit: ticketPagination.limit,
|
||||
};
|
||||
if (ticketSearch) params.search = ticketSearch;
|
||||
const { data } = await api.get(`/api/ticketed-events/admin/${id}/tickets`, { params });
|
||||
setTickets(data.tickets);
|
||||
setTicketPagination(p => ({ ...p, total: data.pagination.total }));
|
||||
} catch {
|
||||
message.error('Failed to load tickets');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, ticketPagination.page, ticketPagination.limit, ticketSearch]);
|
||||
|
||||
const fetchCheckIns = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await api.get(`/api/ticketed-events/admin/${id}/checkins`, { params: { limit: 50 } });
|
||||
setCheckIns(data.checkIns);
|
||||
} catch { /* silent */ }
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { fetchEvent(); }, [fetchEvent]);
|
||||
useEffect(() => { fetchTickets(); }, [fetchTickets]);
|
||||
useEffect(() => { fetchCheckIns(); }, [fetchCheckIns]);
|
||||
|
||||
const handleResend = async (ticketId: string) => {
|
||||
try {
|
||||
await api.post(`/api/ticketed-events/admin/${id}/resend-ticket/${ticketId}`);
|
||||
message.success('Ticket email resent');
|
||||
} catch {
|
||||
message.error('Failed to resend');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelTicket = async (ticketId: string) => {
|
||||
try {
|
||||
await api.post(`/api/ticketed-events/admin/${id}/tickets/${ticketId}/cancel`);
|
||||
message.success('Ticket cancelled');
|
||||
fetchTickets();
|
||||
fetchEvent();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to cancel';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinAsModerator = async () => {
|
||||
setJoiningMeeting(true);
|
||||
try {
|
||||
const { data } = await api.post(`/api/ticketed-events/admin/${id}/meeting-token`);
|
||||
window.open(data.jitsiUrl, '_blank');
|
||||
} catch {
|
||||
message.error('Failed to generate meeting token');
|
||||
} finally {
|
||||
setJoiningMeeting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
const e = event as Record<string, unknown>;
|
||||
const s = stats as Record<string, unknown> | null;
|
||||
|
||||
const ticketColumns = [
|
||||
{ title: 'Code', dataIndex: 'ticketCode', key: 'code', render: (v: string) => <Text code>{v}</Text> },
|
||||
{ title: 'Holder', dataIndex: 'holderName', key: 'holder', render: (v: string, r: Record<string, unknown>) => v || (r.holderEmail as string) },
|
||||
{ title: 'Email', dataIndex: 'holderEmail', key: 'email', responsive: ['lg' as const] },
|
||||
{ title: 'Tier', key: 'tier', render: (_: unknown, r: Record<string, unknown>) => String((r.tier as Record<string, unknown>)?.name || '—') },
|
||||
{
|
||||
title: 'Status', dataIndex: 'status', key: 'status',
|
||||
render: (s: string) => {
|
||||
const colors: Record<string, string> = { VALID: 'green', CHECKED_IN: 'blue', CANCELLED: 'red', REFUNDED: 'orange' };
|
||||
return <Tag color={colors[s]}>{s}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: 'Issued', dataIndex: 'issuedAt', key: 'issued', render: (d: string) => dayjs(d).format('MMM D, HH:mm'), responsive: ['md' as const] },
|
||||
{
|
||||
title: 'Actions', key: 'actions',
|
||||
render: (_: unknown, record: Record<string, unknown>) => (
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<MailOutlined />} onClick={() => handleResend(record.id as string)}>Resend</Button>
|
||||
{record.status === 'VALID' && (
|
||||
<Popconfirm title="Cancel this ticket?" onConfirm={() => handleCancelTicket(record.id as string)}>
|
||||
<Button size="small" danger>Cancel</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const checkInColumns = [
|
||||
{ title: 'Ticket', key: 'ticket', render: (_: unknown, r: Record<string, unknown>) => {
|
||||
const t = r.ticket as Record<string, unknown>;
|
||||
return `${t?.ticketCode} — ${t?.holderName || t?.holderEmail}`;
|
||||
}},
|
||||
{ title: 'Method', dataIndex: 'method', key: 'method' },
|
||||
{ title: 'By', key: 'by', render: (_: unknown, r: Record<string, unknown>) => (r.checkedInBy as Record<string, unknown>)?.name || 'System' },
|
||||
{ title: 'Time', dataIndex: 'checkedInAt', key: 'time', render: (d: string) => dayjs(d).format('MMM D, HH:mm:ss') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/events')}>Back</Button>
|
||||
<Title level={3} style={{ margin: 0 }}>{e.title as string}</Title>
|
||||
<Tag color={
|
||||
e.status === 'PUBLISHED' ? 'success' : e.status === 'DRAFT' ? 'default' : e.status === 'CANCELLED' ? 'error' : 'warning'
|
||||
}>{(e.status as string).replace('_', ' ')}</Tag>
|
||||
</Space>
|
||||
|
||||
<Descriptions bordered size="small" style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="Date">{dayjs(e.date as string).format('MMMM D, YYYY')}</Descriptions.Item>
|
||||
<Descriptions.Item label="Time">{e.startTime as string} – {e.endTime as string}</Descriptions.Item>
|
||||
<Descriptions.Item label="Format">{
|
||||
e.eventFormat === 'ONLINE' ? <Tag icon={<VideoCameraOutlined />} color="purple">Online</Tag>
|
||||
: e.eventFormat === 'HYBRID' ? <Tag color="geekblue"><EnvironmentOutlined /> + <VideoCameraOutlined /> Hybrid</Tag>
|
||||
: <Tag icon={<EnvironmentOutlined />}>In-Person</Tag>
|
||||
}</Descriptions.Item>
|
||||
<Descriptions.Item label="Venue">{(e.venueName as string) || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Visibility"><Tag>{e.visibility as string}</Tag></Descriptions.Item>
|
||||
<Descriptions.Item label="Slug">{e.slug as string}</Descriptions.Item>
|
||||
<Descriptions.Item label="Actions">
|
||||
<Space>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/event/${e.slug}`);
|
||||
message.success('Link copied');
|
||||
}}>Copy Link</Button>
|
||||
{e.status === 'PUBLISHED' && (
|
||||
<Button size="small" type="primary" icon={<ScanOutlined />}
|
||||
onClick={() => navigate(`/app/events/${id}/checkin`)}>
|
||||
Check-in Scanner
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* Meeting Room card for ONLINE/HYBRID events */}
|
||||
{(e.eventFormat === 'ONLINE' || e.eventFormat === 'HYBRID') && !!e.meeting && (
|
||||
<Card size="small" title={<><VideoCameraOutlined /> Meeting Room</>} style={{ marginBottom: 24 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Badge status={(e.meeting as Record<string, unknown>).isActive ? 'success' : 'default'}
|
||||
text={(e.meeting as Record<string, unknown>).isActive ? 'Active' : 'Inactive'} />
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<VideoCameraOutlined />}
|
||||
loading={joiningMeeting} onClick={handleJoinAsModerator}>
|
||||
Join as Moderator
|
||||
</Button>
|
||||
<Button icon={<LinkOutlined />} onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/event/${e.slug}#meeting`);
|
||||
message.success('Guest link copied');
|
||||
}}>
|
||||
Copy Guest Link
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{s && (
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><Statistic title="Tickets Sold" value={s.totalTickets as number} prefix={<TagOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><Statistic title="Revenue" value={(s.totalRevenue as number) / 100} prefix={<DollarOutlined />} precision={2} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><Statistic title="Checked In" value={s.checkedIn as number} prefix={<CheckCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><Statistic title="Capacity" value={e.maxAttendees ? `${e.currentAttendees}/${e.maxAttendees}` : `${e.currentAttendees}`} prefix={<UserOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{s && (s.tierStats as unknown[])?.length > 0 && (
|
||||
<Card size="small" title="Tier Breakdown" style={{ marginBottom: 24 }}>
|
||||
<Table
|
||||
dataSource={s.tierStats as Record<string, unknown>[]}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: 'Tier', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'Type', dataIndex: 'tierType', key: 'type', render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: 'Price', dataIndex: 'priceCAD', key: 'price', render: (v: number) => v > 0 ? `$${(v / 100).toFixed(2)}` : 'Free' },
|
||||
{ title: 'Sold', key: 'sold', render: (_: unknown, r: Record<string, unknown>) => {
|
||||
const max = r.maxQuantity as number | null;
|
||||
return max ? `${r.soldCount}/${max}` : String(r.soldCount);
|
||||
}},
|
||||
{ title: 'Active', dataIndex: 'isActive', key: 'active', render: (v: boolean) => v ? <Badge status="success" text="Yes" /> : <Badge status="default" text="No" /> },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs items={[
|
||||
{
|
||||
key: 'tickets',
|
||||
label: `Tickets (${ticketPagination.total})`,
|
||||
children: (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Search by name, email, or code..."
|
||||
prefix={<UserOutlined />}
|
||||
value={ticketSearch}
|
||||
onChange={e => setTicketSearch(e.target.value)}
|
||||
style={{ width: 300, marginBottom: 16 }}
|
||||
allowClear
|
||||
/>
|
||||
<Table
|
||||
dataSource={tickets}
|
||||
columns={ticketColumns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: ticketPagination.page,
|
||||
pageSize: ticketPagination.limit,
|
||||
total: ticketPagination.total,
|
||||
onChange: (page, pageSize) => setTicketPagination(p => ({ ...p, page, limit: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'checkins',
|
||||
label: `Check-ins (${checkIns.length})`,
|
||||
children: (
|
||||
<Table
|
||||
dataSource={checkIns}
|
||||
columns={checkInColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
513
admin/src/pages/events/TicketedEventsPage.tsx
Normal file
513
admin/src/pages/events/TicketedEventsPage.tsx
Normal file
@ -0,0 +1,513 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Typography, Table, Button, Space, Tag, Input, Select, message, Drawer, Form,
|
||||
DatePicker, TimePicker, InputNumber, Divider, Card, Row, Col, Popconfirm,
|
||||
Tooltip, Radio, Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined, SearchOutlined, EditOutlined, EyeOutlined, DeleteOutlined,
|
||||
CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, ScanOutlined,
|
||||
TagOutlined, VideoCameraOutlined, EnvironmentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface TicketTier {
|
||||
id: string;
|
||||
name: string;
|
||||
tierType: 'PAID' | 'FREE' | 'DONATION';
|
||||
priceCAD: number;
|
||||
maxQuantity: number | null;
|
||||
soldCount: number;
|
||||
maxPerOrder: number;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface TicketedEvent {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
doorsOpenTime: string | null;
|
||||
eventFormat: 'IN_PERSON' | 'ONLINE' | 'HYBRID';
|
||||
venueName: string | null;
|
||||
venueAddress: string | null;
|
||||
status: string;
|
||||
visibility: string;
|
||||
maxAttendees: number | null;
|
||||
currentAttendees: number;
|
||||
coverImageUrl: string | null;
|
||||
organizerName: string | null;
|
||||
ticketTiers: TicketTier[];
|
||||
_count: { tickets: number; checkIns: number };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: 'default',
|
||||
PENDING_APPROVAL: 'warning',
|
||||
PUBLISHED: 'success',
|
||||
CANCELLED: 'error',
|
||||
COMPLETED: 'blue',
|
||||
};
|
||||
|
||||
const VISIBILITY_COLORS: Record<string, string> = {
|
||||
PUBLIC: 'green',
|
||||
UNLISTED: 'orange',
|
||||
PRIVATE: 'red',
|
||||
};
|
||||
|
||||
export default function TicketedEventsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [events, setEvents] = useState<TicketedEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<TicketedEvent | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [enableMeet, setEnableMeet] = useState(false);
|
||||
const watchedFormat = Form.useWatch('eventFormat', form);
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
};
|
||||
if (search) params.search = search;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
|
||||
const { data } = await api.get('/api/ticketed-events/admin', { params });
|
||||
setEvents(data.events);
|
||||
setPagination(p => ({ ...p, total: data.pagination.total }));
|
||||
} catch {
|
||||
message.error('Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, search, statusFilter]);
|
||||
|
||||
useEffect(() => { fetchEvents(); }, [fetchEvents]);
|
||||
useEffect(() => {
|
||||
api.get('/api/settings').then(({ data }) => setEnableMeet(!!data.enableMeet)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingEvent(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
eventFormat: 'IN_PERSON',
|
||||
visibility: 'PUBLIC',
|
||||
tiers: [{ name: 'General Admission', tierType: 'FREE', priceCAD: 0, maxPerOrder: 10, sortOrder: 0 }],
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (event: TicketedEvent) => {
|
||||
setEditingEvent(event);
|
||||
form.setFieldsValue({
|
||||
...event,
|
||||
date: dayjs(event.date),
|
||||
startTime: dayjs(event.startTime, 'HH:mm'),
|
||||
endTime: dayjs(event.endTime, 'HH:mm'),
|
||||
doorsOpenTime: event.doorsOpenTime ? dayjs(event.doorsOpenTime, 'HH:mm') : undefined,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
date: values.date.format('YYYY-MM-DD'),
|
||||
startTime: values.startTime.format('HH:mm'),
|
||||
endTime: values.endTime.format('HH:mm'),
|
||||
doorsOpenTime: values.doorsOpenTime?.format('HH:mm') || undefined,
|
||||
};
|
||||
|
||||
if (editingEvent) {
|
||||
const { tiers, ...eventData } = payload;
|
||||
await api.put(`/api/ticketed-events/admin/${editingEvent.id}`, eventData);
|
||||
} else {
|
||||
await api.post('/api/ticketed-events/admin', payload);
|
||||
}
|
||||
|
||||
message.success(editingEvent ? 'Event updated' : 'Event created');
|
||||
setDrawerOpen(false);
|
||||
fetchEvents();
|
||||
} catch {
|
||||
message.error('Failed to save event');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/api/ticketed-events/admin/${id}/publish`);
|
||||
message.success('Event published');
|
||||
fetchEvents();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to publish';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/api/ticketed-events/admin/${id}/cancel`);
|
||||
message.success('Event cancelled');
|
||||
fetchEvents();
|
||||
} catch {
|
||||
message.error('Failed to cancel event');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/api/ticketed-events/admin/${id}`);
|
||||
message.success('Event deleted');
|
||||
fetchEvents();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to delete';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/api/ticketed-events/admin/${id}/approve`);
|
||||
message.success('Event approved and published');
|
||||
fetchEvents();
|
||||
} catch {
|
||||
message.error('Failed to approve event');
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = (slug: string) => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/event/${slug}`);
|
||||
message.success('Link copied');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (text: string, record: TicketedEvent) => (
|
||||
<a onClick={() => navigate(`/app/events/${record.id}`)}>{text}</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Date',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
render: (d: string, r: TicketedEvent) => (
|
||||
<span>{dayjs(d).format('MMM D, YYYY')} {r.startTime}</span>
|
||||
),
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: 'Format',
|
||||
dataIndex: 'eventFormat',
|
||||
key: 'format',
|
||||
responsive: ['md' as const],
|
||||
render: (f: string) => {
|
||||
if (f === 'ONLINE') return <Tag icon={<VideoCameraOutlined />} color="purple">Online</Tag>;
|
||||
if (f === 'HYBRID') return <Tag color="geekblue"><EnvironmentOutlined /> + <VideoCameraOutlined /></Tag>;
|
||||
return <Tag icon={<EnvironmentOutlined />}>In-Person</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Venue',
|
||||
dataIndex: 'venueName',
|
||||
key: 'venue',
|
||||
responsive: ['lg' as const],
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (s: string) => <Tag color={STATUS_COLORS[s]}>{s.replace('_', ' ')}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Visibility',
|
||||
dataIndex: 'visibility',
|
||||
key: 'visibility',
|
||||
responsive: ['md' as const],
|
||||
render: (v: string) => <Tag color={VISIBILITY_COLORS[v]}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Tickets',
|
||||
key: 'tickets',
|
||||
render: (_: unknown, r: TicketedEvent) => {
|
||||
const sold = r._count.tickets;
|
||||
const max = r.maxAttendees;
|
||||
return max ? `${sold} / ${max}` : `${sold}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: TicketedEvent) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="Edit"><Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} /></Tooltip>
|
||||
<Tooltip title="Detail"><Button size="small" icon={<EyeOutlined />} onClick={() => navigate(`/app/events/${record.id}`)} /></Tooltip>
|
||||
{record.status === 'DRAFT' && (
|
||||
<Tooltip title="Publish"><Button size="small" type="primary" icon={<CheckCircleOutlined />} onClick={() => handlePublish(record.id)} /></Tooltip>
|
||||
)}
|
||||
{record.status === 'PENDING_APPROVAL' && (
|
||||
<Tooltip title="Approve"><Button size="small" type="primary" icon={<CheckCircleOutlined />} onClick={() => handleApprove(record.id)} /></Tooltip>
|
||||
)}
|
||||
{record.status === 'PUBLISHED' && (
|
||||
<>
|
||||
<Tooltip title="Copy Link"><Button size="small" icon={<CopyOutlined />} onClick={() => copyLink(record.slug)} /></Tooltip>
|
||||
<Tooltip title="Check-in Scanner"><Button size="small" icon={<ScanOutlined />} onClick={() => navigate(`/app/events/${record.id}/checkin`)} /></Tooltip>
|
||||
<Popconfirm title="Cancel this event?" onConfirm={() => handleCancel(record.id)}>
|
||||
<Tooltip title="Cancel"><Button size="small" danger icon={<CloseCircleOutlined />} /></Tooltip>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'DRAFT' && (
|
||||
<Popconfirm title="Delete this draft?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Tooltip title="Delete"><Button size="small" danger icon={<DeleteOutlined />} /></Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
<TagOutlined style={{ marginRight: 8 }} />
|
||||
Ticketed Events
|
||||
</Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
Create Event
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input
|
||||
placeholder="Search events..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="Filter by status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
options={[
|
||||
{ value: 'DRAFT', label: 'Draft' },
|
||||
{ value: 'PENDING_APPROVAL', label: 'Pending Approval' },
|
||||
{ value: 'PUBLISHED', label: 'Published' },
|
||||
{ value: 'CANCELLED', label: 'Cancelled' },
|
||||
{ value: 'COMPLETED', label: 'Completed' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
dataSource={events}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, pageSize) => setPagination(p => ({ ...p, page, limit: pageSize })),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={editingEvent ? 'Edit Event' : 'Create Event'}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={640}
|
||||
extra={
|
||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||
{editingEvent ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="date" label="Date" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="startTime" label="Start Time" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="endTime" label="End Time" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="doorsOpenTime" label="Doors Open Time">
|
||||
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>Event Format</Divider>
|
||||
<Form.Item name="eventFormat" label="Format" initialValue="IN_PERSON">
|
||||
<Radio.Group>
|
||||
<Radio.Button value="IN_PERSON"><EnvironmentOutlined /> In-Person</Radio.Button>
|
||||
<Tooltip title={!enableMeet ? 'Enable Jitsi Meet in Settings first' : undefined}>
|
||||
<Radio.Button value="ONLINE" disabled={!enableMeet}><VideoCameraOutlined /> Online</Radio.Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={!enableMeet ? 'Enable Jitsi Meet in Settings first' : undefined}>
|
||||
<Radio.Button value="HYBRID" disabled={!enableMeet}><EnvironmentOutlined /> + <VideoCameraOutlined /> Hybrid</Radio.Button>
|
||||
</Tooltip>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{watchedFormat === 'ONLINE' && (
|
||||
<Alert type="info" message="A meeting room will be auto-created when you save this event." showIcon style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
{watchedFormat === 'HYBRID' && (
|
||||
<Alert type="info" message="A meeting room will be auto-created alongside your venue." showIcon style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
{watchedFormat !== 'ONLINE' && (
|
||||
<>
|
||||
<Divider>Venue</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="venueName" label="Venue Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="venueAddress" label="Venue Address">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider>Settings</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="visibility" label="Visibility" initialValue="PUBLIC">
|
||||
<Select options={[
|
||||
{ value: 'PUBLIC', label: 'Public' },
|
||||
{ value: 'UNLISTED', label: 'Unlisted' },
|
||||
{ value: 'PRIVATE', label: 'Private (Invite Only)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="maxAttendees" label="Max Attendees">
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="Unlimited" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="organizerName" label="Organizer Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="organizerEmail" label="Organizer Email">
|
||||
<Input type="email" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="coverImageUrl" label="Cover Image URL">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
|
||||
{!editingEvent && (
|
||||
<>
|
||||
<Divider>Ticket Tiers</Divider>
|
||||
<Form.List name="tiers">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...rest }) => (
|
||||
<Card key={key} size="small" style={{ marginBottom: 12 }}
|
||||
extra={fields.length > 1 && <Button size="small" danger onClick={() => remove(name)}>Remove</Button>}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item {...rest} name={[name, 'name']} label="Tier Name" rules={[{ required: true }]}>
|
||||
<Input placeholder="e.g. General Admission" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item {...rest} name={[name, 'tierType']} label="Type" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ value: 'FREE', label: 'Free' },
|
||||
{ value: 'PAID', label: 'Paid' },
|
||||
{ value: 'DONATION', label: 'Donation' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Form.Item {...rest} name={[name, 'priceCAD']} label="Price (cents)">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item {...rest} name={[name, 'maxQuantity']} label="Max Quantity">
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="Unlimited" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item {...rest} name={[name, 'maxPerOrder']} label="Max Per Order" initialValue={10}>
|
||||
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ tierType: 'FREE', priceCAD: 0, maxPerOrder: 10, sortOrder: fields.length })} block icon={<PlusOutlined />}>
|
||||
Add Tier
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
347
admin/src/pages/influence/ImpactStoriesPage.tsx
Normal file
347
admin/src/pages/influence/ImpactStoriesPage.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
Tabs,
|
||||
message,
|
||||
Popconfirm,
|
||||
InputNumber,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CheckCircleOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Campaign {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ImpactStory {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
type: 'MILESTONE' | 'VICTORY' | 'RESPONSE' | 'CUSTOM';
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
title: string;
|
||||
body: string;
|
||||
coverImageUrl: string | null;
|
||||
milestoneValue: number | null;
|
||||
milestoneMetric: string | null;
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
campaign: { title: string };
|
||||
createdBy: { name: string | null; email: string } | null;
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
MILESTONE: 'gold',
|
||||
VICTORY: 'green',
|
||||
RESPONSE: 'blue',
|
||||
CUSTOM: 'default',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'default',
|
||||
PUBLISHED: 'green',
|
||||
ARCHIVED: 'red',
|
||||
};
|
||||
|
||||
export default function ImpactStoriesPage() {
|
||||
const [stories, setStories] = useState<ImpactStory[]>([]);
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });
|
||||
const [selectedCampaignId, setSelectedCampaignId] = useState<string | undefined>();
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingStory, setEditingStory] = useState<ImpactStory | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchCampaigns = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/influence/campaigns', { params: { limit: 100 } });
|
||||
setCampaigns(data.campaigns || []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStories = useCallback(async (page = 1, limit = 20) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, limit };
|
||||
if (selectedCampaignId) params.campaignId = selectedCampaignId;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
const { data } = await api.get('/social/stories', { params });
|
||||
setStories(data.stories || []);
|
||||
setPagination({
|
||||
current: data.pagination?.page || 1,
|
||||
pageSize: data.pagination?.limit || 20,
|
||||
total: data.pagination?.total || 0,
|
||||
});
|
||||
} catch {
|
||||
message.error('Failed to load stories');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedCampaignId, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns();
|
||||
}, [fetchCampaigns]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStories();
|
||||
}, [fetchStories]);
|
||||
|
||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||
fetchStories(pag.current, pag.pageSize);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingStory(null);
|
||||
form.resetFields();
|
||||
if (selectedCampaignId) {
|
||||
form.setFieldsValue({ campaignId: selectedCampaignId });
|
||||
}
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (story: ImpactStory) => {
|
||||
setEditingStory(story);
|
||||
form.setFieldsValue({
|
||||
campaignId: story.campaignId,
|
||||
type: story.type,
|
||||
title: story.title,
|
||||
body: story.body,
|
||||
coverImageUrl: story.coverImageUrl || undefined,
|
||||
milestoneValue: story.milestoneValue,
|
||||
milestoneMetric: story.milestoneMetric,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editingStory) {
|
||||
await api.put(`/social/stories/${editingStory.id}`, values);
|
||||
message.success('Story updated');
|
||||
} else {
|
||||
await api.post('/social/stories', values);
|
||||
message.success('Story created');
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchStories(pagination.current, pagination.pageSize);
|
||||
} catch (err: any) {
|
||||
if (err?.response?.data?.error?.message) {
|
||||
message.error(err.response.data.error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/social/stories/${id}/publish`);
|
||||
message.success('Story published and participants notified');
|
||||
fetchStories(pagination.current, pagination.pageSize);
|
||||
} catch {
|
||||
message.error('Failed to publish story');
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/social/stories/${id}/archive`);
|
||||
message.success('Story archived');
|
||||
fetchStories(pagination.current, pagination.pageSize);
|
||||
} catch {
|
||||
message.error('Failed to archive story');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/social/stories/${id}`);
|
||||
message.success('Story deleted');
|
||||
fetchStories(pagination.current, pagination.pageSize);
|
||||
} catch {
|
||||
message.error('Failed to delete story');
|
||||
}
|
||||
};
|
||||
|
||||
const storyType = Form.useWatch('type', form);
|
||||
|
||||
const columns: ColumnsType<ImpactStory> = [
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Campaign',
|
||||
dataIndex: ['campaign', 'title'],
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
width: 120,
|
||||
render: (type: string) => <Tag color={typeColors[type]}>{type}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (status: string) => <Tag color={statusColors[status]}>{status}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Milestone',
|
||||
dataIndex: 'milestoneValue',
|
||||
width: 100,
|
||||
render: (val: number | null) => val ? <Tag color="gold">{val}</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: 'Published',
|
||||
dataIndex: 'publishedAt',
|
||||
width: 140,
|
||||
render: (date: string | null) => date ? dayjs(date).format('MMM D, YYYY') : '-',
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: ImpactStory) => (
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
{record.status === 'DRAFT' && (
|
||||
<Popconfirm
|
||||
title="Publish this story?"
|
||||
description="This will notify campaign participants."
|
||||
onConfirm={() => handlePublish(record.id)}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<CheckCircleOutlined />} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
{record.status === 'PUBLISHED' && (
|
||||
<Popconfirm title="Archive this story?" onConfirm={() => handleArchive(record.id)}>
|
||||
<Button size="small" icon={<StopOutlined />} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm title="Delete this story?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'DRAFT', label: 'Draft' },
|
||||
{ key: 'PUBLISHED', label: 'Published' },
|
||||
{ key: 'ARCHIVED', label: 'Archived' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="Filter by campaign"
|
||||
allowClear
|
||||
style={{ width: 260 }}
|
||||
value={selectedCampaignId}
|
||||
onChange={(val) => setSelectedCampaignId(val)}
|
||||
options={campaigns.map((c) => ({ value: c.id, label: c.title }))}
|
||||
/>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
New Story
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={statusFilter || 'all'}
|
||||
onChange={(key) => setStatusFilter(key === 'all' ? undefined : key)}
|
||||
items={tabItems}
|
||||
/>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={stories}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${total} stories`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingStory ? 'Edit Story' : 'Create Story'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSave}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="campaignId" label="Campaign" rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder="Select campaign"
|
||||
options={campaigns.map((c) => ({ value: c.id, label: c.title }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="Type" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'MILESTONE', label: 'Milestone' },
|
||||
{ value: 'VICTORY', label: 'Victory' },
|
||||
{ value: 'RESPONSE', label: 'Response' },
|
||||
{ value: 'CUSTOM', label: 'Custom' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="Title" rules={[{ required: true, max: 200 }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="body" label="Body" rules={[{ required: true, max: 5000 }]}>
|
||||
<TextArea rows={5} />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverImageUrl" label="Cover Image URL">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
{storyType === 'MILESTONE' && (
|
||||
<>
|
||||
<Form.Item name="milestoneValue" label="Milestone Value">
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="milestoneMetric" label="Milestone Metric">
|
||||
<Input placeholder="e.g. emails_sent" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -45,6 +45,7 @@ import { usePostalCode } from '@/hooks/usePostalCode';
|
||||
import RelatedContent from '@/components/public/RelatedContent';
|
||||
import { VideoPlayer } from '@/components/media/VideoPlayer';
|
||||
import FriendsCampaignBadge from '@/components/social/FriendsCampaignBadge';
|
||||
import CampaignCelebration from '@/components/social/CampaignCelebration';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
@ -283,6 +284,9 @@ export default function CampaignPage() {
|
||||
{campaign?.id && <FriendsCampaignBadge campaignId={campaign.id} />}
|
||||
</div>
|
||||
|
||||
{/* Campaign Milestones / Impact Stories */}
|
||||
{campaign?.id && <CampaignCelebration campaignId={campaign.id} />}
|
||||
|
||||
{/* Cover Video */}
|
||||
{campaign.coverVideoId && siteSettings?.enableMediaFeatures !== false && (
|
||||
<div style={{ marginBottom: 24, borderRadius: 12, overflow: 'hidden' }}>
|
||||
|
||||
@ -88,6 +88,10 @@ export default function EventsPage() {
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#52c41a' }} />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>Events</Text>
|
||||
</Space>
|
||||
<Space size={4}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#722ed1' }} />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>Ticketed</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* Submit button — opens side panel with form for tomorrow */}
|
||||
|
||||
199
admin/src/pages/public/TicketConfirmationPage.tsx
Normal file
199
admin/src/pages/public/TicketConfirmationPage.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography, Card, Button, Spin, Result, Space, Tag, Grid, Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined,
|
||||
QrcodeOutlined, CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const apiBase = '/api';
|
||||
|
||||
interface TicketDetail {
|
||||
id: string;
|
||||
ticketCode: string;
|
||||
holderEmail: string;
|
||||
holderName: string | null;
|
||||
status: string;
|
||||
tier: { name: string; tierType: string; priceCAD: number };
|
||||
event: {
|
||||
title: string;
|
||||
slug: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
doorsOpenTime: string | null;
|
||||
venueName: string | null;
|
||||
venueAddress: string | null;
|
||||
};
|
||||
qrUrl: string;
|
||||
}
|
||||
|
||||
export default function TicketConfirmationPage() {
|
||||
const { slug, ticketCode } = useParams<{ slug: string; ticketCode: string }>();
|
||||
const navigate = useNavigate();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [ticket, setTicket] = useState<TicketDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug || !ticketCode) return;
|
||||
fetchTicket();
|
||||
}, [slug, ticketCode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchTicket = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await axios.get(`${apiBase}/ticketed-events/${slug}/ticket/${ticketCode}`);
|
||||
setTicket(data);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
setError(status === 404 ? 'Ticket not found' : 'Failed to load ticket');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateIcsUrl = () => {
|
||||
if (!ticket) return '';
|
||||
const e = ticket.event;
|
||||
const dateStr = dayjs(e.date).format('YYYYMMDD');
|
||||
const start = `${dateStr}T${e.startTime.replace(':', '')}00`;
|
||||
const end = `${dateStr}T${e.endTime.replace(':', '')}00`;
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'BEGIN:VEVENT',
|
||||
`DTSTART:${start}`,
|
||||
`DTEND:${end}`,
|
||||
`SUMMARY:${e.title}`,
|
||||
e.venueName ? `LOCATION:${e.venueName}${e.venueAddress ? ' - ' + e.venueAddress : ''}` : '',
|
||||
`DESCRIPTION:Ticket: ${ticket.ticketCode}`,
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].filter(Boolean).join('\r\n');
|
||||
|
||||
return `data:text/calendar;charset=utf-8,${encodeURIComponent(ics)}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !ticket) {
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title={error || 'Ticket Not Found'}
|
||||
extra={<Button onClick={() => navigate(`/event/${slug}`)}>View Event</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
VALID: 'green',
|
||||
CHECKED_IN: 'blue',
|
||||
CANCELLED: 'red',
|
||||
REFUNDED: 'orange',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
maxWidth: 500,
|
||||
margin: '0 auto',
|
||||
padding: isMobile ? '16px 8px' : '24px 16px',
|
||||
}}>
|
||||
<Card style={{ textAlign: 'center' }}>
|
||||
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
|
||||
<Title level={3} style={{ marginBottom: 4 }}>Your Ticket</Title>
|
||||
<Text type="secondary">{ticket.event.title}</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* QR Code */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<img
|
||||
src={ticket.qrUrl}
|
||||
alt={`QR Code for ${ticket.ticketCode}`}
|
||||
style={{ width: 250, height: 250 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ticket code */}
|
||||
<Title level={4} style={{ fontFamily: 'monospace', letterSpacing: 2, marginBottom: 4 }}>
|
||||
{ticket.ticketCode}
|
||||
</Title>
|
||||
<Tag color={statusColors[ticket.status]}>{ticket.status}</Tag>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Event details */}
|
||||
<Space direction="vertical" style={{ width: '100%', textAlign: 'left' }}>
|
||||
<div>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
<Text>{dayjs(ticket.event.date).format('MMMM D, YYYY')}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<ClockCircleOutlined style={{ marginRight: 8 }} />
|
||||
<Text>
|
||||
{ticket.event.startTime} – {ticket.event.endTime}
|
||||
{ticket.event.doorsOpenTime && ` (Doors: ${ticket.event.doorsOpenTime})`}
|
||||
</Text>
|
||||
</div>
|
||||
{ticket.event.venueName && (
|
||||
<div>
|
||||
<EnvironmentOutlined style={{ marginRight: 8 }} />
|
||||
<Text>{ticket.event.venueName}</Text>
|
||||
{ticket.event.venueAddress && (
|
||||
<Text type="secondary" style={{ display: 'block', marginLeft: 22 }}>
|
||||
{ticket.event.venueAddress}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<QrcodeOutlined style={{ marginRight: 8 }} />
|
||||
<Text>{ticket.tier.name}</Text>
|
||||
{ticket.tier.tierType === 'PAID' && (
|
||||
<Text type="secondary"> — ${(ticket.tier.priceCAD / 100).toFixed(2)}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Holder info */}
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
{ticket.holderName || ticket.holderEmail}
|
||||
</Text>
|
||||
|
||||
{/* Actions */}
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
href={generateIcsUrl()}
|
||||
download={`${ticket.ticketCode}.ics`}
|
||||
icon={<CalendarOutlined />}
|
||||
>
|
||||
Add to Calendar
|
||||
</Button>
|
||||
<Button block onClick={() => navigate(`/event/${ticket.event.slug}`)}>
|
||||
View Event
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
435
admin/src/pages/public/TicketedEventDetailPage.tsx
Normal file
435
admin/src/pages/public/TicketedEventDetailPage.tsx
Normal file
@ -0,0 +1,435 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography, Card, Button, Row, Col, Tag, Spin, Result, Grid, Space,
|
||||
Input, message, Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined,
|
||||
TagOutlined, UserOutlined, LockOutlined, VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const apiBase = '/api';
|
||||
|
||||
interface TicketTier {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tierType: 'PAID' | 'FREE' | 'DONATION';
|
||||
priceCAD: number;
|
||||
minDonationCAD: number | null;
|
||||
maxQuantity: number | null;
|
||||
soldCount: number;
|
||||
maxPerOrder: number;
|
||||
isActive: boolean;
|
||||
salesStartAt: string | null;
|
||||
salesEndAt: string | null;
|
||||
}
|
||||
|
||||
interface EventDetail {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
richDescription: string | null;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
doorsOpenTime: string | null;
|
||||
eventFormat: 'IN_PERSON' | 'ONLINE' | 'HYBRID';
|
||||
hasMeeting: boolean;
|
||||
venueName: string | null;
|
||||
venueAddress: string | null;
|
||||
coverImageUrl: string | null;
|
||||
maxAttendees: number | null;
|
||||
currentAttendees: number;
|
||||
organizerName: string | null;
|
||||
visibility: string;
|
||||
ticketTiers: TicketTier[];
|
||||
}
|
||||
|
||||
export default function TicketedEventDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
|
||||
const [event, setEvent] = useState<EventDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [inviteCode, setInviteCode] = useState(searchParams.get('code') || '');
|
||||
const [needsInviteCode, setNeedsInviteCode] = useState(false);
|
||||
const [checkingOut, setCheckingOut] = useState<string | null>(null);
|
||||
|
||||
// Guest info for non-authenticated users
|
||||
const [guestEmail, setGuestEmail] = useState('');
|
||||
const [guestName, setGuestName] = useState('');
|
||||
|
||||
// Meeting access
|
||||
const [meetingTicketCode, setMeetingTicketCode] = useState('');
|
||||
const [meetingAccess, setMeetingAccess] = useState<{ jitsiRoom: string; domain: string; eventTitle: string } | null>(null);
|
||||
const [meetingLoading, setMeetingLoading] = useState(false);
|
||||
const [meetingError, setMeetingError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
fetchEvent();
|
||||
}, [slug]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchEvent = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (inviteCode) params.inviteCode = inviteCode;
|
||||
const { data } = await axios.get(`${apiBase}/ticketed-events/${slug}`, { params });
|
||||
if (data.requiresInviteCode) {
|
||||
setNeedsInviteCode(true);
|
||||
setEvent(null);
|
||||
} else {
|
||||
setEvent(data);
|
||||
setNeedsInviteCode(false);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 404) {
|
||||
setEvent(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = async (tier: TicketTier) => {
|
||||
const email = isAuthenticated ? user?.email : guestEmail.trim();
|
||||
const name = isAuthenticated ? user?.name : guestName.trim();
|
||||
|
||||
if (!email) {
|
||||
message.warning('Please enter your email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingOut(tier.id);
|
||||
try {
|
||||
if (tier.tierType === 'FREE') {
|
||||
await axios.post(`${apiBase}/ticketed-events/${slug}/register`, {
|
||||
tierId: tier.id,
|
||||
holderEmail: email,
|
||||
holderName: name || undefined,
|
||||
});
|
||||
message.success('Registration successful! Check your email for your ticket.');
|
||||
fetchEvent();
|
||||
} else {
|
||||
const { data } = await axios.post(`${apiBase}/ticketed-events/${slug}/checkout`, {
|
||||
tierId: tier.id,
|
||||
quantity: 1,
|
||||
buyerEmail: email,
|
||||
buyerName: name || undefined,
|
||||
});
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to process';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setCheckingOut(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isTierAvailable = (tier: TicketTier) => {
|
||||
if (!tier.isActive) return false;
|
||||
if (tier.maxQuantity && tier.soldCount >= tier.maxQuantity) return false;
|
||||
if (tier.salesStartAt && dayjs().isBefore(dayjs(tier.salesStartAt))) return false;
|
||||
if (tier.salesEndAt && dayjs().isAfter(dayjs(tier.salesEndAt))) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isSoldOut = event?.maxAttendees
|
||||
? event.currentAttendees >= event.maxAttendees
|
||||
: false;
|
||||
|
||||
const handleGetMeetingLink = async () => {
|
||||
if (!meetingTicketCode.trim()) {
|
||||
setMeetingError('Please enter your ticket code');
|
||||
return;
|
||||
}
|
||||
setMeetingLoading(true);
|
||||
setMeetingError('');
|
||||
setMeetingAccess(null);
|
||||
try {
|
||||
const { data } = await axios.get(`${apiBase}/ticketed-events/${slug}/meeting-access`, {
|
||||
params: { ticketCode: meetingTicketCode.trim() },
|
||||
});
|
||||
setMeetingAccess(data);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Invalid ticket code';
|
||||
setMeetingError(msg);
|
||||
} finally {
|
||||
setMeetingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsInviteCode) {
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '0 auto', padding: isMobile ? '24px 16px' : 48 }}>
|
||||
<Result
|
||||
icon={<LockOutlined style={{ color: '#faad14' }} />}
|
||||
title="Private Event"
|
||||
subTitle="This event requires an invite code to view."
|
||||
/>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Enter invite code"
|
||||
value={inviteCode}
|
||||
onChange={e => setInviteCode(e.target.value)}
|
||||
onPressEnter={fetchEvent}
|
||||
/>
|
||||
<Button type="primary" onClick={fetchEvent}>Submit</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="Event Not Found"
|
||||
subTitle="This event may have been removed or doesn't exist."
|
||||
extra={<Button onClick={() => navigate('/events')}>Browse Events</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: isMobile ? '16px 8px' : '24px 16px' }}>
|
||||
{/* Cover image */}
|
||||
{event.coverImageUrl && (
|
||||
<div style={{
|
||||
marginBottom: 24,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
maxHeight: 400,
|
||||
}}>
|
||||
<img
|
||||
src={event.coverImageUrl}
|
||||
alt={event.title}
|
||||
style={{ width: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + date */}
|
||||
<Title level={2} style={{ marginBottom: 8 }}>{event.title}</Title>
|
||||
|
||||
<Space wrap style={{ marginBottom: 24 }}>
|
||||
<Tag icon={<CalendarOutlined />} color="blue">
|
||||
{dayjs(event.date).format('MMMM D, YYYY')}
|
||||
</Tag>
|
||||
<Tag icon={<ClockCircleOutlined />}>
|
||||
{event.startTime} – {event.endTime}
|
||||
{event.doorsOpenTime && ` (Doors: ${event.doorsOpenTime})`}
|
||||
</Tag>
|
||||
{event.eventFormat === 'ONLINE' ? (
|
||||
<Tag icon={<VideoCameraOutlined />} color="purple">Online Event</Tag>
|
||||
) : event.eventFormat === 'HYBRID' ? (
|
||||
<>
|
||||
{event.venueName && <Tag icon={<EnvironmentOutlined />} color="green">{event.venueName}</Tag>}
|
||||
<Tag icon={<VideoCameraOutlined />} color="purple">Also Online</Tag>
|
||||
</>
|
||||
) : (
|
||||
event.venueName && <Tag icon={<EnvironmentOutlined />} color="green">{event.venueName}</Tag>
|
||||
)}
|
||||
{event.maxAttendees && (
|
||||
<Tag icon={<UserOutlined />}>
|
||||
{event.currentAttendees}/{event.maxAttendees} attendees
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{/* Description */}
|
||||
{event.richDescription ? (
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: event.richDescription }} />
|
||||
</Card>
|
||||
) : event.description ? (
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Paragraph>{event.description}</Paragraph>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Venue details */}
|
||||
{event.venueAddress && (
|
||||
<Card size="small" style={{ marginBottom: 24 }}>
|
||||
<Space>
|
||||
<EnvironmentOutlined />
|
||||
<div>
|
||||
<Text strong>{event.venueName}</Text>
|
||||
<br />
|
||||
<Text type="secondary">{event.venueAddress}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Organizer */}
|
||||
{event.organizerName && (
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
|
||||
Organized by {event.organizerName}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Ticket tiers */}
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<TagOutlined /> Tickets
|
||||
</Title>
|
||||
|
||||
{isSoldOut && (
|
||||
<Result
|
||||
status="warning"
|
||||
title="Sold Out"
|
||||
subTitle="This event has reached capacity."
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Guest info form for non-authenticated users */}
|
||||
{!isAuthenticated && !isSoldOut && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
Enter your details to get tickets:
|
||||
</Text>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
placeholder="Email *"
|
||||
value={guestEmail}
|
||||
onChange={e => setGuestEmail(e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
placeholder="Name (optional)"
|
||||
value={guestName}
|
||||
onChange={e => setGuestName(e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{event.ticketTiers.map(tier => {
|
||||
const available = isTierAvailable(tier) && !isSoldOut;
|
||||
const remaining = tier.maxQuantity ? tier.maxQuantity - tier.soldCount : null;
|
||||
|
||||
return (
|
||||
<Col xs={24} sm={12} key={tier.id}>
|
||||
<Card
|
||||
style={{
|
||||
height: '100%',
|
||||
opacity: available ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<Title level={5} style={{ marginBottom: 4 }}>{tier.name}</Title>
|
||||
{tier.description && (
|
||||
<Paragraph type="secondary" style={{ fontSize: 13 }}>{tier.description}</Paragraph>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{tier.tierType === 'FREE' ? (
|
||||
<Tag color="green">Free</Tag>
|
||||
) : tier.tierType === 'DONATION' ? (
|
||||
<Tag color="orange">
|
||||
Donation {tier.minDonationCAD ? `(min $${(tier.minDonationCAD / 100).toFixed(2)})` : ''}
|
||||
</Tag>
|
||||
) : (
|
||||
<Text strong style={{ fontSize: 18 }}>
|
||||
${(tier.priceCAD / 100).toFixed(2)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{remaining !== null && (
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8, fontSize: 12 }}>
|
||||
{remaining > 0 ? `${remaining} remaining` : 'Sold out'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
disabled={!available}
|
||||
loading={checkingOut === tier.id}
|
||||
onClick={() => handleCheckout(tier)}
|
||||
>
|
||||
{!available
|
||||
? (remaining === 0 ? 'Sold Out' : 'Unavailable')
|
||||
: tier.tierType === 'FREE'
|
||||
? 'Register Free'
|
||||
: `Get Tickets — $${(tier.priceCAD / 100).toFixed(2)}`
|
||||
}
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* Online Access card for ONLINE/HYBRID events */}
|
||||
{event.eventFormat !== 'IN_PERSON' && event.hasMeeting && (
|
||||
<>
|
||||
<Divider />
|
||||
<Card
|
||||
title={<><VideoCameraOutlined style={{ marginRight: 8 }} />Online Access</>}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Paragraph type="secondary">
|
||||
Enter your ticket code to get your meeting link.
|
||||
</Paragraph>
|
||||
<Space.Compact style={{ width: '100%', maxWidth: 400, marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="Enter ticket code"
|
||||
value={meetingTicketCode}
|
||||
onChange={e => setMeetingTicketCode(e.target.value)}
|
||||
onPressEnter={handleGetMeetingLink}
|
||||
/>
|
||||
<Button type="primary" onClick={handleGetMeetingLink} loading={meetingLoading}>
|
||||
Get Link
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{meetingError && (
|
||||
<Text type="danger" style={{ display: 'block', marginBottom: 12 }}>{meetingError}</Text>
|
||||
)}
|
||||
{meetingAccess && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
style={{ maxWidth: 400 }}
|
||||
icon={<VideoCameraOutlined />}
|
||||
onClick={() => window.open(`https://${meetingAccess.domain}/${meetingAccess.jitsiRoom}`, '_blank')}
|
||||
>
|
||||
Join Meeting
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
admin/src/pages/public/WallOfFamePage.tsx
Normal file
247
admin/src/pages/public/WallOfFamePage.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Typography, Row, Col, Spin, Empty, Tabs, Pagination, Grid, message, theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
StarFilled, TrophyOutlined, EnvironmentOutlined, CalendarOutlined, MailOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import SpotlightCard from '@/components/social/SpotlightCard';
|
||||
import PublicLeaderboard from '@/components/social/PublicLeaderboard';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface FeaturedSpotlight {
|
||||
id: string;
|
||||
userId: string;
|
||||
headline: string | null;
|
||||
story: string | null;
|
||||
featuredMonth: string | null;
|
||||
user: { id: string; name: string | null };
|
||||
}
|
||||
|
||||
export default function WallOfFamePage() {
|
||||
const { token: themeToken } = theme.useToken();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { user } = useAuthStore();
|
||||
const isLoggedIn = !!user;
|
||||
|
||||
const [featured, setFeatured] = useState<FeaturedSpotlight[]>([]);
|
||||
const [wallOfFame, setWallOfFame] = useState<FeaturedSpotlight[]>([]);
|
||||
const [wallPage, setWallPage] = useState(1);
|
||||
const [wallTotal, setWallTotal] = useState(0);
|
||||
const [loadingFeatured, setLoadingFeatured] = useState(true);
|
||||
const [loadingWall, setLoadingWall] = useState(true);
|
||||
const [optedIn, setOptedIn] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('canvass');
|
||||
|
||||
const fetchFeatured = useCallback(async () => {
|
||||
try {
|
||||
setLoadingFeatured(true);
|
||||
const { data } = await api.get('/social/spotlight/featured');
|
||||
setFeatured(data.spotlights || []);
|
||||
} catch {
|
||||
// Silently handle auth errors for unauthenticated users
|
||||
} finally {
|
||||
setLoadingFeatured(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchWallOfFame = useCallback(async (page: number) => {
|
||||
try {
|
||||
setLoadingWall(true);
|
||||
const { data } = await api.get('/social/spotlight/wall-of-fame', {
|
||||
params: { page, limit: 12 },
|
||||
});
|
||||
setWallOfFame(data.spotlights || []);
|
||||
setWallTotal(data.pagination?.total || 0);
|
||||
} catch {
|
||||
// Silently handle
|
||||
} finally {
|
||||
setLoadingWall(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchOptInStatus = useCallback(async () => {
|
||||
if (!isLoggedIn) return;
|
||||
try {
|
||||
const { data } = await api.get('/social/spotlight/opt-in-status');
|
||||
setOptedIn(data.showOnLeaderboard ?? true);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeatured();
|
||||
fetchWallOfFame(1);
|
||||
fetchOptInStatus();
|
||||
}, [fetchFeatured, fetchWallOfFame, fetchOptInStatus]);
|
||||
|
||||
const handleOptInChange = async (value: boolean) => {
|
||||
try {
|
||||
await api.post(value ? '/social/spotlight/opt-in' : '/social/spotlight/opt-out');
|
||||
setOptedIn(value);
|
||||
message.success(value ? 'You will appear on the leaderboard' : 'You have been removed from the leaderboard');
|
||||
} catch {
|
||||
message.error('Failed to update preference');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWallPageChange = (page: number) => {
|
||||
setWallPage(page);
|
||||
fetchWallOfFame(page);
|
||||
};
|
||||
|
||||
const leaderboardTabs = [
|
||||
{
|
||||
key: 'canvass',
|
||||
label: (
|
||||
<span><EnvironmentOutlined style={{ marginRight: 4 }} />Canvass</span>
|
||||
),
|
||||
children: (
|
||||
<PublicLeaderboard
|
||||
type="canvass"
|
||||
showOptIn={isLoggedIn}
|
||||
optedIn={optedIn}
|
||||
onOptInChange={handleOptInChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'shifts',
|
||||
label: (
|
||||
<span><CalendarOutlined style={{ marginRight: 4 }} />Shifts</span>
|
||||
),
|
||||
children: (
|
||||
<PublicLeaderboard
|
||||
type="shifts"
|
||||
showOptIn={isLoggedIn}
|
||||
optedIn={optedIn}
|
||||
onOptInChange={handleOptInChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'campaigns',
|
||||
label: (
|
||||
<span><MailOutlined style={{ marginRight: 4 }} />Campaigns</span>
|
||||
),
|
||||
children: (
|
||||
<PublicLeaderboard
|
||||
type="campaigns"
|
||||
showOptIn={isLoggedIn}
|
||||
optedIn={optedIn}
|
||||
onOptInChange={handleOptInChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1000, margin: '0 auto', padding: isMobile ? '16px' : '24px 16px' }}>
|
||||
{/* Featured Volunteers Section */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<StarFilled style={{ fontSize: 32, color: '#d4a017', marginBottom: 8 }} />
|
||||
<Title level={2} style={{ margin: 0 }}>Volunteer Spotlight</Title>
|
||||
<Text type="secondary">Recognizing the outstanding volunteers making a difference</Text>
|
||||
</div>
|
||||
|
||||
{loadingFeatured ? (
|
||||
<Spin style={{ display: 'block', margin: '24px auto' }} />
|
||||
) : featured.length > 0 ? (
|
||||
<div style={{ marginBottom: 40 }}>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<StarFilled style={{ color: '#d4a017', marginRight: 8 }} />
|
||||
This Month's Featured Volunteers
|
||||
</Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{featured.map((s) => (
|
||||
<Col key={s.id} xs={24} md={12}>
|
||||
<SpotlightCard
|
||||
spotlight={{
|
||||
id: s.id,
|
||||
userId: s.userId,
|
||||
userName: s.user?.name,
|
||||
headline: s.headline,
|
||||
story: s.story,
|
||||
featuredMonth: s.featuredMonth,
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
description="No featured volunteers this month"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ marginBottom: 40 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Leaderboard Section */}
|
||||
<div style={{
|
||||
background: themeToken.colorBgContainer,
|
||||
borderRadius: 12,
|
||||
padding: isMobile ? 16 : 24,
|
||||
marginBottom: 40,
|
||||
}}>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<TrophyOutlined style={{ color: '#d4a017', marginRight: 8 }} />
|
||||
Leaderboard
|
||||
</Title>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={leaderboardTabs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Wall of Fame Section */}
|
||||
{wallTotal > 0 && (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<StarFilled style={{ color: '#d4a017', marginRight: 8 }} />
|
||||
Wall of Fame
|
||||
</Title>
|
||||
{loadingWall ? (
|
||||
<Spin style={{ display: 'block', margin: '24px auto' }} />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
{wallOfFame.map((s) => (
|
||||
<Col key={s.id} xs={24} md={12}>
|
||||
<SpotlightCard
|
||||
spotlight={{
|
||||
id: s.id,
|
||||
userId: s.userId,
|
||||
userName: s.user?.name,
|
||||
headline: s.headline,
|
||||
story: s.story,
|
||||
featuredMonth: s.featuredMonth,
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{wallTotal > 12 && (
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Pagination
|
||||
current={wallPage}
|
||||
total={wallTotal}
|
||||
pageSize={12}
|
||||
onChange={handleWallPageChange}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
admin/src/pages/social/ChallengesAdminPage.tsx
Normal file
297
admin/src/pages/social/ChallengesAdminPage.tsx
Normal file
@ -0,0 +1,297 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
InputNumber,
|
||||
DatePicker,
|
||||
Popconfirm,
|
||||
Tabs,
|
||||
App,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard';
|
||||
import type { PaginationMeta } from '@/types/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface ChallengeRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
metric: string;
|
||||
status: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
minTeamSize: number;
|
||||
maxTeamSize: number;
|
||||
maxTeams: number | null;
|
||||
_count?: { teams: number };
|
||||
}
|
||||
|
||||
const METRIC_OPTIONS = Object.entries(METRIC_MAP).map(([value, info]) => ({
|
||||
value,
|
||||
label: info.label,
|
||||
}));
|
||||
|
||||
const STATUS_TABS = ['ALL', 'DRAFT', 'UPCOMING', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
|
||||
|
||||
export default function ChallengesAdminPage() {
|
||||
const { message } = App.useApp();
|
||||
const [challenges, setChallenges] = useState<ChallengeRow[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, limit: 20 };
|
||||
if (statusFilter !== 'ALL') params.status = statusFilter;
|
||||
const res = await api.get('/social/challenges', { params });
|
||||
setChallenges(res.data.challenges);
|
||||
setPagination(res.data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load challenges');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: ChallengeRow) => {
|
||||
setEditingId(row.id);
|
||||
form.setFieldsValue({
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
metric: row.metric,
|
||||
dateRange: [dayjs(row.startsAt), dayjs(row.endsAt)],
|
||||
minTeamSize: row.minTeamSize,
|
||||
maxTeamSize: row.maxTeamSize,
|
||||
maxTeams: row.maxTeams,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
const payload = {
|
||||
title: values.title,
|
||||
description: values.description || undefined,
|
||||
metric: values.metric,
|
||||
startsAt: values.dateRange[0].toISOString(),
|
||||
endsAt: values.dateRange[1].toISOString(),
|
||||
minTeamSize: values.minTeamSize,
|
||||
maxTeamSize: values.maxTeamSize,
|
||||
maxTeams: values.maxTeams || undefined,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
await api.put(`/social/challenges/admin/${editingId}`, payload);
|
||||
message.success('Challenge updated');
|
||||
} else {
|
||||
await api.post('/social/challenges/admin', payload);
|
||||
message.success('Challenge created');
|
||||
}
|
||||
setModalOpen(false);
|
||||
load();
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.error?.message) {
|
||||
message.error(err.response.data.error.message);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (id: string, action: string) => {
|
||||
try {
|
||||
if (action === 'delete') {
|
||||
await api.delete(`/social/challenges/admin/${id}`);
|
||||
message.success('Deleted');
|
||||
} else {
|
||||
await api.post(`/social/challenges/admin/${id}/${action}`);
|
||||
message.success(`Challenge ${action}d`);
|
||||
}
|
||||
load();
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.error?.message || `Failed to ${action}`);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ChallengeRow> = [
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Metric',
|
||||
dataIndex: 'metric',
|
||||
key: 'metric',
|
||||
width: 160,
|
||||
render: (m: string) => {
|
||||
const info = METRIC_MAP[m];
|
||||
return info ? <Tag icon={info.icon} color={info.color}>{info.label}</Tag> : m;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
render: (s: string) => <Tag color={STATUS_COLORS[s]}>{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Period',
|
||||
key: 'period',
|
||||
width: 180,
|
||||
render: (_: unknown, r: ChallengeRow) =>
|
||||
`${dayjs(r.startsAt).format('MMM D')} - ${dayjs(r.endsAt).format('MMM D')}`,
|
||||
},
|
||||
{
|
||||
title: 'Teams',
|
||||
key: 'teams',
|
||||
width: 70,
|
||||
render: (_: unknown, r: ChallengeRow) => r._count?.teams ?? 0,
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, r: ChallengeRow) => (
|
||||
<Space size={4} wrap>
|
||||
{r.status === 'DRAFT' && (
|
||||
<Button size="small" icon={<PlayCircleOutlined />} onClick={() => handleAction(r.id, 'activate')}>
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
{r.status === 'ACTIVE' && (
|
||||
<>
|
||||
<Button size="small" icon={<CheckCircleOutlined />} onClick={() => handleAction(r.id, 'complete')}>
|
||||
Complete
|
||||
</Button>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => handleAction(r.id, 'rescore')}>
|
||||
Rescore
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(r.status === 'DRAFT' || r.status === 'UPCOMING') && (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>Edit</Button>
|
||||
)}
|
||||
{r.status !== 'COMPLETED' && r.status !== 'CANCELLED' && (
|
||||
<Popconfirm title="Cancel this challenge?" onConfirm={() => handleAction(r.id, 'cancel')}>
|
||||
<Button size="small" danger icon={<CloseCircleOutlined />}>Cancel</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{(r.status === 'DRAFT' || r.status === 'CANCELLED') && (
|
||||
<Popconfirm title="Delete this challenge?" onConfirm={() => handleAction(r.id, 'delete')}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => { setStatusFilter(key); setPage(1); }}
|
||||
items={STATUS_TABS.map((s) => ({ key: s, label: s === 'ALL' ? 'All' : s }))}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
New Challenge
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
dataSource={challenges}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total: pagination?.total ?? 0,
|
||||
pageSize: 20,
|
||||
showSizeChanger: false,
|
||||
onChange: setPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingId ? 'Edit Challenge' : 'New Challenge'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSave}
|
||||
confirmLoading={saving}
|
||||
destroyOnHidden
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="Title" rules={[{ required: true, max: 200 }]}>
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<TextArea rows={3} maxLength={2000} />
|
||||
</Form.Item>
|
||||
<Form.Item name="metric" label="Metric" rules={[{ required: true }]}>
|
||||
<Select options={METRIC_OPTIONS} placeholder="Select metric" />
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="Date Range" rules={[{ required: true }]}>
|
||||
<RangePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Form.Item name="minTeamSize" label="Min Team Size" initialValue={2}>
|
||||
<InputNumber min={1} max={50} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxTeamSize" label="Max Team Size" initialValue={10}>
|
||||
<InputNumber min={2} max={100} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxTeams" label="Max Teams">
|
||||
<InputNumber min={1} placeholder="Unlimited" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
admin/src/pages/social/ReferralAdminPage.tsx
Normal file
148
admin/src/pages/social/ReferralAdminPage.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Table, Typography, Space, Spin, Row, Col, Grid, App } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { TrophyOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface AdminReferralRow {
|
||||
id: number;
|
||||
completedAt: string;
|
||||
referralSource: string | null;
|
||||
referrer: { id: string; name: string | null; email: string };
|
||||
referredUser: { id: string; name: string | null; email: string };
|
||||
}
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
referralCount: number;
|
||||
}
|
||||
|
||||
export default function ReferralAdminPage() {
|
||||
const { message } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [referrals, setReferrals] = useState<AdminReferralRow[]>([]);
|
||||
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [refRes, lbRes] = await Promise.all([
|
||||
api.get('/social/referrals/admin/all', { params: { page, limit: 20 } }),
|
||||
api.get('/social/referrals/admin/leaderboard', { params: { limit: 10 } }),
|
||||
]);
|
||||
setReferrals(refRes.data.referrals);
|
||||
setTotal(refRes.data.pagination.total);
|
||||
setLeaderboard(lbRes.data.leaderboard);
|
||||
} catch {
|
||||
message.error('Failed to load referral data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const referralColumns: ColumnsType<AdminReferralRow> = [
|
||||
{
|
||||
title: 'Referrer',
|
||||
key: 'referrer',
|
||||
render: (_: unknown, r: AdminReferralRow) => r.referrer.name || r.referrer.email,
|
||||
},
|
||||
{
|
||||
title: 'Referred User',
|
||||
key: 'referred',
|
||||
render: (_: unknown, r: AdminReferralRow) => r.referredUser.name || r.referredUser.email,
|
||||
},
|
||||
{
|
||||
title: 'Source',
|
||||
dataIndex: 'referralSource',
|
||||
key: 'source',
|
||||
render: (v: string | null) => v || '-',
|
||||
},
|
||||
{
|
||||
title: 'Date',
|
||||
dataIndex: 'completedAt',
|
||||
key: 'date',
|
||||
render: (v: string) => dayjs(v).format('MMM D, YYYY'),
|
||||
responsive: ['md'] as any,
|
||||
},
|
||||
];
|
||||
|
||||
const leaderboardColumns: ColumnsType<LeaderboardEntry> = [
|
||||
{
|
||||
title: '#',
|
||||
dataIndex: 'rank',
|
||||
key: 'rank',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
render: (_: unknown, r: LeaderboardEntry) => r.name || r.email,
|
||||
},
|
||||
{
|
||||
title: 'Referrals',
|
||||
dataIndex: 'referralCount',
|
||||
key: 'count',
|
||||
width: 100,
|
||||
},
|
||||
];
|
||||
|
||||
if (loading && page === 1) {
|
||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 16 : 24 }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>Referral Management</Typography.Title>
|
||||
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title={`All Referrals (${total})`} size="small">
|
||||
<Table
|
||||
dataSource={referrals}
|
||||
columns={referralColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: setPage,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
locale={{ emptyText: 'No referrals yet' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card
|
||||
title={<span><TrophyOutlined style={{ marginRight: 8 }} />Top Referrers</span>}
|
||||
size="small"
|
||||
>
|
||||
<Table
|
||||
dataSource={leaderboard}
|
||||
columns={leaderboardColumns}
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
locale={{ emptyText: 'No referrals yet' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
admin/src/pages/social/SpotlightAdminPage.tsx
Normal file
372
admin/src/pages/social/SpotlightAdminPage.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, Popconfirm,
|
||||
message, Typography, Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined, CheckOutlined, StarOutlined, InboxOutlined, EditOutlined,
|
||||
DeleteOutlined, UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Spotlight {
|
||||
id: string;
|
||||
userId: string;
|
||||
status: 'NOMINATED' | 'APPROVED' | 'FEATURED' | 'ARCHIVED';
|
||||
headline: string | null;
|
||||
story: string | null;
|
||||
featuredMonth: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; name: string | null; email: string };
|
||||
nominatedBy?: { id: string; name: string | null } | null;
|
||||
approvedBy?: { id: string; name: string | null } | null;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
NOMINATED: 'blue',
|
||||
APPROVED: 'green',
|
||||
FEATURED: 'gold',
|
||||
ARCHIVED: 'default',
|
||||
};
|
||||
|
||||
export default function SpotlightAdminPage() {
|
||||
const [spotlights, setSpotlights] = useState<Spotlight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [nominateOpen, setNominateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [featureOpen, setFeatureOpen] = useState(false);
|
||||
const [selectedSpotlight, setSelectedSpotlight] = useState<Spotlight | null>(null);
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
const [featureMonth, setFeatureMonth] = useState<dayjs.Dayjs | null>(null);
|
||||
|
||||
const [nominateForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const fetchSpotlights = useCallback(async (page = 1) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: Record<string, string | number> = { page, limit: pagination.limit };
|
||||
if (statusFilter !== 'all') params.status = statusFilter;
|
||||
const { data } = await api.get('/social/spotlight/admin', { params });
|
||||
setSpotlights(data.spotlights || []);
|
||||
setPagination((prev) => ({ ...prev, page, total: data.pagination?.total || 0 }));
|
||||
} catch {
|
||||
message.error('Failed to load spotlights');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter, pagination.limit]);
|
||||
|
||||
const searchUsers = useCallback(async (search: string) => {
|
||||
if (search.length < 2) return;
|
||||
try {
|
||||
const { data } = await api.get('/users', { params: { search, limit: 20 } });
|
||||
setUsers(data.users || []);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSpotlights(1);
|
||||
}, [fetchSpotlights]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSearch.length >= 2) {
|
||||
const timer = setTimeout(() => searchUsers(userSearch), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [userSearch, searchUsers]);
|
||||
|
||||
const handleNominate = async (values: { userId: string; headline?: string; story?: string }) => {
|
||||
try {
|
||||
await api.post('/social/spotlight/admin/nominate', values);
|
||||
message.success('Volunteer nominated');
|
||||
setNominateOpen(false);
|
||||
nominateForm.resetFields();
|
||||
fetchSpotlights(1);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.error?.message || 'Failed to nominate');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/social/spotlight/admin/${id}/approve`);
|
||||
message.success('Spotlight approved');
|
||||
fetchSpotlights(pagination.page);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.error?.message || 'Failed to approve');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeature = async () => {
|
||||
if (!selectedSpotlight || !featureMonth) return;
|
||||
try {
|
||||
const month = featureMonth.format('YYYY-MM');
|
||||
await api.post(`/social/spotlight/admin/${selectedSpotlight.id}/feature`, { month });
|
||||
message.success('Spotlight featured');
|
||||
setFeatureOpen(false);
|
||||
setFeatureMonth(null);
|
||||
fetchSpotlights(pagination.page);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.error?.message || 'Failed to feature');
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/social/spotlight/admin/${id}/archive`);
|
||||
message.success('Spotlight archived');
|
||||
fetchSpotlights(pagination.page);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.error?.message || 'Failed to archive');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (values: { headline?: string; story?: string }) => {
|
||||
if (!selectedSpotlight) return;
|
||||
try {
|
||||
await api.put(`/social/spotlight/admin/${selectedSpotlight.id}`, values);
|
||||
message.success('Spotlight updated');
|
||||
setEditOpen(false);
|
||||
fetchSpotlights(pagination.page);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.error?.message || 'Failed to update');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/social/spotlight/admin/${id}`);
|
||||
message.success('Spotlight deleted');
|
||||
fetchSpotlights(pagination.page);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.error?.message || 'Failed to delete');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (record: Spotlight) => {
|
||||
setSelectedSpotlight(record);
|
||||
editForm.setFieldsValue({ headline: record.headline, story: record.story });
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openFeature = (record: Spotlight) => {
|
||||
setSelectedSpotlight(record);
|
||||
setFeatureMonth(null);
|
||||
setFeatureOpen(true);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Spotlight> = [
|
||||
{
|
||||
title: 'Volunteer',
|
||||
key: 'user',
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<Text strong>{r.user.name || r.user.email}</Text>
|
||||
{r.user.name && <br />}
|
||||
{r.user.name && <Text type="secondary" style={{ fontSize: 12 }}>{r.user.email}</Text>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
render: (status: string) => (
|
||||
<Tag color={STATUS_COLORS[status]}>{status}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Headline',
|
||||
dataIndex: 'headline',
|
||||
key: 'headline',
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => v || <Text type="secondary">--</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Month',
|
||||
dataIndex: 'featuredMonth',
|
||||
key: 'featuredMonth',
|
||||
width: 110,
|
||||
render: (v: string | null) =>
|
||||
v ? dayjs(v + '-01').format('MMM YYYY') : <Text type="secondary">--</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 110,
|
||||
render: (v: string) => dayjs(v).format('MMM D, YYYY'),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space size="small" wrap>
|
||||
{record.status === 'NOMINATED' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => handleApprove(record.id)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{record.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<StarOutlined />}
|
||||
style={{ color: '#d4a017', borderColor: '#d4a017' }}
|
||||
onClick={() => openFeature(record)}
|
||||
>
|
||||
Feature
|
||||
</Button>
|
||||
)}
|
||||
{record.status !== 'ARCHIVED' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
onClick={() => handleArchive(record.id)}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
|
||||
<Popconfirm
|
||||
title="Delete this spotlight?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>Volunteer Spotlight</Typography.Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNominateOpen(true)}>
|
||||
Nominate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => setStatusFilter(key)}
|
||||
items={[
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'NOMINATED', label: 'Nominated' },
|
||||
{ key: 'APPROVED', label: 'Approved' },
|
||||
{ key: 'FEATURED', label: 'Featured' },
|
||||
{ key: 'ARCHIVED', label: 'Archived' },
|
||||
]}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Table
|
||||
dataSource={spotlights}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
total: pagination.total,
|
||||
pageSize: pagination.limit,
|
||||
onChange: (page) => fetchSpotlights(page),
|
||||
showTotal: (total) => `${total} spotlights`,
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
|
||||
{/* Nominate Modal */}
|
||||
<Modal
|
||||
title="Nominate Volunteer"
|
||||
open={nominateOpen}
|
||||
onCancel={() => { setNominateOpen(false); nominateForm.resetFields(); }}
|
||||
onOk={() => nominateForm.submit()}
|
||||
okText="Nominate"
|
||||
>
|
||||
<Form form={nominateForm} layout="vertical" onFinish={handleNominate}>
|
||||
<Form.Item name="userId" label="Volunteer" rules={[{ required: true, message: 'Select a volunteer' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search by name or email"
|
||||
filterOption={false}
|
||||
onSearch={setUserSearch}
|
||||
suffixIcon={<UserOutlined />}
|
||||
options={users.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name || ''} (${u.email})`.trim(),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="headline" label="Headline">
|
||||
<Input maxLength={200} placeholder="A short title for their spotlight" />
|
||||
</Form.Item>
|
||||
<Form.Item name="story" label="Story">
|
||||
<TextArea rows={4} maxLength={2000} placeholder="Tell us why this volunteer deserves recognition" showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
title="Edit Spotlight"
|
||||
open={editOpen}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onOk={() => editForm.submit()}
|
||||
okText="Save"
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={handleEdit}>
|
||||
<Form.Item name="headline" label="Headline">
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item name="story" label="Story">
|
||||
<TextArea rows={4} maxLength={2000} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Feature Modal */}
|
||||
<Modal
|
||||
title="Feature Spotlight"
|
||||
open={featureOpen}
|
||||
onCancel={() => setFeatureOpen(false)}
|
||||
onOk={handleFeature}
|
||||
okText="Feature"
|
||||
okButtonProps={{ disabled: !featureMonth }}
|
||||
>
|
||||
<Text>Select the month to feature this volunteer:</Text>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
value={featureMonth}
|
||||
onChange={setFeatureMonth}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Typography, Progress, Tag, Skeleton, Empty, Tabs, List, Statistic, Row, Col } from 'antd';
|
||||
import { Card, Typography, Progress, Tag, Skeleton, Empty, Tabs, List, Statistic, Row, Col, Switch, message } from 'antd';
|
||||
import {
|
||||
TrophyOutlined, ScheduleOutlined, EnvironmentOutlined, MailOutlined,
|
||||
TeamOutlined, FireOutlined, StarOutlined, HomeOutlined, UserAddOutlined,
|
||||
CrownOutlined,
|
||||
CrownOutlined, EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import UserAvatar from '@/components/social/UserAvatar';
|
||||
@ -37,9 +37,12 @@ export default function AchievementsPage() {
|
||||
const [leaderboardType, setLeaderboardType] = useState<string>('canvass');
|
||||
const [myRank, setMyRank] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [leaderboardOptIn, setLeaderboardOptIn] = useState(true);
|
||||
const [optInLoading, setOptInLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchOptInStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -59,6 +62,26 @@ export default function AchievementsPage() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchOptInStatus = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/social/spotlight/opt-in-status');
|
||||
setLeaderboardOptIn(data.showOnLeaderboard ?? true);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleOptInToggle = async (checked: boolean) => {
|
||||
setOptInLoading(true);
|
||||
try {
|
||||
await api.post(checked ? '/social/spotlight/opt-in' : '/social/spotlight/opt-out');
|
||||
setLeaderboardOptIn(checked);
|
||||
message.success(checked ? 'You are now visible on public leaderboards' : 'You are now hidden from public leaderboards');
|
||||
} catch {
|
||||
message.error('Failed to update preference');
|
||||
} finally {
|
||||
setOptInLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLeaderboard = async (type: string) => {
|
||||
try {
|
||||
const { data } = await api.get('/social/achievements/leaderboard', { params: { type, limit: 10 } });
|
||||
@ -148,7 +171,24 @@ export default function AchievementsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Leaderboard */}
|
||||
<Card title="Leaderboard" size="small">
|
||||
<Card
|
||||
title="Leaderboard"
|
||||
size="small"
|
||||
extra={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
|
||||
<EyeOutlined />
|
||||
<Switch
|
||||
size="small"
|
||||
checked={leaderboardOptIn}
|
||||
loading={optInLoading}
|
||||
onChange={handleOptInToggle}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{leaderboardOptIn ? 'Visible' : 'Hidden'}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={leaderboardType}
|
||||
onChange={setLeaderboardType}
|
||||
|
||||
202
admin/src/pages/volunteer/ChallengeDetailPage.tsx
Normal file
202
admin/src/pages/volunteer/ChallengeDetailPage.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Typography, Spin, Card, Tag, Space, Button, Divider, Descriptions, App } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
ClockCircleOutlined,
|
||||
LogoutOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard';
|
||||
import ChallengeLeaderboard from '@/components/social/ChallengeLeaderboard';
|
||||
import TeamJoinCard from '@/components/social/TeamJoinCard';
|
||||
|
||||
interface ChallengeDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
metric: string;
|
||||
status: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
minTeamSize: number;
|
||||
maxTeamSize: number;
|
||||
maxTeams: number | null;
|
||||
createdBy: { name: string | null; email: string };
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
captainUserId: string;
|
||||
captain: { id: string; name: string | null; email: string };
|
||||
members: Array<{
|
||||
id: number;
|
||||
userId: string;
|
||||
score: number;
|
||||
user: { id: string; name: string | null; email: string };
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ChallengeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [challenge, setChallenge] = useState<ChallengeDetail | null>(null);
|
||||
const [myTeam, setMyTeam] = useState<ChallengeDetail['teams'][0] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [challengeRes, myTeamRes] = await Promise.all([
|
||||
api.get(`/social/challenges/${id}`),
|
||||
api.get(`/social/challenges/${id}/my-team`),
|
||||
]);
|
||||
setChallenge(challengeRes.data);
|
||||
setMyTeam(myTeamRes.data.team);
|
||||
} catch {
|
||||
message.error('Failed to load challenge');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!myTeam || !id) return;
|
||||
setLeaving(true);
|
||||
try {
|
||||
await api.post(`/social/challenges/${id}/teams/${myTeam.id}/leave`);
|
||||
message.success('Left team');
|
||||
load();
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.error?.message || 'Failed to leave team');
|
||||
} finally {
|
||||
setLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !challenge) {
|
||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
const metric = (METRIC_MAP[challenge.metric] || METRIC_MAP.DOORS_KNOCKED)!;
|
||||
const isJoinable = challenge.status === 'UPCOMING' || challenge.status === 'ACTIVE';
|
||||
const isActive = challenge.status === 'ACTIVE';
|
||||
const isUpcoming = challenge.status === 'UPCOMING';
|
||||
|
||||
const endsIn = dayjs(challenge.endsAt).diff(dayjs(), 'second');
|
||||
const startsIn = dayjs(challenge.startsAt).diff(dayjs(), 'second');
|
||||
|
||||
const countdownLabel =
|
||||
isActive && endsIn > 0
|
||||
? `Ends in ${Math.floor(endsIn / 86400)}d ${Math.floor((endsIn % 86400) / 3600)}h`
|
||||
: isUpcoming && startsIn > 0
|
||||
? `Starts in ${Math.floor(startsIn / 86400)}d ${Math.floor((startsIn % 86400) / 3600)}h`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/volunteer/challenges')}
|
||||
style={{ marginBottom: 8, padding: 0 }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
{challenge.title}
|
||||
</Typography.Title>
|
||||
{challenge.description && (
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||
{challenge.description}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<Tag color={STATUS_COLORS[challenge.status]}>{challenge.status}</Tag>
|
||||
</div>
|
||||
|
||||
<Descriptions size="small" column={{ xs: 1, sm: 2, md: 3 }} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="Metric">
|
||||
<Tag icon={metric.icon} color={metric.color}>{metric.label}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Period">
|
||||
{dayjs(challenge.startsAt).format('MMM D')} - {dayjs(challenge.endsAt).format('MMM D, YYYY')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Team Size">
|
||||
{challenge.minTeamSize} - {challenge.maxTeamSize}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{countdownLabel && (
|
||||
<Tag icon={<ClockCircleOutlined />} color="processing" style={{ marginBottom: 16 }}>
|
||||
{countdownLabel}
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
<Divider orientation="left">Leaderboard</Divider>
|
||||
|
||||
<ChallengeLeaderboard
|
||||
teams={challenge.teams}
|
||||
myTeamId={myTeam?.id}
|
||||
/>
|
||||
|
||||
<Divider orientation="left">My Team</Divider>
|
||||
|
||||
{myTeam ? (
|
||||
<Card size="small">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>{myTeam.name}</Typography.Title>
|
||||
{isJoinable && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<LogoutOutlined />}
|
||||
loading={leaving}
|
||||
onClick={handleLeave}
|
||||
>
|
||||
Leave
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
{myTeam.members.map((m) => (
|
||||
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>
|
||||
{m.userId === myTeam.captainUserId ? '(C) ' : ''}
|
||||
{m.user.name || m.user.email}
|
||||
{m.userId === user?.id ? ' (you)' : ''}
|
||||
</span>
|
||||
<Typography.Text strong>{m.score}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
) : isJoinable ? (
|
||||
<TeamJoinCard
|
||||
challengeId={challenge.id}
|
||||
teams={challenge.teams}
|
||||
maxTeamSize={challenge.maxTeamSize}
|
||||
onTeamCreated={load}
|
||||
onTeamJoined={load}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
This challenge is not accepting new teams.
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
admin/src/pages/volunteer/ChallengesPage.tsx
Normal file
124
admin/src/pages/volunteer/ChallengesPage.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Typography, Spin, Row, Col, Collapse, Empty, App } from 'antd';
|
||||
import { api } from '@/lib/api';
|
||||
import ChallengeCard from '@/components/social/ChallengeCard';
|
||||
|
||||
interface ChallengeListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
metric: string;
|
||||
status: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
_count?: { teams: number };
|
||||
}
|
||||
|
||||
interface MyTeamMap {
|
||||
[challengeId: string]: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
export default function ChallengesPage() {
|
||||
const { message } = App.useApp();
|
||||
const [challenges, setChallenges] = useState<ChallengeListItem[]>([]);
|
||||
const [myTeams, setMyTeams] = useState<MyTeamMap>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadChallenges();
|
||||
}, []);
|
||||
|
||||
const loadChallenges = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/social/challenges', { params: { limit: 100 } });
|
||||
const all: ChallengeListItem[] = res.data.challenges;
|
||||
setChallenges(all);
|
||||
|
||||
// Fetch my team for UPCOMING/ACTIVE challenges
|
||||
const relevant = all.filter((c) => c.status === 'UPCOMING' || c.status === 'ACTIVE');
|
||||
const teamMap: MyTeamMap = {};
|
||||
await Promise.all(
|
||||
relevant.map(async (c) => {
|
||||
try {
|
||||
const r = await api.get(`/social/challenges/${c.id}/my-team`);
|
||||
teamMap[c.id] = r.data.team;
|
||||
} catch {
|
||||
teamMap[c.id] = null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setMyTeams(teamMap);
|
||||
} catch {
|
||||
message.error('Failed to load challenges');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
const active = challenges.filter((c) => c.status === 'ACTIVE');
|
||||
const upcoming = challenges.filter((c) => c.status === 'UPCOMING');
|
||||
const past = challenges.filter((c) => c.status === 'COMPLETED');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 16 }}>
|
||||
Team Challenges
|
||||
</Typography.Title>
|
||||
|
||||
{active.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 8 }}>Active Challenges</Typography.Title>
|
||||
<Row gutter={[12, 12]}>
|
||||
{active.map((c) => (
|
||||
<Col xs={24} sm={12} lg={8} key={c.id}>
|
||||
<ChallengeCard challenge={c} myTeam={myTeams[c.id]} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upcoming.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 8 }}>Upcoming</Typography.Title>
|
||||
<Row gutter={[12, 12]}>
|
||||
{upcoming.map((c) => (
|
||||
<Col xs={24} sm={12} lg={8} key={c.id}>
|
||||
<ChallengeCard challenge={c} myTeam={myTeams[c.id]} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{past.length > 0 && (
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'past',
|
||||
label: <Typography.Text type="secondary">Past Challenges ({past.length})</Typography.Text>,
|
||||
children: (
|
||||
<Row gutter={[12, 12]}>
|
||||
{past.map((c) => (
|
||||
<Col xs={24} sm={12} lg={8} key={c.id}>
|
||||
<ChallengeCard challenge={c} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{challenges.length === 0 && (
|
||||
<Empty description="No challenges yet" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
441
admin/src/pages/volunteer/MyCalendarPage.tsx
Normal file
441
admin/src/pages/volunteer/MyCalendarPage.tsx
Normal file
@ -0,0 +1,441 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Skeleton,
|
||||
Empty,
|
||||
List,
|
||||
Tag,
|
||||
Space,
|
||||
message,
|
||||
theme,
|
||||
Modal,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
PlusOutlined,
|
||||
ClockCircleOutlined,
|
||||
EnvironmentOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import FeatureGate from '@/components/FeatureGate';
|
||||
import CalendarLayerPanel from '@/components/calendar/CalendarLayerPanel';
|
||||
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
|
||||
import MobileDayView from '@/components/calendar/MobileDayView';
|
||||
import CalendarItemModal, { type CalendarItemFormData } from '@/components/calendar/CalendarItemModal';
|
||||
import type {
|
||||
CalendarLayer,
|
||||
PersonalCalendarItem,
|
||||
PersonalCalendarResponse,
|
||||
SeriesEditScope,
|
||||
} from '@/types/api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function MyCalendarPage() {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// Data state
|
||||
const [layers, setLayers] = useState<CalendarLayer[]>([]);
|
||||
const [items, setItems] = useState<PersonalCalendarItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// View state
|
||||
const [currentMonth, setCurrentMonth] = useState<Dayjs>(dayjs());
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
// Modal state
|
||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<PersonalCalendarItem | null>(null);
|
||||
|
||||
// Derived: enabled layer IDs for filtering
|
||||
const enabledLayerIds = new Set(layers.filter((l) => l.isEnabled).map((l) => l.id));
|
||||
|
||||
// Filtered items based on enabled layers
|
||||
const filteredItems = items.filter((item) => enabledLayerIds.has(item.layerId));
|
||||
|
||||
// Items for the selected date
|
||||
const selectedDateItems = selectedDate
|
||||
? filteredItems.filter((item) => item.date === selectedDate)
|
||||
: [];
|
||||
|
||||
// Fetch layers
|
||||
const fetchLayers = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get<CalendarLayer[]>('/calendar/layers');
|
||||
setLayers(data);
|
||||
} catch {
|
||||
// Layers may not exist yet
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch items for the current visible range
|
||||
const fetchItems = useCallback(async () => {
|
||||
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
|
||||
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
|
||||
try {
|
||||
const { data } = await api.get<PersonalCalendarResponse>('/calendar/my', {
|
||||
params: { startDate, endDate },
|
||||
});
|
||||
// Flatten the dates map into a flat item array
|
||||
const allItems: PersonalCalendarItem[] = [];
|
||||
for (const dateGroup of Object.values(data.dates)) {
|
||||
allItems.push(...dateGroup.items);
|
||||
}
|
||||
setItems(allItems);
|
||||
} catch {
|
||||
// Empty calendar
|
||||
}
|
||||
}, [currentMonth]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([fetchLayers(), fetchItems()]);
|
||||
setLoading(false);
|
||||
};
|
||||
load();
|
||||
}, [fetchLayers, fetchItems]);
|
||||
|
||||
// Layer CRUD
|
||||
const handleCreateLayer = async (name: string, color: string) => {
|
||||
try {
|
||||
await api.post('/calendar/layers', { name, color });
|
||||
await fetchLayers();
|
||||
message.success('Layer created');
|
||||
} catch {
|
||||
message.error('Failed to create layer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateLayer = async (id: string, updates: Partial<CalendarLayer>) => {
|
||||
try {
|
||||
await api.patch(`/calendar/layers/${id}`, updates);
|
||||
// Optimistic update for toggle
|
||||
setLayers((prev) =>
|
||||
prev.map((l) => (l.id === id ? { ...l, ...updates } : l)),
|
||||
);
|
||||
} catch {
|
||||
message.error('Failed to update layer');
|
||||
await fetchLayers();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLayer = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/calendar/layers/${id}`);
|
||||
setLayers((prev) => prev.filter((l) => l.id !== id));
|
||||
message.success('Layer deleted');
|
||||
} catch {
|
||||
message.error('Failed to delete layer');
|
||||
}
|
||||
};
|
||||
|
||||
// Item CRUD
|
||||
const handleSaveItem = async (values: CalendarItemFormData, scope?: SeriesEditScope) => {
|
||||
try {
|
||||
if (editingItem?.calendarItemId) {
|
||||
await api.patch(
|
||||
`/calendar/items/${editingItem.calendarItemId}`,
|
||||
values,
|
||||
{ params: scope ? { scope } : undefined },
|
||||
);
|
||||
message.success('Event updated');
|
||||
} else {
|
||||
await api.post('/calendar/items', values);
|
||||
message.success('Event created');
|
||||
}
|
||||
setItemModalOpen(false);
|
||||
setEditingItem(null);
|
||||
await fetchItems();
|
||||
} catch {
|
||||
message.error('Failed to save event');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (item: PersonalCalendarItem, scope?: SeriesEditScope) => {
|
||||
if (!item.calendarItemId) return;
|
||||
try {
|
||||
await api.delete(`/calendar/items/${item.calendarItemId}`, {
|
||||
params: scope ? { scope } : undefined,
|
||||
});
|
||||
message.success('Event deleted');
|
||||
await fetchItems();
|
||||
} catch {
|
||||
message.error('Failed to delete event');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteItem = (item: PersonalCalendarItem) => {
|
||||
if (item.isRecurring && item.seriesId) {
|
||||
Modal.confirm({
|
||||
title: 'Delete recurring event',
|
||||
content: 'How would you like to delete this recurring event?',
|
||||
okText: 'This event only',
|
||||
cancelText: 'Cancel',
|
||||
onOk: () => handleDeleteItem(item, 'THIS_ONLY'),
|
||||
footer: (_, { OkBtn, CancelBtn }) => (
|
||||
<Space>
|
||||
<CancelBtn />
|
||||
<OkBtn />
|
||||
<Button danger onClick={() => { handleDeleteItem(item, 'ALL'); Modal.destroyAll(); }}>
|
||||
All in series
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: 'Delete event?',
|
||||
content: `Are you sure you want to delete "${item.title}"?`,
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
onOk: () => handleDeleteItem(item),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Open modal for new item on a date
|
||||
const handleAddItem = (date?: string) => {
|
||||
setEditingItem(null);
|
||||
if (date) {
|
||||
setSelectedDate(date);
|
||||
}
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
// Open modal for editing an existing item
|
||||
const handleEditItem = (item: PersonalCalendarItem) => {
|
||||
if (item.type !== 'personal') return; // Only personal items are editable
|
||||
setEditingItem(item);
|
||||
setItemModalOpen(true);
|
||||
};
|
||||
|
||||
// Date click handler
|
||||
const handleDateSelect = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
};
|
||||
|
||||
// Month change handler
|
||||
const handleMonthChange = (month: Dayjs) => {
|
||||
setCurrentMonth(month);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Date detail panel (right side on desktop, or inline on mobile)
|
||||
const dateDetailPanel = selectedDate && (
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? '100%' : 280,
|
||||
flexShrink: 0,
|
||||
padding: isMobile ? '12px 0' : '0 0 0 16px',
|
||||
borderLeft: isMobile ? undefined : `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>
|
||||
{dayjs(selectedDate).format('ddd, MMM D')}
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAddItem(selectedDate)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedDateItems.length === 0 ? (
|
||||
<Empty description="No events" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={selectedDateItems}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
style={{ padding: '8px 0', cursor: item.type === 'personal' ? 'pointer' : 'default' }}
|
||||
onClick={() => handleEditItem(item)}
|
||||
actions={
|
||||
item.type === 'personal'
|
||||
? [
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditItem(item);
|
||||
}}
|
||||
/>,
|
||||
<Button
|
||||
key="delete"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
confirmDeleteItem(item);
|
||||
}}
|
||||
/>,
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div
|
||||
style={{
|
||||
width: 4,
|
||||
height: 32,
|
||||
borderRadius: 2,
|
||||
background: item.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Text style={{ fontSize: 13 }} ellipsis>
|
||||
{item.title}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Space size={4} wrap style={{ fontSize: 11 }}>
|
||||
{!item.isAllDay && (
|
||||
<Tag
|
||||
icon={<ClockCircleOutlined />}
|
||||
style={{ fontSize: 11, margin: 0 }}
|
||||
>
|
||||
{item.startTime?.slice(0, 5)} - {item.endTime?.slice(0, 5)}
|
||||
</Tag>
|
||||
)}
|
||||
{item.isAllDay && <Tag style={{ fontSize: 11, margin: 0 }}>All day</Tag>}
|
||||
{item.location && (
|
||||
<Tag
|
||||
icon={<EnvironmentOutlined />}
|
||||
style={{ fontSize: 11, margin: 0 }}
|
||||
>
|
||||
{item.location}
|
||||
</Tag>
|
||||
)}
|
||||
{item.type !== 'personal' && (
|
||||
<Tag color="blue" style={{ fontSize: 10, margin: 0 }}>
|
||||
{item.type}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<FeatureGate feature="enableSocialCalendar">
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
My Calendar
|
||||
</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAddItem(selectedDate ?? dayjs().format('YYYY-MM-DD'))}
|
||||
>
|
||||
{!isMobile && 'Add Event'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
/* Mobile layout: MobileDayView with layer toggles at top */
|
||||
<div>
|
||||
<CalendarLayerPanel
|
||||
layers={layers}
|
||||
compact
|
||||
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
|
||||
onCreate={handleCreateLayer}
|
||||
onUpdate={handleUpdateLayer}
|
||||
onDelete={handleDeleteLayer}
|
||||
/>
|
||||
<MobileDayView
|
||||
items={filteredItems}
|
||||
currentMonth={currentMonth}
|
||||
selectedDate={selectedDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
onMonthChange={handleMonthChange}
|
||||
onItemClick={handleEditItem}
|
||||
onAddItem={handleAddItem}
|
||||
/>
|
||||
{dateDetailPanel}
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop layout: layer panel | calendar | date detail */
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
<div style={{ width: 240, flexShrink: 0, paddingRight: 16 }}>
|
||||
<CalendarLayerPanel
|
||||
layers={layers}
|
||||
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
|
||||
onCreate={handleCreateLayer}
|
||||
onUpdate={handleUpdateLayer}
|
||||
onDelete={handleDeleteLayer}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<PersonalCalendarView
|
||||
items={filteredItems}
|
||||
currentMonth={currentMonth}
|
||||
selectedDate={selectedDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
onMonthChange={handleMonthChange}
|
||||
onItemClick={handleEditItem}
|
||||
/>
|
||||
</div>
|
||||
{dateDetailPanel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item create/edit modal */}
|
||||
<CalendarItemModal
|
||||
open={itemModalOpen}
|
||||
item={editingItem}
|
||||
layers={layers.filter((l) => l.layerType === 'USER')}
|
||||
defaultDate={selectedDate ?? dayjs().format('YYYY-MM-DD')}
|
||||
onSave={handleSaveItem}
|
||||
onDelete={editingItem ? () => confirmDeleteItem(editingItem) : undefined}
|
||||
onCancel={() => {
|
||||
setItemModalOpen(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
}
|
||||
159
admin/src/pages/volunteer/MyTicketsPage.tsx
Normal file
159
admin/src/pages/volunteer/MyTicketsPage.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography, Card, Row, Col, Tag, Button, Spin, Empty, Space, Grid,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined,
|
||||
TagOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface MyTicket {
|
||||
id: string;
|
||||
ticketCode: string;
|
||||
status: string;
|
||||
holderName: string | null;
|
||||
holderEmail: string;
|
||||
issuedAt: string;
|
||||
checkedInAt: string | null;
|
||||
tier: { name: string; tierType: string; priceCAD: number };
|
||||
event: {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
venueName: string | null;
|
||||
coverImageUrl: string | null;
|
||||
};
|
||||
qrUrl: string;
|
||||
}
|
||||
|
||||
export default function MyTicketsPage() {
|
||||
const navigate = useNavigate();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const [tickets, setTickets] = useState<MyTicket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/api/ticketed-events/my-tickets');
|
||||
setTickets(data.tickets);
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tickets.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 16 : 24 }}>
|
||||
<Title level={3}><TagOutlined /> My Tickets</Title>
|
||||
<Empty
|
||||
description="You don't have any tickets yet"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button type="primary" onClick={() => navigate('/events')}>Browse Events</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group tickets by event
|
||||
const grouped = tickets.reduce<Record<string, MyTicket[]>>((acc, t) => {
|
||||
const key = t.event.id;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(t);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
VALID: 'green',
|
||||
CHECKED_IN: 'blue',
|
||||
CANCELLED: 'red',
|
||||
REFUNDED: 'orange',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 16 : 24 }}>
|
||||
<Title level={3}><TagOutlined /> My Tickets</Title>
|
||||
|
||||
{Object.entries(grouped).map(([eventId, eventTickets]) => {
|
||||
const ev = eventTickets[0]!.event;
|
||||
const isPast = dayjs(ev.date).isBefore(dayjs(), 'day');
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={eventId}
|
||||
style={{ marginBottom: 16, opacity: isPast ? 0.7 : 1 }}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>{ev.title}</Text>
|
||||
{isPast && <Tag>Past</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button size="small" onClick={() => navigate(`/event/${ev.slug}`)}>
|
||||
View Event
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Tag icon={<CalendarOutlined />}>{dayjs(ev.date).format('MMM D, YYYY')}</Tag>
|
||||
<Tag icon={<ClockCircleOutlined />}>{ev.startTime} – {ev.endTime}</Tag>
|
||||
{ev.venueName && <Tag icon={<EnvironmentOutlined />}>{ev.venueName}</Tag>}
|
||||
</Space>
|
||||
|
||||
<Row gutter={[12, 12]}>
|
||||
{eventTickets.map(ticket => (
|
||||
<Col xs={24} sm={12} md={8} key={ticket.id}>
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => navigate(`/event/${ev.slug}/ticket/${ticket.ticketCode}`)}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<img
|
||||
src={ticket.qrUrl}
|
||||
alt={`QR ${ticket.ticketCode}`}
|
||||
style={{ width: 120, height: 120, marginBottom: 8 }}
|
||||
/>
|
||||
<div>
|
||||
<Text code style={{ fontSize: 14 }}>{ticket.ticketCode}</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Tag color={statusColors[ticket.status]} style={{ fontSize: 11 }}>
|
||||
{ticket.status}
|
||||
</Tag>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{ticket.tier.name}</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
admin/src/pages/volunteer/ReferralsPage.tsx
Normal file
217
admin/src/pages/volunteer/ReferralsPage.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Card, Button, Row, Col, Statistic, Table, Typography, Space,
|
||||
Input, InputNumber, Form, Modal, Spin, Grid, App, Empty,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined, GiftOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import InviteCodeCard from '@/components/social/InviteCodeCard';
|
||||
|
||||
interface InviteCode {
|
||||
id: string;
|
||||
code: string;
|
||||
maxUses: number;
|
||||
usedCount: number;
|
||||
expiresAt: string | null;
|
||||
isActive: boolean;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
_count?: { referrals: number };
|
||||
}
|
||||
|
||||
interface ReferralRow {
|
||||
id: number;
|
||||
completedAt: string;
|
||||
referralSource: string | null;
|
||||
referredUser: { id: string; name: string | null; email: string };
|
||||
}
|
||||
|
||||
interface ReferralStats {
|
||||
totalReferrals: number;
|
||||
thisMonth: number;
|
||||
}
|
||||
|
||||
export default function ReferralsPage() {
|
||||
const { message } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [codes, setCodes] = useState<InviteCode[]>([]);
|
||||
const [referrals, setReferrals] = useState<ReferralRow[]>([]);
|
||||
const [stats, setStats] = useState<ReferralStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
|
||||
const [refPage, setRefPage] = useState(1);
|
||||
const [refTotal, setRefTotal] = useState(0);
|
||||
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [codesRes, referralsRes, statsRes] = await Promise.all([
|
||||
api.get('/social/referrals/codes', { params: { limit: 50 } }),
|
||||
api.get('/social/referrals/my-referrals', { params: { page: refPage, limit: 10 } }),
|
||||
api.get('/social/referrals/stats'),
|
||||
]);
|
||||
setCodes(codesRes.data.codes);
|
||||
setReferrals(referralsRes.data.referrals);
|
||||
setRefTotal(referralsRes.data.pagination.total);
|
||||
setStats(statsRes.data);
|
||||
} catch {
|
||||
message.error('Failed to load referral data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleCreate = async (values: { maxUses?: number; expiresInDays?: number; note?: string }) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post('/social/referrals/codes', values);
|
||||
message.success('Invite code created');
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch {
|
||||
message.error('Failed to create invite code');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (id: string) => {
|
||||
setDeactivatingId(id);
|
||||
try {
|
||||
await api.delete(`/social/referrals/codes/${id}`);
|
||||
message.success('Code deactivated');
|
||||
loadData();
|
||||
} catch {
|
||||
message.error('Failed to deactivate code');
|
||||
} finally {
|
||||
setDeactivatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const referralColumns: ColumnsType<ReferralRow> = [
|
||||
{
|
||||
title: 'Person',
|
||||
key: 'person',
|
||||
render: (_: unknown, r: ReferralRow) => r.referredUser.name || r.referredUser.email,
|
||||
},
|
||||
{
|
||||
title: 'Date',
|
||||
dataIndex: 'completedAt',
|
||||
key: 'date',
|
||||
render: (v: string) => dayjs(v).format('MMM D, YYYY'),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 16 : 24 }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>My Referrals</Typography.Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
Create Invite Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Total Referrals"
|
||||
value={stats?.totalReferrals ?? 0}
|
||||
prefix={<TeamOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="This Month"
|
||||
value={stats?.thisMonth ?? 0}
|
||||
prefix={<GiftOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Active Codes" value={codes.filter((c) => c.isActive).length} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Invite Codes */}
|
||||
<Card title="My Invite Codes" size="small">
|
||||
{codes.length === 0 ? (
|
||||
<Empty description="No invite codes yet. Create one to start referring people!" />
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{codes.map((code) => (
|
||||
<InviteCodeCard
|
||||
key={code.id}
|
||||
code={code}
|
||||
onDeactivate={handleDeactivate}
|
||||
deactivating={deactivatingId === code.id}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* My Referrals */}
|
||||
<Card title="People I Referred" size="small">
|
||||
<Table
|
||||
dataSource={referrals}
|
||||
columns={referralColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{
|
||||
current: refPage,
|
||||
total: refTotal,
|
||||
pageSize: 10,
|
||||
onChange: setRefPage,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
locale={{ emptyText: 'No referrals yet' }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
{/* Create Code Modal */}
|
||||
<Modal
|
||||
title="Create Invite Code"
|
||||
open={createOpen}
|
||||
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={creating}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="maxUses" label="Max Uses (0 = unlimited)">
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0" />
|
||||
</Form.Item>
|
||||
<Form.Item name="expiresInDays" label="Expires In (days)">
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} placeholder="No expiry" />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="Note">
|
||||
<Input.TextArea maxLength={200} rows={2} placeholder="Optional note for your reference" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -16,7 +16,7 @@ interface AuthState {
|
||||
|
||||
interface AuthActions {
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (name: string, email: string, password: string) => Promise<{ requiresVerification?: boolean }>;
|
||||
register: (name: string, email: string, password: string, inviteCode?: string) => Promise<{ requiresVerification?: boolean }>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
fetchMe: () => Promise<void>;
|
||||
@ -61,13 +61,14 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
}
|
||||
},
|
||||
|
||||
register: async (name: string, email: string, password: string) => {
|
||||
register: async (name: string, email: string, password: string, inviteCode?: string) => {
|
||||
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
|
||||
try {
|
||||
const { data } = await api.post<AuthResponse>('/auth/register', {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
...(inviteCode ? { inviteCode } : {}),
|
||||
});
|
||||
|
||||
// If verification is required, don't set tokens — user needs to verify email first
|
||||
|
||||
@ -1150,6 +1150,9 @@ export interface SiteSettings {
|
||||
enableSocial: boolean;
|
||||
enableMeet: boolean;
|
||||
enableMeetingPlanner: boolean;
|
||||
enableTicketedEvents: boolean;
|
||||
enableSocialCalendar: boolean;
|
||||
requireEventApproval: boolean;
|
||||
autoSyncPeopleToMap: boolean;
|
||||
// SMS connection config (only present from admin endpoint)
|
||||
smsTermuxApiUrl?: string;
|
||||
@ -1354,6 +1357,61 @@ export interface PangolinConnectedClient {
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
// --- Pangolin Resource Status ---
|
||||
|
||||
export interface ResourceStatusItem {
|
||||
name: string;
|
||||
subdomain: string | null;
|
||||
fullDomain: string;
|
||||
required: boolean;
|
||||
container: string | null;
|
||||
profile: string | null;
|
||||
expectedTargetIp: string | null;
|
||||
expectedTargetPort: number | null;
|
||||
exists: boolean;
|
||||
resourceId: string | number | null;
|
||||
hasTarget: boolean;
|
||||
targetCorrect: boolean;
|
||||
actualTargetIp: string | null;
|
||||
actualTargetPort: number | null;
|
||||
targetId: string | null;
|
||||
targetEnabled: boolean | null;
|
||||
ssl: boolean | null;
|
||||
sso: boolean | null;
|
||||
blockAccess: boolean | null;
|
||||
enabled: boolean | null;
|
||||
}
|
||||
|
||||
export interface ResourceStatusResponse {
|
||||
resources: ResourceStatusItem[];
|
||||
extras: ResourceStatusItem[];
|
||||
summary: {
|
||||
total: number;
|
||||
healthy: number;
|
||||
misconfigured: number;
|
||||
missing: number;
|
||||
extras: number;
|
||||
};
|
||||
siteId: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
created: number;
|
||||
targetFixed: number;
|
||||
skipped: number;
|
||||
warnings: number;
|
||||
errors: number;
|
||||
details: {
|
||||
created: string[];
|
||||
targetFixed: string[];
|
||||
skipped: string[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// --- Listmonk ---
|
||||
|
||||
export interface ListmonkStatus {
|
||||
@ -2253,7 +2311,7 @@ export interface DashboardRecentSignupsResult {
|
||||
|
||||
export interface UnifiedCalendarItem {
|
||||
id: string;
|
||||
type: 'shift' | 'event' | 'poll';
|
||||
type: 'shift' | 'event' | 'poll' | 'ticketed_event';
|
||||
title: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
@ -2269,6 +2327,13 @@ export interface UnifiedCalendarItem {
|
||||
pollSlug?: string;
|
||||
pollStatus?: SchedulingPollStatus;
|
||||
pollVoteCount?: number;
|
||||
ticketedEventId?: string;
|
||||
eventSlug?: string;
|
||||
eventFormat?: string;
|
||||
hasPaidTiers?: boolean;
|
||||
isSoldOut?: boolean;
|
||||
maxAttendees?: number | null;
|
||||
currentAttendees?: number;
|
||||
}
|
||||
|
||||
export interface UnifiedCalendarResponse {
|
||||
@ -2903,3 +2968,156 @@ export interface UpgradeStatusResponse {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
// --- Social Calendar Types ---
|
||||
|
||||
export type CalendarLayerType = 'SYSTEM' | 'USER' | 'EXTERNAL';
|
||||
export type CalendarSystemType = 'SHIFTS' | 'TICKETS' | 'POLLS' | 'PUBLIC_EVENTS';
|
||||
export type CalendarVisibility = 'PRIVATE' | 'FRIENDS' | 'PUBLIC';
|
||||
export type CalendarItemType = 'EVENT' | 'TIME_BLOCK' | 'REMINDER';
|
||||
export type CalendarBusyStatus = 'BUSY' | 'TENTATIVE' | 'FREE';
|
||||
export type CalendarShowDetailsTo = 'NOBODY' | 'FRIENDS' | 'EVERYONE';
|
||||
export type CalendarRecurrenceFrequency = 'DAILY' | 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY';
|
||||
export type SeriesEditScope = 'THIS_ONLY' | 'THIS_AND_FUTURE' | 'ALL';
|
||||
|
||||
export interface CalendarRecurrenceRule {
|
||||
frequency: CalendarRecurrenceFrequency;
|
||||
daysOfWeek?: number[]; // 1=Mon...7=Sun
|
||||
dayOfMonth?: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export interface CalendarLayer {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
layerType: CalendarLayerType;
|
||||
systemType: CalendarSystemType | null;
|
||||
color: string;
|
||||
visibility: CalendarVisibility;
|
||||
isEnabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface CalendarItemData {
|
||||
id: string;
|
||||
userId: string;
|
||||
layerId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isAllDay: boolean;
|
||||
itemType: CalendarItemType;
|
||||
location: string | null;
|
||||
color: string | null;
|
||||
visibility: CalendarVisibility | null;
|
||||
busyStatus: CalendarBusyStatus;
|
||||
showDetailsTo: CalendarShowDetailsTo;
|
||||
recurrenceRule: CalendarRecurrenceRule | null;
|
||||
recurrenceEnd: string | null;
|
||||
seriesId: string | null;
|
||||
isException: boolean;
|
||||
sourceType: 'MANUAL' | 'ICS_FEED';
|
||||
sourceId: string | null;
|
||||
}
|
||||
|
||||
export interface PersonalCalendarResponse {
|
||||
dates: Record<string, { count: number; items: PersonalCalendarItem[] }>;
|
||||
}
|
||||
|
||||
export interface PersonalCalendarItem {
|
||||
id: string;
|
||||
type: 'shift' | 'event' | 'ticket' | 'poll' | 'personal';
|
||||
layerId: string;
|
||||
title: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isAllDay: boolean;
|
||||
location: string | null;
|
||||
color: string;
|
||||
itemType: CalendarItemType;
|
||||
busyStatus: CalendarBusyStatus;
|
||||
showDetailsTo: CalendarShowDetailsTo;
|
||||
// For personal items
|
||||
calendarItemId?: string;
|
||||
seriesId?: string | null;
|
||||
isRecurring?: boolean;
|
||||
// For system items
|
||||
shiftId?: string;
|
||||
ticketId?: string;
|
||||
pollId?: string;
|
||||
}
|
||||
|
||||
// --- Shared Calendar Types ---
|
||||
|
||||
export type SharedViewType = 'MANUAL' | 'ROLE_BASED';
|
||||
export type SharedViewScope = 'MEMBERS' | 'PUBLIC';
|
||||
export type SharedViewMemberStatus = 'INVITED' | 'ACCEPTED' | 'DECLINED';
|
||||
|
||||
export interface SharedCalendarView {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
ownerId: string;
|
||||
viewType: SharedViewType;
|
||||
includedLayerTypes: string[];
|
||||
shareScope: SharedViewScope;
|
||||
shareToken: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
owner?: { id: string; name: string | null; email: string };
|
||||
_count?: { members: number };
|
||||
myStatus?: SharedViewMemberStatus;
|
||||
}
|
||||
|
||||
export interface SharedCalendarMember {
|
||||
id: string;
|
||||
viewId: string;
|
||||
userId: string;
|
||||
status: SharedViewMemberStatus;
|
||||
color: string;
|
||||
joinedAt: string | null;
|
||||
user: { id: string; name: string | null; email: string };
|
||||
}
|
||||
|
||||
export interface SharedViewComment {
|
||||
id: string;
|
||||
viewId: string;
|
||||
userId: string;
|
||||
itemDate: string;
|
||||
itemId: string | null;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
user: { id: string; name: string | null; email: string };
|
||||
}
|
||||
|
||||
export interface SharedViewReactionGroup {
|
||||
itemId: string;
|
||||
emoji: string;
|
||||
count: number;
|
||||
users: { id: string; name: string | null }[];
|
||||
hasReacted: boolean;
|
||||
}
|
||||
|
||||
export interface SharedCalendarItem extends PersonalCalendarItem {
|
||||
memberColor: string;
|
||||
memberName: string;
|
||||
memberId: string;
|
||||
}
|
||||
|
||||
export interface AvailabilitySlot {
|
||||
time: string;
|
||||
members: { userId: string; userName: string; status: 'free' | 'busy' | 'tentative' }[];
|
||||
allFree: boolean;
|
||||
}
|
||||
|
||||
export interface AvailabilityDay {
|
||||
slots: AvailabilitySlot[];
|
||||
}
|
||||
|
||||
export interface AvailabilityResponse {
|
||||
dates: Record<string, AvailabilityDay>;
|
||||
}
|
||||
|
||||
|
||||
15
admin/src/vite-env.d.ts
vendored
15
admin/src/vite-env.d.ts
vendored
@ -23,3 +23,18 @@ declare module 'grapesjs-touch' {
|
||||
const plugin: Plugin;
|
||||
export default plugin;
|
||||
}
|
||||
|
||||
declare module 'html5-qrcode' {
|
||||
export class Html5QrcodeScanner {
|
||||
constructor(
|
||||
elementId: string,
|
||||
config: { fps: number; qrbox: { width: number; height: number } },
|
||||
verbose: boolean,
|
||||
);
|
||||
render(
|
||||
onScanSuccess: (decodedText: string) => void,
|
||||
onScanFailure: (error: string) => void,
|
||||
): void;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,249 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ImpactStoryType" AS ENUM ('MILESTONE', 'VICTORY', 'RESPONSE', 'CUSTOM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ImpactStoryStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SpotlightStatus" AS ENUM ('NOMINATED', 'APPROVED', 'FEATURED', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChallengeStatus" AS ENUM ('DRAFT', 'UPCOMING', 'ACTIVE', 'COMPLETED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChallengeMetric" AS ENUM ('DOORS_KNOCKED', 'EMAILS_SENT', 'SHIFTS_ATTENDED', 'RESPONSES_SUBMITTED', 'REFERRALS_MADE');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'impact_story';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'referral_completed';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'challenge_update';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "privacy_settings" ADD COLUMN "show_on_leaderboard" BOOLEAN DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "invite_codes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"created_by_user_id" TEXT NOT NULL,
|
||||
"max_uses" INTEGER NOT NULL DEFAULT 0,
|
||||
"used_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"expires_at" TIMESTAMP(3),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"note" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "invite_codes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "referrals" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"referrer_id" TEXT NOT NULL,
|
||||
"referred_user_id" TEXT NOT NULL,
|
||||
"invite_code_id" TEXT,
|
||||
"referral_source" TEXT,
|
||||
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "referrals_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "impact_stories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaign_id" TEXT NOT NULL,
|
||||
"type" "ImpactStoryType" NOT NULL,
|
||||
"status" "ImpactStoryStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"cover_image_url" TEXT,
|
||||
"milestone_value" INTEGER,
|
||||
"milestone_metric" TEXT,
|
||||
"created_by_user_id" TEXT,
|
||||
"published_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "impact_stories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "campaign_milestones" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"campaign_id" TEXT NOT NULL,
|
||||
"metric" TEXT NOT NULL,
|
||||
"threshold" INTEGER NOT NULL,
|
||||
"reached_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"story_generated" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "campaign_milestones_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "volunteer_spotlights" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"status" "SpotlightStatus" NOT NULL DEFAULT 'NOMINATED',
|
||||
"headline" TEXT,
|
||||
"story" TEXT,
|
||||
"featured_month" TEXT,
|
||||
"nominated_by_user_id" TEXT,
|
||||
"approved_by_user_id" TEXT,
|
||||
"approved_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "volunteer_spotlights_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "challenges" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"metric" "ChallengeMetric" NOT NULL,
|
||||
"status" "ChallengeStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"starts_at" TIMESTAMP(3) NOT NULL,
|
||||
"ends_at" TIMESTAMP(3) NOT NULL,
|
||||
"min_team_size" INTEGER NOT NULL DEFAULT 2,
|
||||
"max_team_size" INTEGER NOT NULL DEFAULT 10,
|
||||
"max_teams" INTEGER,
|
||||
"created_by_user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "challenges_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "challenge_teams" (
|
||||
"id" TEXT NOT NULL,
|
||||
"challenge_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"captain_user_id" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_scored_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "challenge_teams_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "challenge_team_members" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"team_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "challenge_team_members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "invite_codes_code_key" ON "invite_codes"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_invite_codes_code" ON "invite_codes"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_invite_codes_created_by" ON "invite_codes"("created_by_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "referrals_referred_user_id_key" ON "referrals"("referred_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_referrals_referrer" ON "referrals"("referrer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_impact_stories_campaign" ON "impact_stories"("campaign_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_impact_stories_status" ON "impact_stories"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_impact_stories_type" ON "impact_stories"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "campaign_milestones_campaign_id_metric_threshold_key" ON "campaign_milestones"("campaign_id", "metric", "threshold");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_volunteer_spotlights_user" ON "volunteer_spotlights"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_volunteer_spotlights_status" ON "volunteer_spotlights"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_volunteer_spotlights_month" ON "volunteer_spotlights"("featured_month");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_challenges_status" ON "challenges"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_challenges_starts_at" ON "challenges"("starts_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_challenge_teams_challenge" ON "challenge_teams"("challenge_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_challenge_teams_score" ON "challenge_teams"("score");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "challenge_teams_challenge_id_name_key" ON "challenge_teams"("challenge_id", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_challenge_team_members_user" ON "challenge_team_members"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "challenge_team_members_team_id_user_id_key" ON "challenge_team_members"("team_id", "user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invite_codes" ADD CONSTRAINT "invite_codes_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_referrer_id_fkey" FOREIGN KEY ("referrer_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_referred_user_id_fkey" FOREIGN KEY ("referred_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "referrals" ADD CONSTRAINT "referrals_invite_code_id_fkey" FOREIGN KEY ("invite_code_id") REFERENCES "invite_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "impact_stories" ADD CONSTRAINT "impact_stories_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "impact_stories" ADD CONSTRAINT "impact_stories_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "campaign_milestones" ADD CONSTRAINT "campaign_milestones_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "volunteer_spotlights" ADD CONSTRAINT "volunteer_spotlights_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "volunteer_spotlights" ADD CONSTRAINT "volunteer_spotlights_nominated_by_user_id_fkey" FOREIGN KEY ("nominated_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "volunteer_spotlights" ADD CONSTRAINT "volunteer_spotlights_approved_by_user_id_fkey" FOREIGN KEY ("approved_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "challenges" ADD CONSTRAINT "challenges_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "challenge_teams" ADD CONSTRAINT "challenge_teams_challenge_id_fkey" FOREIGN KEY ("challenge_id") REFERENCES "challenges"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "challenge_teams" ADD CONSTRAINT "challenge_teams_captain_user_id_fkey" FOREIGN KEY ("captain_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "challenge_team_members" ADD CONSTRAINT "challenge_team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "challenge_teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "challenge_team_members" ADD CONSTRAINT "challenge_team_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -0,0 +1,176 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketedEventStatus" AS ENUM ('DRAFT', 'PENDING_APPROVAL', 'PUBLISHED', 'CANCELLED', 'COMPLETED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketedEventVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'PRIVATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketTierType" AS ENUM ('PAID', 'FREE', 'DONATION');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketStatus" AS ENUM ('VALID', 'CHECKED_IN', 'CANCELLED', 'REFUNDED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN "enable_ticketed_events" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "require_event_approval" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ticketed_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"rich_description" TEXT,
|
||||
"date" DATE NOT NULL,
|
||||
"start_time" TEXT NOT NULL,
|
||||
"end_time" TEXT NOT NULL,
|
||||
"doors_open_time" TEXT,
|
||||
"venue_name" TEXT,
|
||||
"venue_address" TEXT,
|
||||
"latitude" DECIMAL(10,7),
|
||||
"longitude" DECIMAL(10,7),
|
||||
"status" "TicketedEventStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"visibility" "TicketedEventVisibility" NOT NULL DEFAULT 'PUBLIC',
|
||||
"invite_code" TEXT,
|
||||
"cover_image_url" TEXT,
|
||||
"max_attendees" INTEGER,
|
||||
"current_attendees" INTEGER NOT NULL DEFAULT 0,
|
||||
"gancio_event_id" INTEGER,
|
||||
"created_by_user_id" TEXT NOT NULL,
|
||||
"organizer_name" TEXT,
|
||||
"organizer_email" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ticketed_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ticket_tiers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"event_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"tier_type" "TicketTierType" NOT NULL,
|
||||
"price_cad" INTEGER NOT NULL DEFAULT 0,
|
||||
"min_donation_cad" INTEGER,
|
||||
"max_quantity" INTEGER,
|
||||
"sold_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"max_per_order" INTEGER NOT NULL DEFAULT 10,
|
||||
"sales_start_at" TIMESTAMP(3),
|
||||
"sales_end_at" TIMESTAMP(3),
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ticket_tiers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tickets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticket_code" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"event_id" TEXT NOT NULL,
|
||||
"tier_id" TEXT NOT NULL,
|
||||
"order_id" TEXT,
|
||||
"holder_email" TEXT NOT NULL,
|
||||
"holder_name" TEXT,
|
||||
"user_id" TEXT,
|
||||
"status" "TicketStatus" NOT NULL DEFAULT 'VALID',
|
||||
"checked_in_at" TIMESTAMP(3),
|
||||
"checked_in_by_user_id" TEXT,
|
||||
"issued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "check_ins" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticket_id" TEXT NOT NULL,
|
||||
"event_id" TEXT NOT NULL,
|
||||
"checked_in_by_user_id" TEXT,
|
||||
"method" TEXT NOT NULL,
|
||||
"checked_in_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"notes" TEXT,
|
||||
|
||||
CONSTRAINT "check_ins_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ticketed_events_slug_key" ON "ticketed_events"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ticketed_events_invite_code_key" ON "ticketed_events"("invite_code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_ticketed_events_status" ON "ticketed_events"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_ticketed_events_date" ON "ticketed_events"("date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_ticketed_events_visibility" ON "ticketed_events"("visibility");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_ticketed_events_creator" ON "ticketed_events"("created_by_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_ticket_tiers_event" ON "ticket_tiers"("event_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tickets_ticket_code_key" ON "tickets"("ticket_code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tickets_token_hash_key" ON "tickets"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tickets_event" ON "tickets"("event_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tickets_tier" ON "tickets"("tier_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tickets_order" ON "tickets"("order_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tickets_holder_email" ON "tickets"("holder_email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tickets_status" ON "tickets"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_checkins_event" ON "check_ins"("event_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_checkins_ticket" ON "check_ins"("ticket_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ticketed_events" ADD CONSTRAINT "ticketed_events_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ticket_tiers" ADD CONSTRAINT "ticket_tiers_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "ticketed_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "ticketed_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_tier_id_fkey" FOREIGN KEY ("tier_id") REFERENCES "ticket_tiers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "ticketed_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_checked_in_by_user_id_fkey" FOREIGN KEY ("checked_in_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,12 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EventFormat" AS ENUM ('IN_PERSON', 'ONLINE', 'HYBRID');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ticketed_events" ADD COLUMN "event_format" "EventFormat" NOT NULL DEFAULT 'IN_PERSON',
|
||||
ADD COLUMN "meeting_id" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ticketed_events_meeting_id_key" ON "ticketed_events"("meeting_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ticketed_events" ADD CONSTRAINT "ticketed_events_meeting_id_fkey" FOREIGN KEY ("meeting_id") REFERENCES "meetings"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,258 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarLayerType" AS ENUM ('SYSTEM', 'USER', 'EXTERNAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarSystemType" AS ENUM ('SHIFTS', 'TICKETS', 'POLLS', 'PUBLIC_EVENTS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarVisibility" AS ENUM ('PRIVATE', 'FRIENDS', 'PUBLIC');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarItemType" AS ENUM ('EVENT', 'TIME_BLOCK', 'REMINDER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarBusyStatus" AS ENUM ('BUSY', 'TENTATIVE', 'FREE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarShowDetailsTo" AS ENUM ('NOBODY', 'FRIENDS', 'EVERYONE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarItemSource" AS ENUM ('MANUAL', 'ICS_FEED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarRecurrenceFrequency" AS ENUM ('DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarFeedStatus" AS ENUM ('OK', 'ERROR', 'PENDING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarFeedInterval" AS ENUM ('FIFTEEN_MIN', 'HOURLY', 'SIX_HOUR', 'DAILY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SharedViewType" AS ENUM ('MANUAL', 'ROLE_BASED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SharedViewScope" AS ENUM ('MEMBERS', 'PUBLIC');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SharedViewMemberStatus" AS ENUM ('INVITED', 'ACCEPTED', 'DECLINED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN "enable_social_calendar" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "calendar_layers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"layer_type" "CalendarLayerType" NOT NULL,
|
||||
"system_type" "CalendarSystemType",
|
||||
"color" TEXT NOT NULL DEFAULT '#1890ff',
|
||||
"visibility" "CalendarVisibility" NOT NULL DEFAULT 'PRIVATE',
|
||||
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "calendar_layers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "calendar_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"layer_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"date" DATE NOT NULL,
|
||||
"start_time" TEXT NOT NULL,
|
||||
"end_time" TEXT NOT NULL,
|
||||
"is_all_day" BOOLEAN NOT NULL DEFAULT false,
|
||||
"item_type" "CalendarItemType" NOT NULL DEFAULT 'EVENT',
|
||||
"location" TEXT,
|
||||
"color" TEXT,
|
||||
"visibility" "CalendarVisibility",
|
||||
"busy_status" "CalendarBusyStatus" NOT NULL DEFAULT 'BUSY',
|
||||
"show_details_to" "CalendarShowDetailsTo" NOT NULL DEFAULT 'FRIENDS',
|
||||
"recurrence_rule" JSONB,
|
||||
"recurrence_end" TIMESTAMP(3),
|
||||
"series_id" TEXT,
|
||||
"is_exception" BOOLEAN NOT NULL DEFAULT false,
|
||||
"source_type" "CalendarItemSource" NOT NULL DEFAULT 'MANUAL',
|
||||
"source_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "calendar_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "calendar_feeds" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"layer_id" TEXT NOT NULL,
|
||||
"refresh_interval" "CalendarFeedInterval" NOT NULL DEFAULT 'HOURLY',
|
||||
"last_fetched_at" TIMESTAMP(3),
|
||||
"last_status" "CalendarFeedStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"last_error" TEXT,
|
||||
"item_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "calendar_feeds_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "shared_calendar_views" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"owner_id" TEXT NOT NULL,
|
||||
"view_type" "SharedViewType" NOT NULL DEFAULT 'MANUAL',
|
||||
"auto_include_roles" JSONB,
|
||||
"included_layer_types" JSONB NOT NULL DEFAULT '[]',
|
||||
"share_scope" "SharedViewScope" NOT NULL DEFAULT 'MEMBERS',
|
||||
"share_token" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "shared_calendar_views_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "shared_calendar_members" (
|
||||
"id" TEXT NOT NULL,
|
||||
"view_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"status" "SharedViewMemberStatus" NOT NULL DEFAULT 'INVITED',
|
||||
"color" TEXT NOT NULL DEFAULT '#1890ff',
|
||||
"joined_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "shared_calendar_members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "shared_view_comments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"view_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"item_date" TEXT NOT NULL,
|
||||
"item_id" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "shared_view_comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "shared_view_reactions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"view_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"item_id" TEXT NOT NULL,
|
||||
"emoji" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "shared_view_reactions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "calendar_export_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"include_personal" BOOLEAN NOT NULL DEFAULT false,
|
||||
"include_layers" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "calendar_export_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_calendar_layers_user" ON "calendar_layers"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "idx_calendar_layers_user_system" ON "calendar_layers"("user_id", "system_type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_calendar_items_user_date" ON "calendar_items"("user_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_calendar_items_layer_date" ON "calendar_items"("layer_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_calendar_items_series" ON "calendar_items"("series_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_calendar_items_source" ON "calendar_items"("source_type", "source_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "calendar_feeds_layer_id_key" ON "calendar_feeds"("layer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_calendar_feeds_user" ON "calendar_feeds"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "shared_calendar_views_share_token_key" ON "shared_calendar_views"("share_token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_shared_views_owner" ON "shared_calendar_views"("owner_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_shared_members_user" ON "shared_calendar_members"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "idx_shared_members_view_user" ON "shared_calendar_members"("view_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_shared_comments_view_date" ON "shared_view_comments"("view_id", "item_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "idx_shared_reactions_unique" ON "shared_view_reactions"("view_id", "user_id", "item_id", "emoji");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "calendar_export_tokens_token_key" ON "calendar_export_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_calendar_export_tokens_user" ON "calendar_export_tokens"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_layers" ADD CONSTRAINT "calendar_layers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_items" ADD CONSTRAINT "calendar_items_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_items" ADD CONSTRAINT "calendar_items_layer_id_fkey" FOREIGN KEY ("layer_id") REFERENCES "calendar_layers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_feeds" ADD CONSTRAINT "calendar_feeds_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_feeds" ADD CONSTRAINT "calendar_feeds_layer_id_fkey" FOREIGN KEY ("layer_id") REFERENCES "calendar_layers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "shared_calendar_views" ADD CONSTRAINT "shared_calendar_views_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "shared_calendar_members" ADD CONSTRAINT "shared_calendar_members_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "shared_calendar_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "shared_calendar_members" ADD CONSTRAINT "shared_calendar_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "shared_view_comments" ADD CONSTRAINT "shared_view_comments_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "shared_calendar_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "shared_view_comments" ADD CONSTRAINT "shared_view_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "shared_view_reactions" ADD CONSTRAINT "shared_view_reactions_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "shared_calendar_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "shared_view_reactions" ADD CONSTRAINT "shared_view_reactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_export_tokens" ADD CONSTRAINT "calendar_export_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,11 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'shared_view_invite';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'shared_view_accepted';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'calendar_event_invite';
|
||||
@ -160,6 +160,39 @@ model User {
|
||||
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
|
||||
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
|
||||
|
||||
// Referral system
|
||||
inviteCodesCreated InviteCode[] @relation("InviteCodesCreated")
|
||||
referralsMade Referral[] @relation("ReferralsMade")
|
||||
referredBy Referral? @relation("ReferredBy")
|
||||
|
||||
// Impact Stories
|
||||
impactStoriesCreated ImpactStory[] @relation("ImpactStoryCreator")
|
||||
|
||||
// Volunteer Spotlight
|
||||
spotlights VolunteerSpotlight[] @relation("SpotlightUser")
|
||||
spotlightNominations VolunteerSpotlight[] @relation("SpotlightNominator")
|
||||
spotlightApprovals VolunteerSpotlight[] @relation("SpotlightApprover")
|
||||
|
||||
// Team Challenges
|
||||
challengesCreated Challenge[] @relation("ChallengesCreated")
|
||||
challengeTeamsCaptained ChallengeTeam[] @relation("ChallengeTeamsCaptained")
|
||||
challengeParticipations ChallengeTeamMember[] @relation("ChallengeParticipations")
|
||||
|
||||
// Ticketed Events
|
||||
ticketedEventsCreated TicketedEvent[] @relation("EventCreator")
|
||||
ticketsHeld Ticket[] @relation("TicketHolder")
|
||||
checkInsMade CheckIn[] @relation("CheckInUser")
|
||||
|
||||
// Social Calendar
|
||||
calendarLayers CalendarLayer[] @relation("CalendarLayerOwner")
|
||||
calendarItems CalendarItem[] @relation("CalendarItemOwner")
|
||||
calendarFeeds CalendarFeed[] @relation("CalendarFeedOwner")
|
||||
sharedCalendarViewsOwned SharedCalendarView[] @relation("SharedViewOwner")
|
||||
sharedCalendarMemberships SharedCalendarMember[] @relation("SharedViewMember")
|
||||
sharedViewComments SharedViewComment[] @relation("SharedViewCommentUser")
|
||||
sharedViewReactions SharedViewReaction[] @relation("SharedViewReactionUser")
|
||||
calendarExportTokens CalendarExportToken[] @relation("CalendarExportTokenOwner")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@ -249,6 +282,8 @@ model Campaign {
|
||||
customRecipients CustomRecipient[]
|
||||
calls Call[]
|
||||
smsCampaigns SmsCampaign[] @relation("SmsCampaigns")
|
||||
stories ImpactStory[] @relation("CampaignStories")
|
||||
milestones CampaignMilestone[] @relation("CampaignMilestones")
|
||||
|
||||
@@index([moderationStatus])
|
||||
@@index([isUserGenerated])
|
||||
@ -898,6 +933,9 @@ model SiteSettings {
|
||||
enableSocial Boolean @default(false) @map("enable_social")
|
||||
enableMeet Boolean @default(false) @map("enable_meet")
|
||||
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
|
||||
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
|
||||
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
|
||||
requireEventApproval Boolean @default(true) @map("require_event_approval")
|
||||
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
||||
|
||||
// SMS connection config (overrides env vars when non-empty)
|
||||
@ -1465,6 +1503,12 @@ enum NotificationType {
|
||||
achievement
|
||||
system
|
||||
group_call
|
||||
impact_story
|
||||
referral_completed
|
||||
challenge_update
|
||||
shared_view_invite
|
||||
shared_view_accepted
|
||||
calendar_event_invite
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@ -2356,6 +2400,7 @@ model PrivacySettings {
|
||||
hidePublicFinishes Boolean? @default(false) @map("hide_public_finishes")
|
||||
allowFriendRequests Boolean? @default(true) @map("allow_friend_requests")
|
||||
closeFriendsOnlyWatching Boolean? @default(false) @map("close_friends_only_watching")
|
||||
showOnLeaderboard Boolean? @default(true) @map("show_on_leaderboard")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @map("updated_at")
|
||||
|
||||
@ -3414,6 +3459,7 @@ model Order {
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
donationPageId String? @map("donation_page_id")
|
||||
donationPage DonationPage? @relation("DonationPageOrders", fields: [donationPageId], references: [id], onDelete: SetNull)
|
||||
tickets Ticket[] @relation("TicketOrder")
|
||||
|
||||
@@index([userId], map: "idx_orders_user")
|
||||
@@index([productId], map: "idx_orders_product")
|
||||
@ -4298,8 +4344,9 @@ model Meeting {
|
||||
endTime DateTime? @map("end_time")
|
||||
|
||||
// Reverse relations (one-to-one)
|
||||
shift Shift? @relation("ShiftMeeting")
|
||||
group SocialGroup? @relation("GroupMeeting")
|
||||
shift Shift? @relation("ShiftMeeting")
|
||||
group SocialGroup? @relation("GroupMeeting")
|
||||
ticketedEvent TicketedEvent? @relation("EventMeeting")
|
||||
|
||||
@@map("meetings")
|
||||
}
|
||||
@ -4403,3 +4450,646 @@ model SchedulingPollComment {
|
||||
@@index([pollId])
|
||||
@@map("scheduling_poll_comments")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SOCIAL: INVITE / REFERRAL SYSTEM
|
||||
// ============================================================================
|
||||
|
||||
model InviteCode {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
maxUses Int @default(0) @map("max_uses") // 0 = unlimited
|
||||
usedCount Int @default(0) @map("used_count")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
note String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
createdBy User @relation("InviteCodesCreated", fields: [createdByUserId], references: [id])
|
||||
referrals Referral[] @relation("InviteCodeReferrals")
|
||||
|
||||
@@index([code], map: "idx_invite_codes_code")
|
||||
@@index([createdByUserId], map: "idx_invite_codes_created_by")
|
||||
@@map("invite_codes")
|
||||
}
|
||||
|
||||
model Referral {
|
||||
id Int @id @default(autoincrement())
|
||||
referrerId String @map("referrer_id")
|
||||
referredUserId String @unique @map("referred_user_id")
|
||||
inviteCodeId String? @map("invite_code_id")
|
||||
referralSource String? @map("referral_source")
|
||||
completedAt DateTime @default(now()) @map("completed_at")
|
||||
|
||||
// Relations
|
||||
referrer User @relation("ReferralsMade", fields: [referrerId], references: [id])
|
||||
referredUser User @relation("ReferredBy", fields: [referredUserId], references: [id])
|
||||
inviteCode InviteCode? @relation("InviteCodeReferrals", fields: [inviteCodeId], references: [id])
|
||||
|
||||
@@index([referrerId], map: "idx_referrals_referrer")
|
||||
@@map("referrals")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SOCIAL: IMPACT STORIES / CAMPAIGN VICTORIES
|
||||
// ============================================================================
|
||||
|
||||
enum ImpactStoryType {
|
||||
MILESTONE
|
||||
VICTORY
|
||||
RESPONSE
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
enum ImpactStoryStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model ImpactStory {
|
||||
id String @id @default(cuid())
|
||||
campaignId String @map("campaign_id")
|
||||
type ImpactStoryType
|
||||
status ImpactStoryStatus @default(DRAFT)
|
||||
title String
|
||||
body String @db.Text
|
||||
coverImageUrl String? @map("cover_image_url")
|
||||
milestoneValue Int? @map("milestone_value")
|
||||
milestoneMetric String? @map("milestone_metric")
|
||||
createdByUserId String? @map("created_by_user_id")
|
||||
publishedAt DateTime? @map("published_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
campaign Campaign @relation("CampaignStories", fields: [campaignId], references: [id])
|
||||
createdBy User? @relation("ImpactStoryCreator", fields: [createdByUserId], references: [id])
|
||||
|
||||
@@index([campaignId], map: "idx_impact_stories_campaign")
|
||||
@@index([status], map: "idx_impact_stories_status")
|
||||
@@index([type], map: "idx_impact_stories_type")
|
||||
@@map("impact_stories")
|
||||
}
|
||||
|
||||
model CampaignMilestone {
|
||||
id Int @id @default(autoincrement())
|
||||
campaignId String @map("campaign_id")
|
||||
metric String // "emails_sent", "verified_responses"
|
||||
threshold Int
|
||||
reachedAt DateTime @default(now()) @map("reached_at")
|
||||
storyGenerated Boolean @default(false) @map("story_generated")
|
||||
|
||||
// Relations
|
||||
campaign Campaign @relation("CampaignMilestones", fields: [campaignId], references: [id])
|
||||
|
||||
@@unique([campaignId, metric, threshold])
|
||||
@@map("campaign_milestones")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SOCIAL: VOLUNTEER SPOTLIGHT / WALL OF FAME
|
||||
// ============================================================================
|
||||
|
||||
enum SpotlightStatus {
|
||||
NOMINATED
|
||||
APPROVED
|
||||
FEATURED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model VolunteerSpotlight {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
status SpotlightStatus @default(NOMINATED)
|
||||
headline String?
|
||||
story String? @db.Text
|
||||
featuredMonth String? @map("featured_month") // "2026-03"
|
||||
nominatedByUserId String? @map("nominated_by_user_id")
|
||||
approvedByUserId String? @map("approved_by_user_id")
|
||||
approvedAt DateTime? @map("approved_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation("SpotlightUser", fields: [userId], references: [id])
|
||||
nominatedBy User? @relation("SpotlightNominator", fields: [nominatedByUserId], references: [id])
|
||||
approvedBy User? @relation("SpotlightApprover", fields: [approvedByUserId], references: [id])
|
||||
|
||||
@@index([userId], map: "idx_volunteer_spotlights_user")
|
||||
@@index([status], map: "idx_volunteer_spotlights_status")
|
||||
@@index([featuredMonth], map: "idx_volunteer_spotlights_month")
|
||||
@@map("volunteer_spotlights")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SOCIAL: TEAM CHALLENGES
|
||||
// ============================================================================
|
||||
|
||||
enum ChallengeStatus {
|
||||
DRAFT
|
||||
UPCOMING
|
||||
ACTIVE
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum ChallengeMetric {
|
||||
DOORS_KNOCKED
|
||||
EMAILS_SENT
|
||||
SHIFTS_ATTENDED
|
||||
RESPONSES_SUBMITTED
|
||||
REFERRALS_MADE
|
||||
}
|
||||
|
||||
model Challenge {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String? @db.Text
|
||||
metric ChallengeMetric
|
||||
status ChallengeStatus @default(DRAFT)
|
||||
startsAt DateTime @map("starts_at")
|
||||
endsAt DateTime @map("ends_at")
|
||||
minTeamSize Int @default(2) @map("min_team_size")
|
||||
maxTeamSize Int @default(10) @map("max_team_size")
|
||||
maxTeams Int? @map("max_teams") // null = unlimited
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
createdBy User @relation("ChallengesCreated", fields: [createdByUserId], references: [id])
|
||||
teams ChallengeTeam[] @relation("ChallengeTeams")
|
||||
|
||||
@@index([status], map: "idx_challenges_status")
|
||||
@@index([startsAt], map: "idx_challenges_starts_at")
|
||||
@@map("challenges")
|
||||
}
|
||||
|
||||
model ChallengeTeam {
|
||||
id String @id @default(cuid())
|
||||
challengeId String @map("challenge_id")
|
||||
name String
|
||||
captainUserId String @map("captain_user_id")
|
||||
score Int @default(0)
|
||||
lastScoredAt DateTime? @map("last_scored_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
challenge Challenge @relation("ChallengeTeams", fields: [challengeId], references: [id])
|
||||
captain User @relation("ChallengeTeamsCaptained", fields: [captainUserId], references: [id])
|
||||
members ChallengeTeamMember[] @relation("ChallengeTeamMembers")
|
||||
|
||||
@@unique([challengeId, name])
|
||||
@@index([challengeId], map: "idx_challenge_teams_challenge")
|
||||
@@index([score], map: "idx_challenge_teams_score")
|
||||
@@map("challenge_teams")
|
||||
}
|
||||
|
||||
model ChallengeTeamMember {
|
||||
id Int @id @default(autoincrement())
|
||||
teamId String @map("team_id")
|
||||
userId String @map("user_id")
|
||||
score Int @default(0)
|
||||
joinedAt DateTime @default(now()) @map("joined_at")
|
||||
|
||||
// Relations
|
||||
team ChallengeTeam @relation("ChallengeTeamMembers", fields: [teamId], references: [id], onDelete: Cascade)
|
||||
user User @relation("ChallengeParticipations", fields: [userId], references: [id])
|
||||
|
||||
@@unique([teamId, userId])
|
||||
@@index([userId], map: "idx_challenge_team_members_user")
|
||||
@@map("challenge_team_members")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TICKETED EVENTS
|
||||
// ============================================================================
|
||||
|
||||
enum TicketedEventStatus {
|
||||
DRAFT
|
||||
PENDING_APPROVAL
|
||||
PUBLISHED
|
||||
CANCELLED
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
enum TicketedEventVisibility {
|
||||
PUBLIC
|
||||
UNLISTED
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
enum TicketTierType {
|
||||
PAID
|
||||
FREE
|
||||
DONATION
|
||||
}
|
||||
|
||||
enum TicketStatus {
|
||||
VALID
|
||||
CHECKED_IN
|
||||
CANCELLED
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
enum EventFormat {
|
||||
IN_PERSON
|
||||
ONLINE
|
||||
HYBRID
|
||||
}
|
||||
|
||||
model TicketedEvent {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
description String? @db.Text
|
||||
richDescription String? @db.Text @map("rich_description")
|
||||
|
||||
// Schedule
|
||||
date DateTime @db.Date
|
||||
startTime String @map("start_time")
|
||||
endTime String @map("end_time")
|
||||
doorsOpenTime String? @map("doors_open_time")
|
||||
|
||||
// Venue
|
||||
venueName String? @map("venue_name")
|
||||
venueAddress String? @map("venue_address")
|
||||
latitude Decimal? @db.Decimal(10, 7)
|
||||
longitude Decimal? @db.Decimal(10, 7)
|
||||
|
||||
// Status
|
||||
status TicketedEventStatus @default(DRAFT)
|
||||
visibility TicketedEventVisibility @default(PUBLIC)
|
||||
inviteCode String? @unique @map("invite_code")
|
||||
|
||||
// Media
|
||||
coverImageUrl String? @map("cover_image_url")
|
||||
|
||||
// Capacity
|
||||
maxAttendees Int? @map("max_attendees")
|
||||
currentAttendees Int @default(0) @map("current_attendees")
|
||||
|
||||
// Gancio sync
|
||||
gancioEventId Int? @map("gancio_event_id")
|
||||
|
||||
// Format & Meeting
|
||||
eventFormat EventFormat @default(IN_PERSON) @map("event_format")
|
||||
meetingId String? @unique @map("meeting_id")
|
||||
|
||||
// Creator
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
organizerName String? @map("organizer_name")
|
||||
organizerEmail String? @map("organizer_email")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
createdBy User @relation("EventCreator", fields: [createdByUserId], references: [id])
|
||||
meeting Meeting? @relation("EventMeeting", fields: [meetingId], references: [id], onDelete: SetNull)
|
||||
ticketTiers TicketTier[] @relation("EventTiers")
|
||||
tickets Ticket[] @relation("EventTickets")
|
||||
checkIns CheckIn[] @relation("EventCheckIns")
|
||||
|
||||
@@index([status], map: "idx_ticketed_events_status")
|
||||
@@index([date], map: "idx_ticketed_events_date")
|
||||
@@index([visibility], map: "idx_ticketed_events_visibility")
|
||||
@@index([createdByUserId], map: "idx_ticketed_events_creator")
|
||||
@@map("ticketed_events")
|
||||
}
|
||||
|
||||
model TicketTier {
|
||||
id String @id @default(cuid())
|
||||
eventId String @map("event_id")
|
||||
name String
|
||||
description String?
|
||||
tierType TicketTierType @map("tier_type")
|
||||
priceCAD Int @default(0) @map("price_cad") // In cents
|
||||
minDonationCAD Int? @map("min_donation_cad") // In cents
|
||||
maxQuantity Int? @map("max_quantity")
|
||||
soldCount Int @default(0) @map("sold_count")
|
||||
maxPerOrder Int @default(10) @map("max_per_order")
|
||||
salesStartAt DateTime? @map("sales_start_at")
|
||||
salesEndAt DateTime? @map("sales_end_at")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
event TicketedEvent @relation("EventTiers", fields: [eventId], references: [id], onDelete: Cascade)
|
||||
tickets Ticket[] @relation("TierTickets")
|
||||
|
||||
@@index([eventId], map: "idx_ticket_tiers_event")
|
||||
@@map("ticket_tiers")
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(cuid())
|
||||
ticketCode String @unique @map("ticket_code")
|
||||
tokenHash String @unique @map("token_hash")
|
||||
eventId String @map("event_id")
|
||||
tierId String @map("tier_id")
|
||||
orderId String? @map("order_id")
|
||||
holderEmail String @map("holder_email")
|
||||
holderName String? @map("holder_name")
|
||||
userId String? @map("user_id")
|
||||
status TicketStatus @default(VALID)
|
||||
checkedInAt DateTime? @map("checked_in_at")
|
||||
checkedInByUserId String? @map("checked_in_by_user_id")
|
||||
issuedAt DateTime @default(now()) @map("issued_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
event TicketedEvent @relation("EventTickets", fields: [eventId], references: [id])
|
||||
tier TicketTier @relation("TierTickets", fields: [tierId], references: [id])
|
||||
order Order? @relation("TicketOrder", fields: [orderId], references: [id])
|
||||
holder User? @relation("TicketHolder", fields: [userId], references: [id])
|
||||
checkIns CheckIn[] @relation("TicketCheckIns")
|
||||
|
||||
@@index([eventId], map: "idx_tickets_event")
|
||||
@@index([tierId], map: "idx_tickets_tier")
|
||||
@@index([orderId], map: "idx_tickets_order")
|
||||
@@index([holderEmail], map: "idx_tickets_holder_email")
|
||||
@@index([status], map: "idx_tickets_status")
|
||||
@@map("tickets")
|
||||
}
|
||||
|
||||
model CheckIn {
|
||||
id String @id @default(cuid())
|
||||
ticketId String @map("ticket_id")
|
||||
eventId String @map("event_id")
|
||||
checkedInByUserId String? @map("checked_in_by_user_id")
|
||||
method String // "QR" | "MANUAL" | "CODE"
|
||||
checkedInAt DateTime @default(now()) @map("checked_in_at")
|
||||
notes String?
|
||||
|
||||
// Relations
|
||||
ticket Ticket @relation("TicketCheckIns", fields: [ticketId], references: [id])
|
||||
event TicketedEvent @relation("EventCheckIns", fields: [eventId], references: [id])
|
||||
checkedInBy User? @relation("CheckInUser", fields: [checkedInByUserId], references: [id])
|
||||
|
||||
@@index([eventId], map: "idx_checkins_event")
|
||||
@@index([ticketId], map: "idx_checkins_ticket")
|
||||
@@map("check_ins")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SOCIAL CALENDAR
|
||||
// ============================================================================
|
||||
|
||||
enum CalendarLayerType {
|
||||
SYSTEM
|
||||
USER
|
||||
EXTERNAL
|
||||
}
|
||||
|
||||
enum CalendarSystemType {
|
||||
SHIFTS
|
||||
TICKETS
|
||||
POLLS
|
||||
PUBLIC_EVENTS
|
||||
}
|
||||
|
||||
enum CalendarVisibility {
|
||||
PRIVATE
|
||||
FRIENDS
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
enum CalendarItemType {
|
||||
EVENT
|
||||
TIME_BLOCK
|
||||
REMINDER
|
||||
}
|
||||
|
||||
enum CalendarBusyStatus {
|
||||
BUSY
|
||||
TENTATIVE
|
||||
FREE
|
||||
}
|
||||
|
||||
enum CalendarShowDetailsTo {
|
||||
NOBODY
|
||||
FRIENDS
|
||||
EVERYONE
|
||||
}
|
||||
|
||||
enum CalendarItemSource {
|
||||
MANUAL
|
||||
ICS_FEED
|
||||
}
|
||||
|
||||
enum CalendarRecurrenceFrequency {
|
||||
DAILY
|
||||
WEEKLY
|
||||
BIWEEKLY
|
||||
MONTHLY
|
||||
}
|
||||
|
||||
enum CalendarFeedStatus {
|
||||
OK
|
||||
ERROR
|
||||
PENDING
|
||||
}
|
||||
|
||||
enum CalendarFeedInterval {
|
||||
FIFTEEN_MIN
|
||||
HOURLY
|
||||
SIX_HOUR
|
||||
DAILY
|
||||
}
|
||||
|
||||
enum SharedViewType {
|
||||
MANUAL
|
||||
ROLE_BASED
|
||||
}
|
||||
|
||||
enum SharedViewScope {
|
||||
MEMBERS
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
enum SharedViewMemberStatus {
|
||||
INVITED
|
||||
ACCEPTED
|
||||
DECLINED
|
||||
}
|
||||
|
||||
model CalendarLayer {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
name String
|
||||
layerType CalendarLayerType @map("layer_type")
|
||||
systemType CalendarSystemType? @map("system_type")
|
||||
color String @default("#1890ff")
|
||||
visibility CalendarVisibility @default(PRIVATE)
|
||||
isEnabled Boolean @default(true) @map("is_enabled")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation("CalendarLayerOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
items CalendarItem[] @relation("CalendarLayerItems")
|
||||
feed CalendarFeed? @relation("CalendarFeedLayer")
|
||||
|
||||
@@unique([userId, systemType], map: "idx_calendar_layers_user_system")
|
||||
@@index([userId], map: "idx_calendar_layers_user")
|
||||
@@map("calendar_layers")
|
||||
}
|
||||
|
||||
model CalendarItem {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
layerId String @map("layer_id")
|
||||
title String
|
||||
description String? @db.Text
|
||||
date DateTime @db.Date
|
||||
startTime String @map("start_time") // HH:MM
|
||||
endTime String @map("end_time") // HH:MM
|
||||
isAllDay Boolean @default(false) @map("is_all_day")
|
||||
itemType CalendarItemType @default(EVENT) @map("item_type")
|
||||
location String?
|
||||
color String?
|
||||
visibility CalendarVisibility? // null = inherit from layer
|
||||
busyStatus CalendarBusyStatus @default(BUSY) @map("busy_status")
|
||||
showDetailsTo CalendarShowDetailsTo @default(FRIENDS) @map("show_details_to")
|
||||
|
||||
// Recurrence
|
||||
recurrenceRule Json? @map("recurrence_rule")
|
||||
recurrenceEnd DateTime? @map("recurrence_end")
|
||||
seriesId String? @map("series_id")
|
||||
isException Boolean @default(false) @map("is_exception")
|
||||
|
||||
// Source tracking
|
||||
sourceType CalendarItemSource @default(MANUAL) @map("source_type")
|
||||
sourceId String? @map("source_id")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation("CalendarItemOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
layer CalendarLayer @relation("CalendarLayerItems", fields: [layerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, date], map: "idx_calendar_items_user_date")
|
||||
@@index([layerId, date], map: "idx_calendar_items_layer_date")
|
||||
@@index([seriesId], map: "idx_calendar_items_series")
|
||||
@@index([sourceType, sourceId], map: "idx_calendar_items_source")
|
||||
@@map("calendar_items")
|
||||
}
|
||||
|
||||
model CalendarFeed {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
name String
|
||||
url String
|
||||
layerId String @unique @map("layer_id")
|
||||
refreshInterval CalendarFeedInterval @default(HOURLY) @map("refresh_interval")
|
||||
lastFetchedAt DateTime? @map("last_fetched_at")
|
||||
lastStatus CalendarFeedStatus @default(PENDING) @map("last_status")
|
||||
lastError String? @map("last_error")
|
||||
itemCount Int @default(0) @map("item_count")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation("CalendarFeedOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
layer CalendarLayer @relation("CalendarFeedLayer", fields: [layerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId], map: "idx_calendar_feeds_user")
|
||||
@@map("calendar_feeds")
|
||||
}
|
||||
|
||||
model SharedCalendarView {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String? @db.Text
|
||||
ownerId String @map("owner_id")
|
||||
viewType SharedViewType @default(MANUAL) @map("view_type")
|
||||
autoIncludeRoles Json? @map("auto_include_roles")
|
||||
includedLayerTypes Json @default("[]") @map("included_layer_types")
|
||||
shareScope SharedViewScope @default(MEMBERS) @map("share_scope")
|
||||
shareToken String? @unique @map("share_token")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
owner User @relation("SharedViewOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
members SharedCalendarMember[] @relation("SharedViewMembers")
|
||||
comments SharedViewComment[] @relation("SharedViewComments")
|
||||
reactions SharedViewReaction[] @relation("SharedViewReactions")
|
||||
|
||||
@@index([ownerId], map: "idx_shared_views_owner")
|
||||
@@map("shared_calendar_views")
|
||||
}
|
||||
|
||||
model SharedCalendarMember {
|
||||
id String @id @default(cuid())
|
||||
viewId String @map("view_id")
|
||||
userId String @map("user_id")
|
||||
status SharedViewMemberStatus @default(INVITED)
|
||||
color String @default("#1890ff")
|
||||
joinedAt DateTime? @map("joined_at")
|
||||
|
||||
// Relations
|
||||
view SharedCalendarView @relation("SharedViewMembers", fields: [viewId], references: [id], onDelete: Cascade)
|
||||
user User @relation("SharedViewMember", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([viewId, userId], map: "idx_shared_members_view_user")
|
||||
@@index([userId], map: "idx_shared_members_user")
|
||||
@@map("shared_calendar_members")
|
||||
}
|
||||
|
||||
model SharedViewComment {
|
||||
id String @id @default(cuid())
|
||||
viewId String @map("view_id")
|
||||
userId String @map("user_id")
|
||||
itemDate String @map("item_date") // YYYY-MM-DD
|
||||
itemId String? @map("item_id") // specific item reference
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
view SharedCalendarView @relation("SharedViewComments", fields: [viewId], references: [id], onDelete: Cascade)
|
||||
user User @relation("SharedViewCommentUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([viewId, itemDate], map: "idx_shared_comments_view_date")
|
||||
@@map("shared_view_comments")
|
||||
}
|
||||
|
||||
model SharedViewReaction {
|
||||
id String @id @default(cuid())
|
||||
viewId String @map("view_id")
|
||||
userId String @map("user_id")
|
||||
itemId String @map("item_id")
|
||||
emoji String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
view SharedCalendarView @relation("SharedViewReactions", fields: [viewId], references: [id], onDelete: Cascade)
|
||||
user User @relation("SharedViewReactionUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([viewId, userId, itemId, emoji], map: "idx_shared_reactions_unique")
|
||||
@@map("shared_view_reactions")
|
||||
}
|
||||
|
||||
model CalendarExportToken {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
token String @unique
|
||||
includePersonal Boolean @default(false) @map("include_personal")
|
||||
includeLayers Json? @map("include_layers")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
user User @relation("CalendarExportTokenOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId], map: "idx_calendar_export_tokens_user")
|
||||
@@map("calendar_export_tokens")
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ export const registerSchema = z.object({
|
||||
.regex(/[0-9]/, 'Password must contain at least one digit'),
|
||||
name: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
inviteCode: z.string().max(20).optional(),
|
||||
// Role removed from public registration - must be set server-side only
|
||||
});
|
||||
|
||||
|
||||
@ -132,6 +132,15 @@ export const authService = {
|
||||
},
|
||||
});
|
||||
|
||||
// Fire-and-forget: process referral if invite code provided
|
||||
if (data.inviteCode) {
|
||||
import('../social/referral.service').then(({ referralService }) => {
|
||||
referralService.processRegistrationReferral(user.id, data.inviteCode).catch(err => {
|
||||
logger.warn('Referral processing failed:', err);
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Fire-and-forget: auto-link or create Contact if People feature is enabled
|
||||
siteSettingsService.get().then(async (s) => {
|
||||
if (!s.enablePeople) return;
|
||||
|
||||
152
api/src/modules/calendar/calendar.routes.ts
Normal file
152
api/src/modules/calendar/calendar.routes.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { calendarService } from './calendar.service';
|
||||
import {
|
||||
createLayerSchema,
|
||||
updateLayerSchema,
|
||||
createItemSchema,
|
||||
updateItemSchema,
|
||||
dateRangeSchema,
|
||||
seriesEditScopeSchema,
|
||||
} from './calendar.schemas';
|
||||
import type { SeriesEditScope } from './calendar.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// =========================================================================
|
||||
// Layers
|
||||
// =========================================================================
|
||||
|
||||
// GET /api/calendar/layers — get all user layers (ensures system layers exist)
|
||||
router.get('/layers', async (req, res, next) => {
|
||||
try {
|
||||
const layers = await calendarService.ensureSystemLayers(req.user!.id);
|
||||
res.json({ layers });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/calendar/layers — create a user layer
|
||||
router.post('/layers', validate(createLayerSchema), async (req, res, next) => {
|
||||
try {
|
||||
const layer = await calendarService.createLayer(req.user!.id, req.body);
|
||||
res.status(201).json({ layer });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/calendar/layers/:id — update a layer
|
||||
router.patch('/layers/:id', validate(updateLayerSchema), async (req, res, next) => {
|
||||
try {
|
||||
const layer = await calendarService.updateLayer(
|
||||
req.user!.id,
|
||||
req.params.id as string,
|
||||
req.body
|
||||
);
|
||||
res.json({ layer });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/calendar/layers/:id — delete a layer (USER/EXTERNAL only)
|
||||
router.delete('/layers/:id', async (req, res, next) => {
|
||||
try {
|
||||
await calendarService.deleteLayer(req.user!.id, req.params.id as string);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Items
|
||||
// =========================================================================
|
||||
|
||||
// GET /api/calendar/items?startDate=&endDate=&layerIds=
|
||||
router.get('/items', validate(dateRangeSchema, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
|
||||
const layerIdsRaw = req.query.layerIds as string | undefined;
|
||||
const layerIds = layerIdsRaw ? layerIdsRaw.split(',').filter(Boolean) : undefined;
|
||||
|
||||
const items = await calendarService.getItems(req.user!.id, startDate, endDate, layerIds);
|
||||
res.json({ items });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/calendar/items — create item
|
||||
router.post('/items', validate(createItemSchema), async (req, res, next) => {
|
||||
try {
|
||||
const item = await calendarService.createItem(req.user!.id, req.body);
|
||||
res.status(201).json({ item });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/calendar/items/:id?scope=THIS_ONLY|THIS_AND_FUTURE|ALL
|
||||
router.patch('/items/:id', validate(updateItemSchema), async (req, res, next) => {
|
||||
try {
|
||||
const scopeRaw = req.query.scope as string | undefined;
|
||||
let scope: SeriesEditScope | undefined;
|
||||
if (scopeRaw) {
|
||||
scope = seriesEditScopeSchema.parse(scopeRaw);
|
||||
}
|
||||
|
||||
const item = await calendarService.updateItem(
|
||||
req.user!.id,
|
||||
req.params.id as string,
|
||||
req.body,
|
||||
scope
|
||||
);
|
||||
res.json({ item });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/calendar/items/:id?scope=THIS_ONLY|THIS_AND_FUTURE|ALL
|
||||
router.delete('/items/:id', async (req, res, next) => {
|
||||
try {
|
||||
const scopeRaw = req.query.scope as string | undefined;
|
||||
let scope: SeriesEditScope | undefined;
|
||||
if (scopeRaw) {
|
||||
scope = seriesEditScopeSchema.parse(scopeRaw);
|
||||
}
|
||||
|
||||
await calendarService.deleteItem(
|
||||
req.user!.id,
|
||||
req.params.id as string,
|
||||
scope
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Personal calendar (merged view)
|
||||
// =========================================================================
|
||||
|
||||
// GET /api/calendar/my?startDate=&endDate=
|
||||
router.get('/my', validate(dateRangeSchema, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query as { startDate: string; endDate: string };
|
||||
const result = await calendarService.getPersonalCalendar(req.user!.id, startDate, endDate);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
80
api/src/modules/calendar/calendar.schemas.ts
Normal file
80
api/src/modules/calendar/calendar.schemas.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// --- Layer schemas ---
|
||||
|
||||
export const createLayerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex color (#RRGGBB)'),
|
||||
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']),
|
||||
});
|
||||
|
||||
export const updateLayerSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex color (#RRGGBB)').optional(),
|
||||
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(),
|
||||
isEnabled: z.boolean().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
// --- Item schemas ---
|
||||
|
||||
const recurrenceRuleSchema = z.object({
|
||||
frequency: z.enum(['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY']),
|
||||
daysOfWeek: z.array(z.number().int().min(1).max(7)).optional(), // 1=Mon...7=Sun
|
||||
dayOfMonth: z.number().int().min(1).max(31).optional(),
|
||||
interval: z.number().int().min(1).max(12).optional(),
|
||||
});
|
||||
|
||||
export const createItemSchema = z.object({
|
||||
layerId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().optional(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
|
||||
isAllDay: z.boolean().optional(),
|
||||
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']),
|
||||
location: z.string().optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
||||
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(),
|
||||
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
|
||||
showDetailsTo: z.enum(['NOBODY', 'FRIENDS', 'EVERYONE']).optional(),
|
||||
recurrenceRule: recurrenceRuleSchema.optional(),
|
||||
recurrenceEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Recurrence end must be YYYY-MM-DD').optional(),
|
||||
});
|
||||
|
||||
export const updateItemSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM').optional(),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM').optional(),
|
||||
isAllDay: z.boolean().optional(),
|
||||
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']).optional(),
|
||||
location: z.string().nullable().optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
|
||||
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).nullable().optional(),
|
||||
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
|
||||
showDetailsTo: z.enum(['NOBODY', 'FRIENDS', 'EVERYONE']).optional(),
|
||||
recurrenceRule: recurrenceRuleSchema.nullable().optional(),
|
||||
recurrenceEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
});
|
||||
|
||||
// --- Scope for recurring item edits ---
|
||||
|
||||
export const seriesEditScopeSchema = z.enum(['THIS_ONLY', 'THIS_AND_FUTURE', 'ALL']);
|
||||
|
||||
// --- Date range query ---
|
||||
|
||||
export const dateRangeSchema = z.object({
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be YYYY-MM-DD'),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'endDate must be YYYY-MM-DD'),
|
||||
});
|
||||
|
||||
// --- Exported types ---
|
||||
|
||||
export type CreateLayerInput = z.infer<typeof createLayerSchema>;
|
||||
export type UpdateLayerInput = z.infer<typeof updateLayerSchema>;
|
||||
export type CreateItemInput = z.infer<typeof createItemSchema>;
|
||||
export type UpdateItemInput = z.infer<typeof updateItemSchema>;
|
||||
export type SeriesEditScope = z.infer<typeof seriesEditScopeSchema>;
|
||||
781
api/src/modules/calendar/calendar.service.ts
Normal file
781
api/src/modules/calendar/calendar.service.ts
Normal file
@ -0,0 +1,781 @@
|
||||
import {
|
||||
CalendarLayerType,
|
||||
CalendarSystemType,
|
||||
CalendarItemType,
|
||||
CalendarBusyStatus,
|
||||
CalendarShowDetailsTo,
|
||||
CalendarItemSource,
|
||||
CalendarVisibility,
|
||||
SignupStatus,
|
||||
ShiftStatus,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import type {
|
||||
CreateLayerInput,
|
||||
UpdateLayerInput,
|
||||
CreateItemInput,
|
||||
UpdateItemInput,
|
||||
SeriesEditScope,
|
||||
} from './calendar.schemas';
|
||||
|
||||
// Shape reused by the personal calendar endpoint
|
||||
interface PersonalCalendarItem {
|
||||
id: string;
|
||||
type: 'calendar_item' | 'shift' | 'ticket' | 'poll' | 'public_event';
|
||||
title: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
startTime: string; // HH:MM
|
||||
endTime: string; // HH:MM
|
||||
location: string | null;
|
||||
color: string | null;
|
||||
itemType: string;
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
layerColor: string;
|
||||
}
|
||||
|
||||
interface PersonalCalendarResponse {
|
||||
dates: Record<string, { count: number; items: PersonalCalendarItem[] }>;
|
||||
}
|
||||
|
||||
// Recurrence rule shape stored in JSON
|
||||
interface RecurrenceRule {
|
||||
frequency: 'DAILY' | 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY';
|
||||
daysOfWeek?: number[]; // 1=Mon...7=Sun
|
||||
dayOfMonth?: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
const SYSTEM_LAYER_DEFS: { systemType: CalendarSystemType; name: string; color: string }[] = [
|
||||
{ systemType: CalendarSystemType.SHIFTS, name: 'My Shifts', color: '#52c41a' },
|
||||
{ systemType: CalendarSystemType.TICKETS, name: 'My Tickets', color: '#fa8c16' },
|
||||
{ systemType: CalendarSystemType.POLLS, name: 'My Polls', color: '#722ed1' },
|
||||
{ systemType: CalendarSystemType.PUBLIC_EVENTS, name: 'Public Events', color: '#1890ff' },
|
||||
];
|
||||
|
||||
const MAX_RECURRENCE_DAYS = 92; // ~3 months
|
||||
|
||||
export const calendarService = {
|
||||
// =========================================================================
|
||||
// Layer management
|
||||
// =========================================================================
|
||||
|
||||
async ensureSystemLayers(userId: string) {
|
||||
for (const def of SYSTEM_LAYER_DEFS) {
|
||||
await prisma.calendarLayer.upsert({
|
||||
where: {
|
||||
userId_systemType: { userId, systemType: def.systemType },
|
||||
},
|
||||
update: {}, // no-op if exists
|
||||
create: {
|
||||
userId,
|
||||
name: def.name,
|
||||
color: def.color,
|
||||
layerType: CalendarLayerType.SYSTEM,
|
||||
systemType: def.systemType,
|
||||
visibility: CalendarVisibility.PRIVATE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getUserLayers(userId);
|
||||
},
|
||||
|
||||
async getUserLayers(userId: string) {
|
||||
return prisma.calendarLayer.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
},
|
||||
|
||||
async createLayer(userId: string, data: CreateLayerInput) {
|
||||
// Get the next sort order
|
||||
const maxSort = await prisma.calendarLayer.aggregate({
|
||||
where: { userId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
const sortOrder = (maxSort._max.sortOrder ?? -1) + 1;
|
||||
|
||||
return prisma.calendarLayer.create({
|
||||
data: {
|
||||
userId,
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
visibility: data.visibility as CalendarVisibility,
|
||||
layerType: CalendarLayerType.USER,
|
||||
sortOrder,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async updateLayer(userId: string, layerId: string, data: UpdateLayerInput) {
|
||||
const layer = await prisma.calendarLayer.findFirst({
|
||||
where: { id: layerId, userId },
|
||||
});
|
||||
|
||||
if (!layer) {
|
||||
throw new AppError(404, 'Layer not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
return prisma.calendarLayer.update({
|
||||
where: { id: layerId },
|
||||
data: {
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.color !== undefined && { color: data.color }),
|
||||
...(data.visibility !== undefined && { visibility: data.visibility as CalendarVisibility }),
|
||||
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
|
||||
...(data.sortOrder !== undefined && { sortOrder: data.sortOrder }),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async deleteLayer(userId: string, layerId: string) {
|
||||
const layer = await prisma.calendarLayer.findFirst({
|
||||
where: { id: layerId, userId },
|
||||
});
|
||||
|
||||
if (!layer) {
|
||||
throw new AppError(404, 'Layer not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
if (layer.layerType === CalendarLayerType.SYSTEM) {
|
||||
throw new AppError(400, 'Cannot delete system layers', 'CANNOT_DELETE_SYSTEM');
|
||||
}
|
||||
|
||||
await prisma.calendarLayer.delete({ where: { id: layerId } });
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Item management
|
||||
// =========================================================================
|
||||
|
||||
async getItems(userId: string, startDate: string, endDate: string, layerIds?: string[]) {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
const where: Prisma.CalendarItemWhereInput = {
|
||||
userId,
|
||||
date: { gte: start, lte: end },
|
||||
};
|
||||
|
||||
if (layerIds && layerIds.length > 0) {
|
||||
where.layerId = { in: layerIds };
|
||||
}
|
||||
|
||||
return prisma.calendarItem.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
||||
include: {
|
||||
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async createItem(userId: string, data: CreateItemInput) {
|
||||
// Verify layer ownership
|
||||
const layer = await prisma.calendarLayer.findFirst({
|
||||
where: { id: data.layerId, userId },
|
||||
});
|
||||
|
||||
if (!layer) {
|
||||
throw new AppError(404, 'Layer not found', 'LAYER_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (layer.layerType === CalendarLayerType.SYSTEM) {
|
||||
throw new AppError(400, 'Cannot add items to system layers', 'CANNOT_ADD_TO_SYSTEM');
|
||||
}
|
||||
|
||||
const recurrenceRule = data.recurrenceRule
|
||||
? (data.recurrenceRule as unknown as Prisma.InputJsonValue)
|
||||
: undefined;
|
||||
|
||||
// Create the template item
|
||||
const templateItem = await prisma.calendarItem.create({
|
||||
data: {
|
||||
userId,
|
||||
layerId: data.layerId,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
date: new Date(data.date),
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
isAllDay: data.isAllDay ?? false,
|
||||
itemType: (data.itemType as CalendarItemType) ?? CalendarItemType.EVENT,
|
||||
location: data.location,
|
||||
color: data.color,
|
||||
visibility: data.visibility as CalendarVisibility | undefined,
|
||||
busyStatus: (data.busyStatus as CalendarBusyStatus) ?? CalendarBusyStatus.BUSY,
|
||||
showDetailsTo: (data.showDetailsTo as CalendarShowDetailsTo) ?? CalendarShowDetailsTo.FRIENDS,
|
||||
recurrenceRule,
|
||||
recurrenceEnd: data.recurrenceEnd ? new Date(data.recurrenceEnd) : undefined,
|
||||
sourceType: CalendarItemSource.MANUAL,
|
||||
},
|
||||
});
|
||||
|
||||
// If recurring, set seriesId to own id and materialize instances
|
||||
if (data.recurrenceRule) {
|
||||
await prisma.calendarItem.update({
|
||||
where: { id: templateItem.id },
|
||||
data: { seriesId: templateItem.id },
|
||||
});
|
||||
|
||||
const startDate = new Date(data.date);
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + MAX_RECURRENCE_DAYS);
|
||||
|
||||
// Limit recurrence end
|
||||
const recEnd = data.recurrenceEnd ? new Date(data.recurrenceEnd) : endDate;
|
||||
const effectiveEnd = recEnd < endDate ? recEnd : endDate;
|
||||
|
||||
await this.materializeRecurrence(
|
||||
{ ...templateItem, seriesId: templateItem.id },
|
||||
startDate,
|
||||
effectiveEnd
|
||||
);
|
||||
}
|
||||
|
||||
return prisma.calendarItem.findUnique({
|
||||
where: { id: templateItem.id },
|
||||
include: {
|
||||
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async updateItem(
|
||||
userId: string,
|
||||
itemId: string,
|
||||
data: UpdateItemInput,
|
||||
scope?: SeriesEditScope
|
||||
) {
|
||||
const item = await prisma.calendarItem.findFirst({
|
||||
where: { id: itemId, userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new AppError(404, 'Item not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
const updateData: Prisma.CalendarItemUncheckedUpdateInput = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.date !== undefined) updateData.date = new Date(data.date);
|
||||
if (data.startTime !== undefined) updateData.startTime = data.startTime;
|
||||
if (data.endTime !== undefined) updateData.endTime = data.endTime;
|
||||
if (data.isAllDay !== undefined) updateData.isAllDay = data.isAllDay;
|
||||
if (data.itemType !== undefined) updateData.itemType = data.itemType as CalendarItemType;
|
||||
if (data.location !== undefined) updateData.location = data.location;
|
||||
if (data.color !== undefined) updateData.color = data.color;
|
||||
if (data.visibility !== undefined) updateData.visibility = data.visibility as CalendarVisibility | null;
|
||||
if (data.busyStatus !== undefined) updateData.busyStatus = data.busyStatus as CalendarBusyStatus;
|
||||
if (data.showDetailsTo !== undefined) updateData.showDetailsTo = data.showDetailsTo as CalendarShowDetailsTo;
|
||||
if (data.recurrenceRule !== undefined) {
|
||||
updateData.recurrenceRule = data.recurrenceRule as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull;
|
||||
}
|
||||
if (data.recurrenceEnd !== undefined) {
|
||||
updateData.recurrenceEnd = data.recurrenceEnd ? new Date(data.recurrenceEnd) : null;
|
||||
}
|
||||
|
||||
// Non-recurring or no scope: simple update
|
||||
if (!item.seriesId || !scope || scope === 'THIS_ONLY') {
|
||||
if (item.seriesId && scope === 'THIS_ONLY') {
|
||||
// Mark as exception so future series updates skip it
|
||||
updateData.isException = true;
|
||||
}
|
||||
|
||||
return prisma.calendarItem.update({
|
||||
where: { id: itemId },
|
||||
data: updateData,
|
||||
include: {
|
||||
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (scope === 'ALL') {
|
||||
// Update all non-exception instances in the series
|
||||
await prisma.calendarItem.updateMany({
|
||||
where: { seriesId: item.seriesId, userId, isException: false },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return prisma.calendarItem.findFirst({
|
||||
where: { id: itemId },
|
||||
include: {
|
||||
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (scope === 'THIS_AND_FUTURE') {
|
||||
// Update this item and all future non-exception instances
|
||||
await prisma.calendarItem.updateMany({
|
||||
where: {
|
||||
seriesId: item.seriesId,
|
||||
userId,
|
||||
isException: false,
|
||||
date: { gte: item.date },
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return prisma.calendarItem.findFirst({
|
||||
where: { id: itemId },
|
||||
include: {
|
||||
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: simple update
|
||||
return prisma.calendarItem.update({
|
||||
where: { id: itemId },
|
||||
data: updateData,
|
||||
include: {
|
||||
layer: { select: { name: true, color: true, layerType: true, systemType: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async deleteItem(userId: string, itemId: string, scope?: SeriesEditScope) {
|
||||
const item = await prisma.calendarItem.findFirst({
|
||||
where: { id: itemId, userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new AppError(404, 'Item not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
// Non-recurring or no scope: delete single item
|
||||
if (!item.seriesId || !scope || scope === 'THIS_ONLY') {
|
||||
await prisma.calendarItem.delete({ where: { id: itemId } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope === 'ALL') {
|
||||
await prisma.calendarItem.deleteMany({
|
||||
where: { seriesId: item.seriesId, userId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope === 'THIS_AND_FUTURE') {
|
||||
await prisma.calendarItem.deleteMany({
|
||||
where: {
|
||||
seriesId: item.seriesId,
|
||||
userId,
|
||||
date: { gte: item.date },
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Recurrence materialization
|
||||
// =========================================================================
|
||||
|
||||
async materializeRecurrence(
|
||||
templateItem: {
|
||||
id: string;
|
||||
userId: string;
|
||||
layerId: string;
|
||||
seriesId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isAllDay: boolean;
|
||||
itemType: CalendarItemType;
|
||||
location: string | null;
|
||||
color: string | null;
|
||||
visibility: CalendarVisibility | null;
|
||||
busyStatus: CalendarBusyStatus;
|
||||
showDetailsTo: CalendarShowDetailsTo;
|
||||
recurrenceRule: unknown;
|
||||
date: Date;
|
||||
},
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) {
|
||||
const rule = templateItem.recurrenceRule as RecurrenceRule | null;
|
||||
if (!rule) return;
|
||||
|
||||
const interval = rule.interval ?? 1;
|
||||
const dates = generateRecurrenceDates(rule, startDate, endDate, interval);
|
||||
|
||||
// Remove the template item's own date (it already exists)
|
||||
const templateDateStr = templateItem.date.toISOString().split('T')[0];
|
||||
const filteredDates = dates.filter(d => d.toISOString().split('T')[0] !== templateDateStr);
|
||||
|
||||
if (filteredDates.length === 0) return;
|
||||
|
||||
// Fetch existing instance dates to avoid duplicates
|
||||
const existing = await prisma.calendarItem.findMany({
|
||||
where: {
|
||||
seriesId: templateItem.seriesId,
|
||||
userId: templateItem.userId,
|
||||
},
|
||||
select: { date: true },
|
||||
});
|
||||
const existingDateSet = new Set(existing.map(e => e.date.toISOString().split('T')[0]));
|
||||
|
||||
const newDates = filteredDates.filter(
|
||||
d => !existingDateSet.has(d.toISOString().split('T')[0])
|
||||
);
|
||||
|
||||
if (newDates.length === 0) return;
|
||||
|
||||
// Batch create
|
||||
await prisma.calendarItem.createMany({
|
||||
data: newDates.map(date => ({
|
||||
userId: templateItem.userId,
|
||||
layerId: templateItem.layerId,
|
||||
title: templateItem.title,
|
||||
description: templateItem.description,
|
||||
date,
|
||||
startTime: templateItem.startTime,
|
||||
endTime: templateItem.endTime,
|
||||
isAllDay: templateItem.isAllDay,
|
||||
itemType: templateItem.itemType,
|
||||
location: templateItem.location,
|
||||
color: templateItem.color,
|
||||
visibility: templateItem.visibility,
|
||||
busyStatus: templateItem.busyStatus,
|
||||
showDetailsTo: templateItem.showDetailsTo,
|
||||
seriesId: templateItem.seriesId,
|
||||
sourceType: CalendarItemSource.MANUAL,
|
||||
})),
|
||||
});
|
||||
|
||||
logger.debug(`Materialized ${newDates.length} recurrence instances for series ${templateItem.seriesId}`);
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Personal calendar (merged view)
|
||||
// =========================================================================
|
||||
|
||||
async getPersonalCalendar(
|
||||
userId: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<PersonalCalendarResponse> {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
// Fetch user's layers
|
||||
const layers = await prisma.calendarLayer.findMany({
|
||||
where: { userId, isEnabled: true },
|
||||
});
|
||||
|
||||
const layerMap = new Map(layers.map(l => [l.id, l]));
|
||||
const systemLayersByType = new Map(
|
||||
layers.filter(l => l.systemType).map(l => [l.systemType!, l])
|
||||
);
|
||||
|
||||
const allItems: PersonalCalendarItem[] = [];
|
||||
|
||||
// 1. User's CalendarItems from enabled layers
|
||||
const enabledLayerIds = layers.map(l => l.id);
|
||||
if (enabledLayerIds.length > 0) {
|
||||
const items = await prisma.calendarItem.findMany({
|
||||
where: {
|
||||
userId,
|
||||
layerId: { in: enabledLayerIds },
|
||||
date: { gte: start, lte: end },
|
||||
},
|
||||
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
const layer = layerMap.get(item.layerId);
|
||||
if (!layer) continue;
|
||||
allItems.push({
|
||||
id: item.id,
|
||||
type: 'calendar_item',
|
||||
title: item.title,
|
||||
date: item.date.toISOString().split('T')[0],
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
location: item.location,
|
||||
color: item.color ?? layer.color,
|
||||
itemType: item.itemType,
|
||||
layerId: layer.id,
|
||||
layerName: layer.name,
|
||||
layerColor: layer.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. System layer: SHIFTS (from ShiftSignup where userId matches)
|
||||
const shiftsLayer = systemLayersByType.get(CalendarSystemType.SHIFTS);
|
||||
if (shiftsLayer) {
|
||||
try {
|
||||
const signups = await prisma.shiftSignup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: SignupStatus.CONFIRMED,
|
||||
shift: {
|
||||
status: { not: ShiftStatus.CANCELLED },
|
||||
date: { gte: start, lte: end },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
shift: {
|
||||
select: { id: true, title: true, date: true, startTime: true, endTime: true, location: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const signup of signups) {
|
||||
const s = signup.shift;
|
||||
allItems.push({
|
||||
id: `shift-${s.id}`,
|
||||
type: 'shift',
|
||||
title: s.title,
|
||||
date: s.date.toISOString().split('T')[0],
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
location: s.location,
|
||||
color: shiftsLayer.color,
|
||||
itemType: 'EVENT',
|
||||
layerId: shiftsLayer.id,
|
||||
layerName: shiftsLayer.name,
|
||||
layerColor: shiftsLayer.color,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('Failed to fetch shifts for personal calendar:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. System layer: TICKETS (from Ticket where userId matches)
|
||||
const ticketsLayer = systemLayersByType.get(CalendarSystemType.TICKETS);
|
||||
if (ticketsLayer) {
|
||||
try {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: 'VALID',
|
||||
event: {
|
||||
date: { gte: start, lte: end },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
event: {
|
||||
select: { id: true, title: true, date: true, startTime: true, endTime: true, venueName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const ticket of tickets) {
|
||||
const e = ticket.event;
|
||||
allItems.push({
|
||||
id: `ticket-${ticket.id}`,
|
||||
type: 'ticket',
|
||||
title: e.title,
|
||||
date: e.date.toISOString().split('T')[0],
|
||||
startTime: e.startTime,
|
||||
endTime: e.endTime,
|
||||
location: e.venueName,
|
||||
color: ticketsLayer.color,
|
||||
itemType: 'EVENT',
|
||||
layerId: ticketsLayer.id,
|
||||
layerName: ticketsLayer.name,
|
||||
layerColor: ticketsLayer.color,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('Failed to fetch tickets for personal calendar:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. System layer: POLLS (from SchedulingPollVote where userId matches)
|
||||
const pollsLayer = systemLayersByType.get(CalendarSystemType.POLLS);
|
||||
if (pollsLayer) {
|
||||
try {
|
||||
const votes = await prisma.schedulingPollVote.findMany({
|
||||
where: {
|
||||
userId,
|
||||
option: {
|
||||
date: { gte: start, lte: end },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
poll: { select: { id: true, title: true, location: true } },
|
||||
option: { select: { id: true, date: true, startTime: true, endTime: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Deduplicate by poll+option (a user may have multiple votes per poll but same option)
|
||||
const seen = new Set<string>();
|
||||
for (const vote of votes) {
|
||||
const key = `${vote.poll.id}-${vote.option.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
allItems.push({
|
||||
id: `poll-${vote.poll.id}-${vote.option.id}`,
|
||||
type: 'poll',
|
||||
title: vote.poll.title,
|
||||
date: vote.option.date.toISOString().split('T')[0],
|
||||
startTime: vote.option.startTime,
|
||||
endTime: vote.option.endTime,
|
||||
location: vote.poll.location,
|
||||
color: pollsLayer.color,
|
||||
itemType: 'EVENT',
|
||||
layerId: pollsLayer.id,
|
||||
layerName: pollsLayer.name,
|
||||
layerColor: pollsLayer.color,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('Failed to fetch polls for personal calendar:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all items by date then time
|
||||
allItems.sort((a, b) => {
|
||||
const dateCompare = a.date.localeCompare(b.date);
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
return a.startTime.localeCompare(b.startTime);
|
||||
});
|
||||
|
||||
// Group by date
|
||||
const dates: Record<string, { count: number; items: PersonalCalendarItem[] }> = {};
|
||||
|
||||
for (const item of allItems) {
|
||||
if (!dates[item.date]) {
|
||||
dates[item.date] = { count: 0, items: [] };
|
||||
}
|
||||
dates[item.date].count++;
|
||||
dates[item.date].items.push(item);
|
||||
}
|
||||
|
||||
return { dates };
|
||||
},
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate dates matching a recurrence rule within a range.
|
||||
*/
|
||||
function generateRecurrenceDates(
|
||||
rule: RecurrenceRule,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
interval: number
|
||||
): Date[] {
|
||||
const dates: Date[] = [];
|
||||
const maxOccurrences = 200; // safety limit
|
||||
|
||||
const current = new Date(startDate);
|
||||
let count = 0;
|
||||
|
||||
switch (rule.frequency) {
|
||||
case 'DAILY': {
|
||||
while (current <= endDate && count < maxOccurrences) {
|
||||
dates.push(new Date(current));
|
||||
count++;
|
||||
current.setDate(current.getDate() + interval);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'WEEKLY': {
|
||||
const daysOfWeek = rule.daysOfWeek ?? [];
|
||||
if (daysOfWeek.length === 0) break;
|
||||
|
||||
// Walk day-by-day, tracking week boundaries for interval
|
||||
const weekStart = new Date(startDate);
|
||||
let weekNum = 0;
|
||||
|
||||
while (current <= endDate && count < maxOccurrences) {
|
||||
// Check if we moved to a new week (ISO: Monday=1)
|
||||
const daysSinceStart = Math.floor(
|
||||
(current.getTime() - weekStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const currentWeek = Math.floor(daysSinceStart / 7);
|
||||
if (currentWeek !== weekNum) {
|
||||
weekNum = currentWeek;
|
||||
}
|
||||
|
||||
// Only include if this week matches the interval
|
||||
if (weekNum % interval === 0) {
|
||||
// Convert JS day (0=Sun) to ISO day (1=Mon...7=Sun)
|
||||
const jsDay = current.getDay();
|
||||
const isoDay = jsDay === 0 ? 7 : jsDay;
|
||||
if (daysOfWeek.includes(isoDay)) {
|
||||
dates.push(new Date(current));
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'BIWEEKLY': {
|
||||
const biDays = rule.daysOfWeek ?? [];
|
||||
if (biDays.length === 0) break;
|
||||
|
||||
const biWeekStart = new Date(startDate);
|
||||
let biWeekNum = 0;
|
||||
|
||||
while (current <= endDate && count < maxOccurrences) {
|
||||
const daysSinceStart = Math.floor(
|
||||
(current.getTime() - biWeekStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const currentWeek = Math.floor(daysSinceStart / 7);
|
||||
if (currentWeek !== biWeekNum) {
|
||||
biWeekNum = currentWeek;
|
||||
}
|
||||
|
||||
// Every 2 weeks
|
||||
if (biWeekNum % 2 === 0) {
|
||||
const jsDay = current.getDay();
|
||||
const isoDay = jsDay === 0 ? 7 : jsDay;
|
||||
if (biDays.includes(isoDay)) {
|
||||
dates.push(new Date(current));
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MONTHLY': {
|
||||
const dayOfMonth = rule.dayOfMonth ?? startDate.getDate();
|
||||
while (current <= endDate && count < maxOccurrences) {
|
||||
// Set to the target day of month
|
||||
const targetDate = new Date(current.getFullYear(), current.getMonth(), dayOfMonth);
|
||||
// Handle months with fewer days (e.g., Feb 30 → clamp to last day)
|
||||
if (targetDate.getMonth() !== current.getMonth()) {
|
||||
// Day overflowed — use last day of month
|
||||
targetDate.setDate(0); // goes to last day of previous month
|
||||
targetDate.setMonth(targetDate.getMonth() + 1);
|
||||
targetDate.setDate(0);
|
||||
}
|
||||
|
||||
if (targetDate >= startDate && targetDate <= endDate) {
|
||||
dates.push(targetDate);
|
||||
count++;
|
||||
}
|
||||
|
||||
current.setMonth(current.getMonth() + interval);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
61
api/src/modules/calendar/shared-calendar.schemas.ts
Normal file
61
api/src/modules/calendar/shared-calendar.schemas.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createSharedViewSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().optional(),
|
||||
includedLayerTypes: z.array(z.string()),
|
||||
shareScope: z.enum(['MEMBERS', 'PUBLIC']),
|
||||
});
|
||||
|
||||
export const updateSharedViewSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
includedLayerTypes: z.array(z.string()).optional(),
|
||||
shareScope: z.enum(['MEMBERS', 'PUBLIC']).optional(),
|
||||
});
|
||||
|
||||
export const inviteMembersSchema = z.object({
|
||||
userIds: z.array(z.string().min(1)).min(1).max(20),
|
||||
});
|
||||
|
||||
export const respondToInviteSchema = z.object({
|
||||
status: z.enum(['ACCEPTED', 'DECLINED']),
|
||||
});
|
||||
|
||||
export const createCommentSchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||
itemId: z.string().optional(),
|
||||
content: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export const createReactionSchema = z.object({
|
||||
itemId: z.string().min(1),
|
||||
emoji: z.string().min(1).max(4),
|
||||
});
|
||||
|
||||
export const shareItemSchema = z.object({
|
||||
friendIds: z.array(z.string().min(1)).min(1).max(10),
|
||||
message: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const availabilityQuerySchema = z.object({
|
||||
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'start must be YYYY-MM-DD'),
|
||||
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'end must be YYYY-MM-DD'),
|
||||
dayStart: z.string().regex(/^\d{2}:\d{2}$/).default('09:00'),
|
||||
dayEnd: z.string().regex(/^\d{2}:\d{2}$/).default('17:00'),
|
||||
slotDuration: z.coerce.number().refine(v => [15, 30, 60].includes(v)).default(30),
|
||||
});
|
||||
|
||||
export const dateRangeQuerySchema = z.object({
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be YYYY-MM-DD'),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'endDate must be YYYY-MM-DD'),
|
||||
});
|
||||
|
||||
export type CreateSharedViewInput = z.infer<typeof createSharedViewSchema>;
|
||||
export type UpdateSharedViewInput = z.infer<typeof updateSharedViewSchema>;
|
||||
export type InviteMembersInput = z.infer<typeof inviteMembersSchema>;
|
||||
export type RespondToInviteInput = z.infer<typeof respondToInviteSchema>;
|
||||
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
|
||||
export type CreateReactionInput = z.infer<typeof createReactionSchema>;
|
||||
export type ShareItemInput = z.infer<typeof shareItemSchema>;
|
||||
export type AvailabilityQueryInput = z.infer<typeof availabilityQuerySchema>;
|
||||
@ -9,7 +9,7 @@ import { logger } from '../../utils/logger';
|
||||
|
||||
export interface UnifiedCalendarItem {
|
||||
id: string;
|
||||
type: 'shift' | 'event' | 'poll';
|
||||
type: 'shift' | 'event' | 'poll' | 'ticketed_event';
|
||||
title: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
startTime: string; // HH:MM
|
||||
@ -28,6 +28,14 @@ export interface UnifiedCalendarItem {
|
||||
pollSlug?: string;
|
||||
pollStatus?: string;
|
||||
pollVoteCount?: number;
|
||||
// Ticketed event-specific
|
||||
ticketedEventId?: string;
|
||||
eventSlug?: string;
|
||||
eventFormat?: string;
|
||||
hasPaidTiers?: boolean;
|
||||
isSoldOut?: boolean;
|
||||
maxAttendees?: number | null;
|
||||
currentAttendees?: number;
|
||||
}
|
||||
|
||||
export interface UnifiedCalendarResponse {
|
||||
@ -55,19 +63,23 @@ export const unifiedCalendarService = {
|
||||
// Set end to end of day
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
// Fetch shifts, Gancio events, and polls in parallel
|
||||
const [shifts, gancioEvents, pollItems] = await Promise.all([
|
||||
// Fetch shifts, Gancio events, polls, and ticketed events in parallel
|
||||
const [shifts, gancioEvents, pollItems, ticketedEventItems] = await Promise.all([
|
||||
this.fetchShifts(start, end),
|
||||
this.fetchGancioEvents(start, end),
|
||||
this.fetchPolls(start, end),
|
||||
this.fetchTicketedEvents(start, end),
|
||||
]);
|
||||
|
||||
// Build set of Gancio event IDs that correspond to synced shifts (to deduplicate)
|
||||
const syncedGancioIds = new Set(
|
||||
shifts
|
||||
// Build set of Gancio event IDs that correspond to synced shifts or ticketed events (to deduplicate)
|
||||
const syncedGancioIds = new Set([
|
||||
...shifts
|
||||
.filter(s => s.gancioEventId !== null)
|
||||
.map(s => s.gancioEventId!),
|
||||
);
|
||||
...ticketedEventItems
|
||||
.filter(te => te.gancioEventId)
|
||||
.map(te => te.gancioEventId!),
|
||||
]);
|
||||
|
||||
// Normalize shifts into calendar items
|
||||
const shiftItems: UnifiedCalendarItem[] = shifts.map(s => ({
|
||||
@ -105,7 +117,7 @@ export const unifiedCalendarService = {
|
||||
});
|
||||
|
||||
// Merge and group by date
|
||||
const allItems = [...shiftItems, ...eventItems, ...pollItems];
|
||||
const allItems = [...shiftItems, ...eventItems, ...pollItems, ...ticketedEventItems];
|
||||
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||
|
||||
const dates: Record<string, { count: number; items: UnifiedCalendarItem[] }> = {};
|
||||
@ -216,6 +228,64 @@ export const unifiedCalendarService = {
|
||||
}
|
||||
},
|
||||
|
||||
async fetchTicketedEvents(start: Date, end: Date): Promise<UnifiedCalendarItem[]> {
|
||||
try {
|
||||
const events = await prisma.ticketedEvent.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
date: { gte: start, lte: end },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
date: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
eventFormat: true,
|
||||
venueName: true,
|
||||
maxAttendees: true,
|
||||
currentAttendees: true,
|
||||
gancioEventId: true,
|
||||
ticketTiers: {
|
||||
select: { tierType: true, soldCount: true, maxQuantity: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
||||
});
|
||||
|
||||
return events.map(e => {
|
||||
const hasPaidTiers = e.ticketTiers.some(t => t.tierType === 'PAID');
|
||||
const isSoldOut = e.maxAttendees ? e.currentAttendees >= e.maxAttendees : false;
|
||||
const formatTags: string[] = [];
|
||||
if (e.eventFormat === 'ONLINE') formatTags.push('online');
|
||||
if (e.eventFormat === 'HYBRID') formatTags.push('hybrid');
|
||||
const location = e.eventFormat === 'ONLINE' ? 'Online Event' : e.venueName;
|
||||
return {
|
||||
id: `ticketed-${e.id}`,
|
||||
type: 'ticketed_event' as const,
|
||||
title: e.title,
|
||||
date: e.date.toISOString().split('T')[0],
|
||||
startTime: e.startTime,
|
||||
endTime: e.endTime,
|
||||
location,
|
||||
tags: ['ticketed', ...(hasPaidTiers ? ['paid'] : ['free']), ...formatTags],
|
||||
ticketedEventId: e.id,
|
||||
eventSlug: e.slug,
|
||||
eventFormat: e.eventFormat,
|
||||
gancioEventId: e.gancioEventId ?? undefined,
|
||||
hasPaidTiers,
|
||||
isSoldOut,
|
||||
maxAttendees: e.maxAttendees,
|
||||
currentAttendees: e.currentAttendees,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug('Failed to fetch ticketed events for calendar:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async fetchGancioEvents(start: Date, end: Date): Promise<GancioEvent[]> {
|
||||
try {
|
||||
const events = await gancioClient.fetchPublicEvents();
|
||||
|
||||
@ -99,6 +99,11 @@ export const campaignEmailsService = {
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Fire-and-forget: check campaign milestones
|
||||
import('../../social/impact-stories.service').then(({ impactStoriesService }) => {
|
||||
impactStoriesService.checkMilestones(campaign.id).catch(() => {});
|
||||
}).catch(() => {});
|
||||
|
||||
return {
|
||||
id: campaignEmail.id,
|
||||
status: campaignEmail.status,
|
||||
@ -150,6 +155,11 @@ export const campaignEmailsService = {
|
||||
// Social group sync (fire-and-forget)
|
||||
groupService.syncCampaignTeam(campaign.id).catch(() => {});
|
||||
|
||||
// Fire-and-forget: check campaign milestones
|
||||
import('../../social/impact-stories.service').then(({ impactStoriesService }) => {
|
||||
impactStoriesService.checkMilestones(campaign.id).catch(() => {});
|
||||
}).catch(() => {});
|
||||
|
||||
return {
|
||||
id: campaignEmail.id,
|
||||
status: campaignEmail.status,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -47,6 +47,9 @@ export const webhookService = {
|
||||
case 'charge.refunded':
|
||||
await this.handleChargeRefunded(event.data.object as Stripe.Charge);
|
||||
break;
|
||||
case 'checkout.session.expired':
|
||||
await this.handleCheckoutExpired(event.data.object as Stripe.Checkout.Session);
|
||||
break;
|
||||
default:
|
||||
logger.debug(`Unhandled Stripe event type: ${event.type}`);
|
||||
}
|
||||
@ -61,6 +64,8 @@ export const webhookService = {
|
||||
await this.handleProductCheckout(session);
|
||||
} else if (type === 'donation') {
|
||||
await this.handleDonationCheckout(session);
|
||||
} else if (type === 'event_ticket') {
|
||||
await this.handleEventTicketCheckout(session);
|
||||
} else {
|
||||
logger.warn(`Unknown checkout type: ${type}`);
|
||||
}
|
||||
@ -359,6 +364,87 @@ export const webhookService = {
|
||||
logger.info(`Subscription cancelled: ${subscription.id}`);
|
||||
},
|
||||
|
||||
async handleEventTicketCheckout(session: Stripe.Checkout.Session) {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { stripeCheckoutSessionId: session.id },
|
||||
});
|
||||
if (!order) {
|
||||
logger.error('Order not found for event ticket checkout', { sessionId: session.id });
|
||||
return;
|
||||
}
|
||||
if (order.status === 'COMPLETED') return; // idempotent
|
||||
|
||||
const { eventId, tierId, quantity, buyerEmail, buyerName, userId } = session.metadata || {};
|
||||
if (!eventId || !tierId || !quantity) {
|
||||
logger.error('Missing metadata in event ticket checkout', { sessionId: session.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentIntentId = typeof session.payment_intent === 'string'
|
||||
? session.payment_intent
|
||||
: (session.payment_intent as { id: string } | null)?.id || null;
|
||||
|
||||
// Complete the order
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create tickets
|
||||
try {
|
||||
const { ticketsService } = await import('../ticketed-events/tickets.service');
|
||||
const { ticketEmailService } = await import('../ticketed-events/ticket-email.service');
|
||||
|
||||
const tickets = await ticketsService.createTickets({
|
||||
eventId,
|
||||
tierId,
|
||||
quantity: parseInt(quantity, 10),
|
||||
holderEmail: buyerEmail || order.buyerEmail,
|
||||
holderName: buyerName || order.buyerName || undefined,
|
||||
userId: userId || order.userId || undefined,
|
||||
orderId: order.id,
|
||||
});
|
||||
|
||||
// Fetch event + tier for email
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
|
||||
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
||||
|
||||
if (event && tier) {
|
||||
for (const ticket of tickets) {
|
||||
ticketEmailService.sendTicketConfirmation({
|
||||
holderEmail: ticket.holderEmail,
|
||||
holderName: ticket.holderName,
|
||||
ticketCode: ticket.ticketCode,
|
||||
token: (ticket as Record<string, unknown>).token as string,
|
||||
eventTitle: event.title,
|
||||
eventDate: event.date,
|
||||
eventStartTime: event.startTime,
|
||||
eventEndTime: event.endTime,
|
||||
eventSlug: event.slug,
|
||||
venueName: event.venueName,
|
||||
venueAddress: event.venueAddress,
|
||||
tierName: tier.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await this.createAuditLog('event_ticket_purchased', {
|
||||
orderId: order.id,
|
||||
eventId,
|
||||
tierId,
|
||||
ticketCount: tickets.length,
|
||||
amount: order.amountCAD,
|
||||
});
|
||||
logger.info(`Event ticket order completed: ${order.id}, ${tickets.length} tickets`);
|
||||
} catch (err) {
|
||||
logger.error('Failed to create tickets after checkout:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async handleChargeRefunded(charge: Stripe.Charge) {
|
||||
const paymentIntentId = typeof charge.payment_intent === 'string'
|
||||
? charge.payment_intent
|
||||
@ -375,6 +461,30 @@ export const webhookService = {
|
||||
data: { status: 'REFUNDED' },
|
||||
});
|
||||
await this.createAuditLog('order_refunded', { orderId: order.id });
|
||||
|
||||
// If this was an event ticket order, refund the linked tickets
|
||||
if (order.type === 'event_ticket') {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { orderId: order.id, status: 'VALID' },
|
||||
});
|
||||
for (const ticket of tickets) {
|
||||
await prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { status: 'REFUNDED' },
|
||||
});
|
||||
await prisma.ticketTier.update({
|
||||
where: { id: ticket.tierId },
|
||||
data: { soldCount: { decrement: 1 } },
|
||||
});
|
||||
await prisma.ticketedEvent.update({
|
||||
where: { id: ticket.eventId },
|
||||
data: { currentAttendees: { decrement: 1 } },
|
||||
});
|
||||
}
|
||||
if (tickets.length > 0) {
|
||||
logger.info(`Refunded ${tickets.length} event tickets for order ${order.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check payments
|
||||
@ -389,6 +499,20 @@ export const webhookService = {
|
||||
}
|
||||
},
|
||||
|
||||
async handleCheckoutExpired(session: Stripe.Checkout.Session) {
|
||||
// Clean up pending orders for expired checkout sessions
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { stripeCheckoutSessionId: session.id },
|
||||
});
|
||||
if (order && order.status === 'PENDING') {
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status: 'FAILED' },
|
||||
});
|
||||
logger.info(`Checkout expired, order marked failed: ${order.id}`);
|
||||
}
|
||||
},
|
||||
|
||||
async createAuditLog(action: string, metadata: Record<string, unknown>) {
|
||||
try {
|
||||
logger.info(`Payment audit: ${action}`, metadata);
|
||||
|
||||
@ -57,6 +57,9 @@ export const updateSiteSettingsSchema = z.object({
|
||||
enableSocial: z.boolean().optional(),
|
||||
enableMeet: z.boolean().optional(),
|
||||
enableMeetingPlanner: z.boolean().optional(),
|
||||
enableTicketedEvents: z.boolean().optional(),
|
||||
enableSocialCalendar: z.boolean().optional(),
|
||||
requireEventApproval: z.boolean().optional(),
|
||||
autoSyncPeopleToMap: z.boolean().optional(),
|
||||
|
||||
// SMS connection config
|
||||
|
||||
@ -169,6 +169,113 @@ const ACHIEVEMENTS: AchievementDef[] = [
|
||||
where: { userId },
|
||||
}),
|
||||
},
|
||||
// Referral achievements
|
||||
{
|
||||
id: 'FIRST_REFERRAL',
|
||||
name: 'Ambassador',
|
||||
description: 'Refer your first friend to the platform',
|
||||
icon: 'usergroup-add',
|
||||
category: 'social',
|
||||
threshold: 1,
|
||||
getProgress: async (userId) =>
|
||||
prisma.referral.count({ where: { referrerId: userId } }),
|
||||
},
|
||||
{
|
||||
id: 'REFERRAL_5',
|
||||
name: 'Recruiter',
|
||||
description: 'Refer 5 friends to the platform',
|
||||
icon: 'usergroup-add',
|
||||
category: 'social',
|
||||
threshold: 5,
|
||||
getProgress: async (userId) =>
|
||||
prisma.referral.count({ where: { referrerId: userId } }),
|
||||
},
|
||||
{
|
||||
id: 'REFERRAL_25',
|
||||
name: 'Movement Builder',
|
||||
description: 'Refer 25 friends to the platform',
|
||||
icon: 'star',
|
||||
category: 'social',
|
||||
threshold: 25,
|
||||
getProgress: async (userId) =>
|
||||
prisma.referral.count({ where: { referrerId: userId } }),
|
||||
},
|
||||
// Campaign milestone achievement
|
||||
{
|
||||
id: 'MILESTONE_CONTRIBUTOR',
|
||||
name: 'Milestone Maker',
|
||||
description: 'Participate in a campaign that reaches a milestone',
|
||||
icon: 'trophy',
|
||||
category: 'campaigns',
|
||||
threshold: 1,
|
||||
getProgress: async (userId) => {
|
||||
const result = await prisma.campaignEmail.findMany({
|
||||
where: {
|
||||
userId,
|
||||
campaign: { milestones: { some: {} } },
|
||||
},
|
||||
distinct: ['campaignId'],
|
||||
select: { campaignId: true },
|
||||
});
|
||||
return result.length;
|
||||
},
|
||||
},
|
||||
// Spotlight achievement
|
||||
{
|
||||
id: 'SPOTLIGHT_STAR',
|
||||
name: 'Spotlight Star',
|
||||
description: 'Be featured as a volunteer spotlight',
|
||||
icon: 'star',
|
||||
category: 'social',
|
||||
threshold: 1,
|
||||
getProgress: async (userId) =>
|
||||
prisma.volunteerSpotlight.count({
|
||||
where: { userId, status: 'FEATURED' },
|
||||
}),
|
||||
},
|
||||
// Challenge achievements
|
||||
{
|
||||
id: 'FIRST_CHALLENGE',
|
||||
name: 'Challenger',
|
||||
description: 'Join your first team challenge',
|
||||
icon: 'trophy',
|
||||
category: 'social',
|
||||
threshold: 1,
|
||||
getProgress: async (userId) =>
|
||||
prisma.challengeTeamMember.count({ where: { userId } }),
|
||||
},
|
||||
{
|
||||
id: 'CHALLENGE_WINNER',
|
||||
name: 'Champion',
|
||||
description: 'Win a team challenge',
|
||||
icon: 'crown',
|
||||
category: 'social',
|
||||
threshold: 1,
|
||||
getProgress: async (userId) => {
|
||||
// Count challenges where user's team has the highest score and challenge is COMPLETED
|
||||
const teams = await prisma.challengeTeamMember.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
challenge: { select: { id: true, status: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
let wins = 0;
|
||||
for (const membership of teams) {
|
||||
if (membership.team.challenge.status !== 'COMPLETED') continue;
|
||||
const topTeam = await prisma.challengeTeam.findFirst({
|
||||
where: { challengeId: membership.team.challenge.id },
|
||||
orderBy: { score: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (topTeam?.id === membership.teamId) wins++;
|
||||
}
|
||||
return wins;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Achievement map for quick lookup */
|
||||
|
||||
177
api/src/modules/social/challenge.routes.ts
Normal file
177
api/src/modules/social/challenge.routes.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { challengeService } from './challenge.service';
|
||||
import {
|
||||
createChallengeSchema,
|
||||
updateChallengeSchema,
|
||||
createTeamSchema,
|
||||
listChallengesSchema,
|
||||
} from './challenge.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Authenticated (any logged-in user) ───────────────────────────────
|
||||
|
||||
/** GET / — list challenges */
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page, limit, status } = listChallengesSchema.parse(req.query);
|
||||
const result = await challengeService.listChallenges(page, limit, status);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CHALLENGES_LIST_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /:id — get challenge detail */
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const challenge = await challengeService.findById(req.params.id as string);
|
||||
res.json(challenge);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CHALLENGE_GET_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /:id/leaderboard — leaderboard */
|
||||
router.get('/:id/leaderboard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await challengeService.getLeaderboard(req.params.id as string);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'LEADERBOARD_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /:id/my-team — get my team for this challenge */
|
||||
router.get('/:id/my-team', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const team = await challengeService.getMyTeam(req.params.id as string, req.user!.id);
|
||||
res.json({ team });
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'MY_TEAM_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /:id/teams — create team */
|
||||
router.post('/:id/teams', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name } = createTeamSchema.parse(req.body);
|
||||
const team = await challengeService.createTeam(req.params.id as string, req.user!.id, name);
|
||||
res.status(201).json(team);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CREATE_TEAM_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /:id/teams/:teamId/join — join team */
|
||||
router.post('/:id/teams/:teamId/join', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const team = await challengeService.joinTeam(req.params.teamId as string, req.user!.id);
|
||||
res.json(team);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'JOIN_TEAM_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /:id/teams/:teamId/leave — leave team */
|
||||
router.post('/:id/teams/:teamId/leave', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await challengeService.leaveTeam(req.params.teamId as string, req.user!.id);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'LEAVE_TEAM_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /:id/teams/:teamId — team detail */
|
||||
router.get('/:id/teams/:teamId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const team = await challengeService.getTeamDetail(req.params.teamId as string);
|
||||
res.json(team);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'TEAM_DETAIL_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Admin ────────────────────────────────────────────────────────────
|
||||
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
|
||||
|
||||
/** POST /admin — create challenge */
|
||||
adminRouter.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = createChallengeSchema.parse(req.body);
|
||||
const challenge = await challengeService.create(data, req.user!.id);
|
||||
res.status(201).json(challenge);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CREATE_CHALLENGE_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /admin/:id — update challenge */
|
||||
adminRouter.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = updateChallengeSchema.parse(req.body);
|
||||
const challenge = await challengeService.update(req.params.id as string, data);
|
||||
res.json(challenge);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'UPDATE_CHALLENGE_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /admin/:id — delete challenge */
|
||||
adminRouter.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await challengeService.delete(req.params.id as string);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'DELETE_CHALLENGE_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /admin/:id/activate — activate challenge */
|
||||
adminRouter.post('/:id/activate', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const challenge = await challengeService.activate(req.params.id as string);
|
||||
res.json(challenge);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'ACTIVATE_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /admin/:id/complete — force complete */
|
||||
adminRouter.post('/:id/complete', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const challenge = await challengeService.complete(req.params.id as string);
|
||||
res.json(challenge);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'COMPLETE_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /admin/:id/cancel — cancel challenge */
|
||||
adminRouter.post('/:id/cancel', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const challenge = await challengeService.cancel(req.params.id as string);
|
||||
res.json(challenge);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CANCEL_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /admin/:id/rescore — force rescore */
|
||||
adminRouter.post('/:id/rescore', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await challengeService.rescore(req.params.id as string);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'RESCORE_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
router.use('/admin', adminRouter);
|
||||
|
||||
export { router as challengeRouter };
|
||||
37
api/src/modules/social/challenge.schemas.ts
Normal file
37
api/src/modules/social/challenge.schemas.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createChallengeSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
metric: z.enum([
|
||||
'DOORS_KNOCKED',
|
||||
'EMAILS_SENT',
|
||||
'SHIFTS_ATTENDED',
|
||||
'RESPONSES_SUBMITTED',
|
||||
'REFERRALS_MADE',
|
||||
]),
|
||||
startsAt: z.string().datetime(),
|
||||
endsAt: z.string().datetime(),
|
||||
minTeamSize: z.number().int().min(1).max(50).default(2),
|
||||
maxTeamSize: z.number().int().min(2).max(100).default(10),
|
||||
maxTeams: z.number().int().min(1).optional(),
|
||||
});
|
||||
|
||||
export const updateChallengeSchema = createChallengeSchema.partial();
|
||||
|
||||
export const createTeamSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export const listChallengesSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
status: z
|
||||
.enum(['DRAFT', 'UPCOMING', 'ACTIVE', 'COMPLETED', 'CANCELLED'])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type CreateChallengeInput = z.infer<typeof createChallengeSchema>;
|
||||
export type UpdateChallengeInput = z.infer<typeof updateChallengeSchema>;
|
||||
export type CreateTeamInput = z.infer<typeof createTeamSchema>;
|
||||
export type ListChallengesInput = z.infer<typeof listChallengesSchema>;
|
||||
347
api/src/modules/social/challenge.service.ts
Normal file
347
api/src/modules/social/challenge.service.ts
Normal file
@ -0,0 +1,347 @@
|
||||
import { prisma } from '../../config/database';
|
||||
import { challengeScoringService } from '../../services/challenge-scoring.service';
|
||||
import type { ChallengeStatus } from '@prisma/client';
|
||||
import type { CreateChallengeInput, UpdateChallengeInput } from './challenge.schemas';
|
||||
|
||||
const USER_SELECT = { id: true, email: true, name: true } as const;
|
||||
|
||||
export const challengeService = {
|
||||
// ── Admin ──────────────────────────────────────────────────────────
|
||||
|
||||
async create(data: CreateChallengeInput, userId: string) {
|
||||
return prisma.challenge.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
metric: data.metric,
|
||||
startsAt: new Date(data.startsAt),
|
||||
endsAt: new Date(data.endsAt),
|
||||
minTeamSize: data.minTeamSize,
|
||||
maxTeamSize: data.maxTeamSize,
|
||||
maxTeams: data.maxTeams,
|
||||
createdByUserId: userId,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateChallengeInput) {
|
||||
const challenge = await prisma.challenge.findUnique({ where: { id } });
|
||||
if (!challenge) {
|
||||
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
|
||||
}
|
||||
if (challenge.status !== 'DRAFT' && challenge.status !== 'UPCOMING') {
|
||||
throw Object.assign(new Error('Can only edit DRAFT or UPCOMING challenges'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
return prisma.challenge.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.title !== undefined && { title: data.title }),
|
||||
...(data.description !== undefined && { description: data.description }),
|
||||
...(data.metric !== undefined && { metric: data.metric }),
|
||||
...(data.startsAt !== undefined && { startsAt: new Date(data.startsAt) }),
|
||||
...(data.endsAt !== undefined && { endsAt: new Date(data.endsAt) }),
|
||||
...(data.minTeamSize !== undefined && { minTeamSize: data.minTeamSize }),
|
||||
...(data.maxTeamSize !== undefined && { maxTeamSize: data.maxTeamSize }),
|
||||
...(data.maxTeams !== undefined && { maxTeams: data.maxTeams }),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const challenge = await prisma.challenge.findUnique({ where: { id } });
|
||||
if (!challenge) {
|
||||
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
|
||||
}
|
||||
if (challenge.status !== 'DRAFT' && challenge.status !== 'CANCELLED') {
|
||||
throw Object.assign(new Error('Can only delete DRAFT or CANCELLED challenges'), { statusCode: 400 });
|
||||
}
|
||||
await prisma.challenge.delete({ where: { id } });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async activate(id: string) {
|
||||
const challenge = await prisma.challenge.findUnique({ where: { id } });
|
||||
if (!challenge) {
|
||||
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
|
||||
}
|
||||
if (challenge.status !== 'DRAFT') {
|
||||
throw Object.assign(new Error('Can only activate DRAFT challenges'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const newStatus: ChallengeStatus = challenge.startsAt <= now ? 'ACTIVE' : 'UPCOMING';
|
||||
|
||||
return prisma.challenge.update({
|
||||
where: { id },
|
||||
data: { status: newStatus },
|
||||
});
|
||||
},
|
||||
|
||||
async complete(id: string) {
|
||||
const challenge = await prisma.challenge.findUnique({ where: { id } });
|
||||
if (!challenge) {
|
||||
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
|
||||
}
|
||||
if (challenge.status !== 'ACTIVE') {
|
||||
throw Object.assign(new Error('Can only complete ACTIVE challenges'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
await challengeScoringService.scoreChallenge(id);
|
||||
return prisma.challenge.update({
|
||||
where: { id },
|
||||
data: { status: 'COMPLETED' },
|
||||
});
|
||||
},
|
||||
|
||||
async cancel(id: string) {
|
||||
const challenge = await prisma.challenge.findUnique({ where: { id } });
|
||||
if (!challenge) {
|
||||
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
|
||||
}
|
||||
if (challenge.status === 'COMPLETED' || challenge.status === 'CANCELLED') {
|
||||
throw Object.assign(new Error('Challenge is already completed or cancelled'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
return prisma.challenge.update({
|
||||
where: { id },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
},
|
||||
|
||||
async rescore(id: string) {
|
||||
const challenge = await prisma.challenge.findUnique({ where: { id } });
|
||||
if (!challenge) {
|
||||
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
|
||||
}
|
||||
await challengeScoringService.scoreChallenge(id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
// ── Queries ────────────────────────────────────────────────────────
|
||||
|
||||
async findById(id: string) {
|
||||
const challenge = await prisma.challenge.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
createdBy: { select: USER_SELECT },
|
||||
teams: {
|
||||
include: {
|
||||
captain: { select: USER_SELECT },
|
||||
members: {
|
||||
include: { user: { select: USER_SELECT } },
|
||||
orderBy: { score: 'desc' },
|
||||
},
|
||||
},
|
||||
orderBy: { score: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!challenge) {
|
||||
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
|
||||
}
|
||||
return challenge;
|
||||
},
|
||||
|
||||
async listChallenges(page: number, limit: number, status?: ChallengeStatus) {
|
||||
const where = status ? { status } : {};
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [challenges, total] = await Promise.all([
|
||||
prisma.challenge.findMany({
|
||||
where,
|
||||
include: {
|
||||
_count: { select: { teams: true } },
|
||||
createdBy: { select: USER_SELECT },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.challenge.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
challenges,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async getLeaderboard(challengeId: string) {
|
||||
const teams = await prisma.challengeTeam.findMany({
|
||||
where: { challengeId },
|
||||
include: {
|
||||
captain: { select: USER_SELECT },
|
||||
members: {
|
||||
include: { user: { select: USER_SELECT } },
|
||||
orderBy: { score: 'desc' },
|
||||
},
|
||||
},
|
||||
orderBy: { score: 'desc' },
|
||||
});
|
||||
return { teams };
|
||||
},
|
||||
|
||||
async getTeamDetail(teamId: string) {
|
||||
const team = await prisma.challengeTeam.findUnique({
|
||||
where: { id: teamId },
|
||||
include: {
|
||||
challenge: true,
|
||||
captain: { select: USER_SELECT },
|
||||
members: {
|
||||
include: { user: { select: USER_SELECT } },
|
||||
orderBy: { score: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!team) {
|
||||
throw Object.assign(new Error('Team not found'), { statusCode: 404 });
|
||||
}
|
||||
return team;
|
||||
},
|
||||
|
||||
// ── User actions ───────────────────────────────────────────────────
|
||||
|
||||
async createTeam(challengeId: string, userId: string, teamName: string) {
|
||||
const challenge = await prisma.challenge.findUnique({
|
||||
where: { id: challengeId },
|
||||
include: { _count: { select: { teams: true } } },
|
||||
});
|
||||
if (!challenge) {
|
||||
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
|
||||
}
|
||||
if (challenge.status !== 'UPCOMING' && challenge.status !== 'ACTIVE') {
|
||||
throw Object.assign(new Error('Challenge is not accepting teams'), { statusCode: 400 });
|
||||
}
|
||||
if (challenge.maxTeams && challenge._count.teams >= challenge.maxTeams) {
|
||||
throw Object.assign(new Error('Maximum number of teams reached'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
// Check user not already on a team for this challenge
|
||||
const existing = await prisma.challengeTeamMember.findFirst({
|
||||
where: { userId, team: { challengeId } },
|
||||
});
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('You are already on a team for this challenge'), { statusCode: 409 });
|
||||
}
|
||||
|
||||
const team = await prisma.challengeTeam.create({
|
||||
data: {
|
||||
challengeId,
|
||||
name: teamName,
|
||||
captainUserId: userId,
|
||||
members: {
|
||||
create: { userId },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
captain: { select: USER_SELECT },
|
||||
members: { include: { user: { select: USER_SELECT } } },
|
||||
},
|
||||
});
|
||||
|
||||
return team;
|
||||
},
|
||||
|
||||
async joinTeam(teamId: string, userId: string) {
|
||||
const team = await prisma.challengeTeam.findUnique({
|
||||
where: { id: teamId },
|
||||
include: {
|
||||
challenge: true,
|
||||
_count: { select: { members: true } },
|
||||
},
|
||||
});
|
||||
if (!team) {
|
||||
throw Object.assign(new Error('Team not found'), { statusCode: 404 });
|
||||
}
|
||||
if (team.challenge.status !== 'UPCOMING' && team.challenge.status !== 'ACTIVE') {
|
||||
throw Object.assign(new Error('Challenge is not accepting members'), { statusCode: 400 });
|
||||
}
|
||||
if (team._count.members >= team.challenge.maxTeamSize) {
|
||||
throw Object.assign(new Error('Team is full'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
// Check user not already on another team for this challenge
|
||||
const existing = await prisma.challengeTeamMember.findFirst({
|
||||
where: { userId, team: { challengeId: team.challengeId } },
|
||||
});
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('You are already on a team for this challenge'), { statusCode: 409 });
|
||||
}
|
||||
|
||||
await prisma.challengeTeamMember.create({
|
||||
data: { teamId, userId },
|
||||
});
|
||||
|
||||
return prisma.challengeTeam.findUnique({
|
||||
where: { id: teamId },
|
||||
include: {
|
||||
captain: { select: USER_SELECT },
|
||||
members: { include: { user: { select: USER_SELECT } } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async leaveTeam(teamId: string, userId: string) {
|
||||
const member = await prisma.challengeTeamMember.findUnique({
|
||||
where: { teamId_userId: { teamId, userId } },
|
||||
});
|
||||
if (!member) {
|
||||
throw Object.assign(new Error('You are not on this team'), { statusCode: 404 });
|
||||
}
|
||||
|
||||
const team = await prisma.challengeTeam.findUnique({
|
||||
where: { id: teamId },
|
||||
include: { _count: { select: { members: true } } },
|
||||
});
|
||||
if (!team) {
|
||||
throw Object.assign(new Error('Team not found'), { statusCode: 404 });
|
||||
}
|
||||
|
||||
// If captain and last member, delete entire team
|
||||
if (team.captainUserId === userId && team._count.members === 1) {
|
||||
await prisma.challengeTeam.delete({ where: { id: teamId } });
|
||||
return { success: true, teamDeleted: true };
|
||||
}
|
||||
|
||||
// Remove member
|
||||
await prisma.challengeTeamMember.delete({
|
||||
where: { teamId_userId: { teamId, userId } },
|
||||
});
|
||||
|
||||
// If captain, transfer to next member
|
||||
if (team.captainUserId === userId) {
|
||||
const nextMember = await prisma.challengeTeamMember.findFirst({
|
||||
where: { teamId },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
if (nextMember) {
|
||||
await prisma.challengeTeam.update({
|
||||
where: { id: teamId },
|
||||
data: { captainUserId: nextMember.userId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, teamDeleted: false };
|
||||
},
|
||||
|
||||
async getMyTeam(challengeId: string, userId: string) {
|
||||
const member = await prisma.challengeTeamMember.findFirst({
|
||||
where: { userId, team: { challengeId } },
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
captain: { select: USER_SELECT },
|
||||
members: {
|
||||
include: { user: { select: USER_SELECT } },
|
||||
orderBy: { score: 'desc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return member?.team ?? null;
|
||||
},
|
||||
};
|
||||
@ -5,7 +5,7 @@ import { friendshipService } from './friendship.service';
|
||||
/** A unified feed item representing any activity type */
|
||||
export interface FeedItem {
|
||||
id: string;
|
||||
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted';
|
||||
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed';
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userEmail: string;
|
||||
@ -56,11 +56,15 @@ export const feedService = {
|
||||
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
|
||||
|
||||
// Query all activity types in parallel
|
||||
const [shiftSignups, campaignEmails, canvassSessions, responses] = await Promise.all([
|
||||
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges] = await Promise.all([
|
||||
this.getShiftSignupActivities(visibleFriendIds, since),
|
||||
this.getCampaignEmailActivities(visibleFriendIds, since),
|
||||
this.getCanvassSessionActivities(visibleFriendIds, since),
|
||||
this.getResponseActivities(visibleFriendIds, since),
|
||||
this.getImpactStoryActivities(since),
|
||||
this.getSpotlightActivities(since),
|
||||
this.getReferralActivities(visibleFriendIds, since),
|
||||
this.getChallengeActivities(since),
|
||||
]);
|
||||
|
||||
// Merge and sort by timestamp descending
|
||||
@ -69,6 +73,10 @@ export const feedService = {
|
||||
...campaignEmails,
|
||||
...canvassSessions,
|
||||
...responses,
|
||||
...impactStories,
|
||||
...spotlights,
|
||||
...referrals,
|
||||
...challenges,
|
||||
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
// Cap total items
|
||||
@ -94,11 +102,12 @@ export const feedService = {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
|
||||
|
||||
const [shiftSignups, campaignEmails, canvassSessions, responses] = await Promise.all([
|
||||
const [shiftSignups, campaignEmails, canvassSessions, responses, referrals] = await Promise.all([
|
||||
this.getShiftSignupActivities([userId], since),
|
||||
this.getCampaignEmailActivities([userId], since),
|
||||
this.getCanvassSessionActivities([userId], since),
|
||||
this.getResponseActivities([userId], since),
|
||||
this.getReferralActivities([userId], since),
|
||||
]);
|
||||
|
||||
const allItems: FeedItem[] = [
|
||||
@ -106,6 +115,7 @@ export const feedService = {
|
||||
...campaignEmails,
|
||||
...canvassSessions,
|
||||
...responses,
|
||||
...referrals,
|
||||
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
const total = allItems.length;
|
||||
@ -235,4 +245,121 @@ export const feedService = {
|
||||
timestamp: r.createdAt,
|
||||
}));
|
||||
},
|
||||
|
||||
async getImpactStoryActivities(since: Date): Promise<FeedItem[]> {
|
||||
const stories = await prisma.impactStory.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
publishedAt: { gte: since },
|
||||
},
|
||||
include: {
|
||||
campaign: { select: { id: true, title: true, slug: true } },
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: FEED_MAX_ITEMS,
|
||||
});
|
||||
|
||||
return stories.map((s) => ({
|
||||
id: `impact_story:${s.id}`,
|
||||
type: 'impact_story' as const,
|
||||
userId: s.createdBy?.id || '',
|
||||
userName: s.createdBy?.name || null,
|
||||
userEmail: s.createdBy?.email || '',
|
||||
title: s.title,
|
||||
description: `${s.campaign.title}${s.milestoneValue ? ` — ${s.milestoneValue} ${s.milestoneMetric || 'milestone'}` : ''}`,
|
||||
metadata: { campaignId: s.campaign.id, campaignSlug: s.campaign.slug, storyType: s.type },
|
||||
timestamp: s.publishedAt || s.createdAt,
|
||||
}));
|
||||
},
|
||||
|
||||
async getSpotlightActivities(since: Date): Promise<FeedItem[]> {
|
||||
const spotlights = await prisma.volunteerSpotlight.findMany({
|
||||
where: {
|
||||
status: 'FEATURED',
|
||||
updatedAt: { gte: since },
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: FEED_MAX_ITEMS,
|
||||
});
|
||||
|
||||
return spotlights.map((s) => ({
|
||||
id: `spotlight:${s.id}`,
|
||||
type: 'volunteer_featured' as const,
|
||||
userId: s.user.id,
|
||||
userName: s.user.name,
|
||||
userEmail: s.user.email,
|
||||
title: 'Featured as Volunteer Spotlight',
|
||||
description: s.headline || `Spotlight for ${s.featuredMonth || 'this month'}`,
|
||||
metadata: { spotlightId: s.id, featuredMonth: s.featuredMonth },
|
||||
timestamp: s.updatedAt,
|
||||
}));
|
||||
},
|
||||
|
||||
async getReferralActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
|
||||
const referrals = await prisma.referral.findMany({
|
||||
where: {
|
||||
referrerId: { in: userIds },
|
||||
completedAt: { gte: since },
|
||||
},
|
||||
include: {
|
||||
referrer: { select: { id: true, name: true, email: true } },
|
||||
referredUser: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { completedAt: 'desc' },
|
||||
take: FEED_MAX_ITEMS,
|
||||
});
|
||||
|
||||
return referrals.map((r) => ({
|
||||
id: `referral:${r.id}`,
|
||||
type: 'referral_completed' as const,
|
||||
userId: r.referrer.id,
|
||||
userName: r.referrer.name,
|
||||
userEmail: r.referrer.email,
|
||||
title: 'Referred a new member',
|
||||
description: `${r.referredUser.name || 'A new member'} joined the platform`,
|
||||
metadata: { referredUserId: r.referredUser.id },
|
||||
timestamp: r.completedAt,
|
||||
}));
|
||||
},
|
||||
|
||||
async getChallengeActivities(since: Date): Promise<FeedItem[]> {
|
||||
const challenges = await prisma.challenge.findMany({
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
updatedAt: { gte: since },
|
||||
},
|
||||
include: {
|
||||
teams: {
|
||||
orderBy: { score: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
captain: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: FEED_MAX_ITEMS,
|
||||
});
|
||||
|
||||
return challenges
|
||||
.filter((c) => c.teams.length > 0)
|
||||
.map((c) => {
|
||||
const winner = c.teams[0];
|
||||
return {
|
||||
id: `challenge:${c.id}`,
|
||||
type: 'challenge_completed' as const,
|
||||
userId: winner.captain.id,
|
||||
userName: winner.captain.name,
|
||||
userEmail: winner.captain.email,
|
||||
title: `Challenge completed: ${c.title}`,
|
||||
description: `Team "${winner.name}" won with ${winner.score} points`,
|
||||
metadata: { challengeId: c.id, winningTeamId: winner.id, metric: c.metric },
|
||||
timestamp: c.updatedAt,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
102
api/src/modules/social/impact-stories.routes.ts
Normal file
102
api/src/modules/social/impact-stories.routes.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { Router } from 'express';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { impactStoriesService } from './impact-stories.service';
|
||||
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Admin routes (require admin role) ---
|
||||
|
||||
router.post('/', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const data = createStorySchema.parse(req.body);
|
||||
const story = await impactStoriesService.create(data, req.user!.id);
|
||||
res.status(201).json(story);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const data = updateStorySchema.parse(req.body);
|
||||
const story = await impactStoriesService.update(req.params.id as string, data);
|
||||
res.json(story);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await impactStoriesService.delete(req.params.id as string);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/publish', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const story = await impactStoriesService.publish(req.params.id as string);
|
||||
// Fire-and-forget: notify participants
|
||||
impactStoriesService.notifyParticipants(story.id).catch(() => {});
|
||||
res.json(story);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/archive', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const story = await impactStoriesService.archive(req.params.id as string);
|
||||
res.json(story);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Authenticated routes (any logged-in user) ---
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { page, limit, campaignId, status } = listStoriesSchema.parse(req.query);
|
||||
// Admin users can filter by status; regular users see published only
|
||||
const userRoles = req.user!.roles || [req.user!.role];
|
||||
const isAdmin = userRoles.some((r: string) =>
|
||||
['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'].includes(r),
|
||||
);
|
||||
|
||||
if (isAdmin && (campaignId || status)) {
|
||||
const result = await impactStoriesService.listAdmin(page, limit, campaignId, status);
|
||||
res.json(result);
|
||||
} else {
|
||||
const result = await impactStoriesService.listPublished(page, limit);
|
||||
res.json(result);
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/campaign/:campaignId', async (req, res, next) => {
|
||||
try {
|
||||
const { page, limit } = listStoriesSchema.parse(req.query);
|
||||
const campaignId = req.params.campaignId as string;
|
||||
const result = await impactStoriesService.listByCampaign(campaignId, page, limit);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const story = await impactStoriesService.findById(req.params.id as string);
|
||||
res.json(story);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export const impactStoriesRouter = router;
|
||||
32
api/src/modules/social/impact-stories.schemas.ts
Normal file
32
api/src/modules/social/impact-stories.schemas.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createStorySchema = z.object({
|
||||
campaignId: z.string().min(1),
|
||||
type: z.enum(['MILESTONE', 'VICTORY', 'RESPONSE', 'CUSTOM']),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(5000),
|
||||
coverImageUrl: z.string().url().optional(),
|
||||
milestoneValue: z.number().int().positive().optional(),
|
||||
milestoneMetric: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const updateStorySchema = z.object({
|
||||
campaignId: z.string().min(1).optional(),
|
||||
type: z.enum(['MILESTONE', 'VICTORY', 'RESPONSE', 'CUSTOM']).optional(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
body: z.string().min(1).max(5000).optional(),
|
||||
coverImageUrl: z.string().url().nullable().optional(),
|
||||
milestoneValue: z.number().int().positive().nullable().optional(),
|
||||
milestoneMetric: z.string().max(100).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listStoriesSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
campaignId: z.string().optional(),
|
||||
status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']).optional(),
|
||||
});
|
||||
|
||||
export type CreateStoryInput = z.infer<typeof createStorySchema>;
|
||||
export type UpdateStoryInput = z.infer<typeof updateStorySchema>;
|
||||
export type ListStoriesInput = z.infer<typeof listStoriesSchema>;
|
||||
308
api/src/modules/social/impact-stories.service.ts
Normal file
308
api/src/modules/social/impact-stories.service.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import { prisma } from '../../config/database';
|
||||
import { ImpactStoryStatus, type Prisma } from '@prisma/client';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { notificationService } from './notification.service';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { CreateStoryInput, UpdateStoryInput } from './impact-stories.schemas';
|
||||
|
||||
const MILESTONE_THRESHOLDS = [50, 100, 250, 500, 1000, 2500, 5000];
|
||||
|
||||
export const impactStoriesService = {
|
||||
async create(data: CreateStoryInput, userId: string) {
|
||||
// Verify campaign exists
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: { id: data.campaignId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!campaign) {
|
||||
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
|
||||
}
|
||||
|
||||
return prisma.impactStory.create({
|
||||
data: {
|
||||
campaignId: data.campaignId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
coverImageUrl: data.coverImageUrl,
|
||||
milestoneValue: data.milestoneValue,
|
||||
milestoneMetric: data.milestoneMetric,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
include: {
|
||||
campaign: { select: { title: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateStoryInput) {
|
||||
const existing = await prisma.impactStory.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
|
||||
}
|
||||
|
||||
return prisma.impactStory.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.campaignId !== undefined && { campaignId: data.campaignId }),
|
||||
...(data.type !== undefined && { type: data.type }),
|
||||
...(data.title !== undefined && { title: data.title }),
|
||||
...(data.body !== undefined && { body: data.body }),
|
||||
...(data.coverImageUrl !== undefined && { coverImageUrl: data.coverImageUrl }),
|
||||
...(data.milestoneValue !== undefined && { milestoneValue: data.milestoneValue }),
|
||||
...(data.milestoneMetric !== undefined && { milestoneMetric: data.milestoneMetric }),
|
||||
},
|
||||
include: {
|
||||
campaign: { select: { title: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const existing = await prisma.impactStory.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
|
||||
}
|
||||
await prisma.impactStory.delete({ where: { id } });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async publish(id: string) {
|
||||
const existing = await prisma.impactStory.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
|
||||
}
|
||||
|
||||
return prisma.impactStory.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: ImpactStoryStatus.PUBLISHED,
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
campaign: { select: { title: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async archive(id: string) {
|
||||
const existing = await prisma.impactStory.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
|
||||
}
|
||||
|
||||
return prisma.impactStory.update({
|
||||
where: { id },
|
||||
data: { status: ImpactStoryStatus.ARCHIVED },
|
||||
include: {
|
||||
campaign: { select: { title: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async findById(id: string) {
|
||||
const story = await prisma.impactStory.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
campaign: { select: { title: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
if (!story) {
|
||||
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
|
||||
}
|
||||
return story;
|
||||
},
|
||||
|
||||
async listByCampaign(campaignId: string, page: number, limit: number) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where: Prisma.ImpactStoryWhereInput = { campaignId };
|
||||
|
||||
const [stories, total] = await Promise.all([
|
||||
prisma.impactStory.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
campaign: { select: { title: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
prisma.impactStory.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
stories,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async listPublished(page: number, limit: number) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where: Prisma.ImpactStoryWhereInput = { status: ImpactStoryStatus.PUBLISHED };
|
||||
|
||||
const [stories, total] = await Promise.all([
|
||||
prisma.impactStory.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
include: {
|
||||
campaign: { select: { title: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
prisma.impactStory.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
stories,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async listAdmin(page: number, limit: number, campaignId?: string, status?: string) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where: Prisma.ImpactStoryWhereInput = {};
|
||||
if (campaignId) where.campaignId = campaignId;
|
||||
if (status) where.status = status as ImpactStoryStatus;
|
||||
|
||||
const [stories, total] = await Promise.all([
|
||||
prisma.impactStory.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
campaign: { select: { title: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
prisma.impactStory.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
stories,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async checkMilestones(campaignId: string) {
|
||||
try {
|
||||
// Count total emails for this campaign
|
||||
const emailCount = await prisma.campaignEmail.count({
|
||||
where: { campaignId },
|
||||
});
|
||||
|
||||
// Get campaign info for auto-generated story titles
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: { id: campaignId },
|
||||
select: { id: true, title: true, createdByUserId: true },
|
||||
});
|
||||
if (!campaign) return;
|
||||
|
||||
for (const threshold of MILESTONE_THRESHOLDS) {
|
||||
if (emailCount < threshold) break; // Thresholds are sorted, no point checking higher
|
||||
|
||||
// Upsert milestone using the @@unique constraint
|
||||
const milestone = await prisma.campaignMilestone.upsert({
|
||||
where: {
|
||||
campaignId_metric_threshold: {
|
||||
campaignId,
|
||||
metric: 'emails_sent',
|
||||
threshold,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
campaignId,
|
||||
metric: 'emails_sent',
|
||||
threshold,
|
||||
reachedAt: new Date(),
|
||||
storyGenerated: false,
|
||||
},
|
||||
});
|
||||
|
||||
// If milestone exists but no story generated yet, create a draft
|
||||
if (!milestone.storyGenerated) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.impactStory.create({
|
||||
data: {
|
||||
campaignId,
|
||||
type: 'MILESTONE',
|
||||
title: `${campaign.title} reached ${threshold} emails!`,
|
||||
body: `A major milestone has been reached! The campaign "${campaign.title}" has now received ${threshold} advocacy emails from supporters.`,
|
||||
milestoneValue: threshold,
|
||||
milestoneMetric: 'emails_sent',
|
||||
createdByUserId: campaign.createdByUserId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.campaignMilestone.update({
|
||||
where: { id: milestone.id },
|
||||
data: { storyGenerated: true },
|
||||
});
|
||||
});
|
||||
|
||||
// Notify campaign creator
|
||||
if (campaign.createdByUserId) {
|
||||
notificationService.createNotification(
|
||||
campaign.createdByUserId,
|
||||
'system',
|
||||
'Campaign Milestone Reached!',
|
||||
`"${campaign.title}" has reached ${threshold} emails!`,
|
||||
{ campaignId, milestone: threshold, type: 'campaign_milestone' },
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
logger.info(`Milestone ${threshold} reached for campaign ${campaignId}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to check milestones for campaign ${campaignId}:`, err);
|
||||
}
|
||||
},
|
||||
|
||||
async notifyParticipants(storyId: string) {
|
||||
const story = await prisma.impactStory.findUnique({
|
||||
where: { id: storyId },
|
||||
include: { campaign: { select: { id: true, title: true } } },
|
||||
});
|
||||
if (!story) {
|
||||
throw new AppError(404, 'Story not found', 'STORY_NOT_FOUND');
|
||||
}
|
||||
|
||||
// Get unique user IDs from campaign emails (only registered users)
|
||||
const participants = await prisma.campaignEmail.findMany({
|
||||
where: { campaignId: story.campaignId, userId: { not: null } },
|
||||
distinct: ['userId'],
|
||||
select: { userId: true },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
let notified = 0;
|
||||
for (const p of participants) {
|
||||
if (!p.userId) continue;
|
||||
try {
|
||||
await notificationService.createNotification(
|
||||
p.userId,
|
||||
'system',
|
||||
story.title,
|
||||
`New update for "${story.campaign.title}": ${story.title}`,
|
||||
{ storyId: story.id, campaignId: story.campaignId, type: 'impact_story' },
|
||||
);
|
||||
notified++;
|
||||
} catch {
|
||||
// Skip individual notification failures
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Notified ${notified} participants about story ${storyId}`);
|
||||
return { notified };
|
||||
},
|
||||
};
|
||||
@ -13,6 +13,12 @@ const TYPE_TO_PREF: Record<string, string> = {
|
||||
achievement: 'enableAchievements',
|
||||
system: 'enableSystemUpdates',
|
||||
group_call: 'enableSystemUpdates',
|
||||
impact_story: 'enableSystemUpdates',
|
||||
referral_completed: 'enableSystemUpdates',
|
||||
challenge_update: 'enableSystemUpdates',
|
||||
shared_view_invite: 'enableFriendRequests',
|
||||
shared_view_accepted: 'enableFriendRequests',
|
||||
calendar_event_invite: 'enableFriendRequests',
|
||||
};
|
||||
|
||||
export const notificationService = {
|
||||
|
||||
@ -11,6 +11,7 @@ const DEFAULTS: Omit<PrivacySettings, 'id' | 'userId' | 'createdAt' | 'updatedAt
|
||||
hidePublicFinishes: false,
|
||||
allowFriendRequests: true,
|
||||
closeFriendsOnlyWatching: false,
|
||||
showOnLeaderboard: true,
|
||||
};
|
||||
|
||||
export const privacyService = {
|
||||
|
||||
96
api/src/modules/social/referral.routes.ts
Normal file
96
api/src/modules/social/referral.routes.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { referralService } from './referral.service';
|
||||
import { createInviteCodeSchema, validateCodeSchema, paginationSchema } from './referral.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** POST /api/social/referrals/codes — create invite code */
|
||||
router.post('/codes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = createInviteCodeSchema.parse(req.body);
|
||||
const code = await referralService.createInviteCode(req.user!.id, data);
|
||||
res.status(201).json(code);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'CREATE_CODE_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/referrals/codes — list my codes */
|
||||
router.get('/codes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page, limit } = paginationSchema.parse(req.query);
|
||||
const result = await referralService.listMyCodes(req.user!.id, page, limit);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'LIST_CODES_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/social/referrals/codes/:id — deactivate code */
|
||||
router.delete('/codes/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const codeId = req.params.id as string;
|
||||
const result = await referralService.deactivateCode(req.user!.id, codeId);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'DEACTIVATE_CODE_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/referrals/validate/:code — validate an invite code */
|
||||
router.get('/validate/:code', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { code } = validateCodeSchema.parse(req.params);
|
||||
const result = await referralService.validateCode(code);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'VALIDATE_CODE_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/referrals/my-referrals — list people I referred */
|
||||
router.get('/my-referrals', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page, limit } = paginationSchema.parse(req.query);
|
||||
const result = await referralService.getMyReferrals(req.user!.id, page, limit);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'MY_REFERRALS_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/referrals/stats — my referral stats */
|
||||
router.get('/stats', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = await referralService.getReferralStats(req.user!.id);
|
||||
res.json(stats);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'REFERRAL_STATS_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/referrals/admin/all — all referrals (admin only) */
|
||||
router.get('/admin/all', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page, limit } = paginationSchema.parse(req.query);
|
||||
const result = await referralService.listAllReferrals(page, limit);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'ADMIN_REFERRALS_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/referrals/admin/leaderboard — top referrers (admin only) */
|
||||
router.get('/admin/leaderboard', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt((req.query.limit as string) || '10', 10);
|
||||
const leaderboard = await referralService.getReferralLeaderboard(Math.min(limit, 50));
|
||||
res.json({ leaderboard });
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message, code: 'LEADERBOARD_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
export { router as referralRouter };
|
||||
20
api/src/modules/social/referral.schemas.ts
Normal file
20
api/src/modules/social/referral.schemas.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createInviteCodeSchema = z.object({
|
||||
maxUses: z.coerce.number().int().min(0).optional(),
|
||||
expiresInDays: z.coerce.number().int().min(1).max(365).optional(),
|
||||
note: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const validateCodeSchema = z.object({
|
||||
code: z.string().min(1).max(20),
|
||||
});
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export type CreateInviteCodeInput = z.infer<typeof createInviteCodeSchema>;
|
||||
export type ValidateCodeInput = z.infer<typeof validateCodeSchema>;
|
||||
export type PaginationInput = z.infer<typeof paginationSchema>;
|
||||
209
api/src/modules/social/referral.service.ts
Normal file
209
api/src/modules/social/referral.service.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import crypto from 'crypto';
|
||||
import { prisma } from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { notificationService } from './notification.service';
|
||||
import { achievementsService } from './achievements.service';
|
||||
|
||||
function generateCode(): string {
|
||||
return crypto.randomBytes(6).toString('base64url').slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
export const referralService = {
|
||||
async createInviteCode(
|
||||
userId: string,
|
||||
opts: { maxUses?: number; expiresInDays?: number; note?: string },
|
||||
) {
|
||||
const code = generateCode();
|
||||
const expiresAt = opts.expiresInDays
|
||||
? new Date(Date.now() + opts.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
return prisma.inviteCode.create({
|
||||
data: {
|
||||
code,
|
||||
createdByUserId: userId,
|
||||
maxUses: opts.maxUses ?? 0,
|
||||
expiresAt,
|
||||
note: opts.note,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async listMyCodes(userId: string, page: number, limit: number) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [codes, total] = await Promise.all([
|
||||
prisma.inviteCode.findMany({
|
||||
where: { createdByUserId: userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: { _count: { select: { referrals: true } } },
|
||||
}),
|
||||
prisma.inviteCode.count({ where: { createdByUserId: userId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
codes,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async deactivateCode(userId: string, codeId: string) {
|
||||
const code = await prisma.inviteCode.findUnique({ where: { id: codeId } });
|
||||
if (!code) throw new AppError(404, 'Invite code not found', 'CODE_NOT_FOUND');
|
||||
if (code.createdByUserId !== userId) throw new AppError(403, 'Not your invite code', 'FORBIDDEN');
|
||||
|
||||
return prisma.inviteCode.update({
|
||||
where: { id: codeId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
},
|
||||
|
||||
async validateCode(code: string): Promise<{ valid: boolean; referrerId?: string; error?: string }> {
|
||||
const inviteCode = await prisma.inviteCode.findUnique({ where: { code } });
|
||||
if (!inviteCode) return { valid: false, error: 'Code not found' };
|
||||
if (!inviteCode.isActive) return { valid: false, error: 'Code is no longer active' };
|
||||
if (inviteCode.expiresAt && inviteCode.expiresAt < new Date()) {
|
||||
return { valid: false, error: 'Code has expired' };
|
||||
}
|
||||
if (inviteCode.maxUses > 0 && inviteCode.usedCount >= inviteCode.maxUses) {
|
||||
return { valid: false, error: 'Code has reached maximum uses' };
|
||||
}
|
||||
return { valid: true, referrerId: inviteCode.createdByUserId };
|
||||
},
|
||||
|
||||
async processRegistrationReferral(newUserId: string, inviteCode?: string) {
|
||||
if (!inviteCode) return;
|
||||
|
||||
const validation = await this.validateCode(inviteCode);
|
||||
if (!validation.valid || !validation.referrerId) {
|
||||
logger.warn(`Invalid invite code "${inviteCode}" used during registration for user ${newUserId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const codeRecord = await prisma.inviteCode.findUnique({ where: { code: inviteCode } });
|
||||
if (!codeRecord) return;
|
||||
|
||||
// Check if user was already referred (unique constraint on referredUserId)
|
||||
const existing = await prisma.referral.findUnique({ where: { referredUserId: newUserId } });
|
||||
if (existing) return;
|
||||
|
||||
await prisma.inviteCode.update({
|
||||
where: { id: codeRecord.id },
|
||||
data: { usedCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
await prisma.referral.create({
|
||||
data: {
|
||||
referrerId: validation.referrerId,
|
||||
referredUserId: newUserId,
|
||||
inviteCodeId: codeRecord.id,
|
||||
referralSource: 'invite_code',
|
||||
},
|
||||
});
|
||||
|
||||
// Notify the referrer
|
||||
const newUser = await prisma.user.findUnique({
|
||||
where: { id: newUserId },
|
||||
select: { name: true, email: true },
|
||||
});
|
||||
const displayName = newUser?.name || newUser?.email || 'Someone';
|
||||
|
||||
await notificationService.createNotification(
|
||||
validation.referrerId,
|
||||
'referral_completed',
|
||||
'New Referral!',
|
||||
`${displayName} joined using your invite code`,
|
||||
{ referredUserId: newUserId, inviteCode },
|
||||
);
|
||||
|
||||
// Check social achievements
|
||||
achievementsService.checkAndUnlock(validation.referrerId, ['social']).catch((err) => {
|
||||
logger.warn('Referral achievement check failed:', err);
|
||||
});
|
||||
},
|
||||
|
||||
async getMyReferrals(userId: string, page: number, limit: number) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [referrals, total] = await Promise.all([
|
||||
prisma.referral.findMany({
|
||||
where: { referrerId: userId },
|
||||
orderBy: { completedAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
referredUser: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
prisma.referral.count({ where: { referrerId: userId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
referrals,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async getReferralStats(userId: string) {
|
||||
const now = new Date();
|
||||
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const [totalReferrals, thisMonth] = await Promise.all([
|
||||
prisma.referral.count({ where: { referrerId: userId } }),
|
||||
prisma.referral.count({
|
||||
where: { referrerId: userId, completedAt: { gte: firstOfMonth } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { totalReferrals, thisMonth };
|
||||
},
|
||||
|
||||
async listAllReferrals(page: number, limit: number) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [referrals, total] = await Promise.all([
|
||||
prisma.referral.findMany({
|
||||
orderBy: { completedAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
referrer: { select: { id: true, name: true, email: true } },
|
||||
referredUser: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
prisma.referral.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
referrals,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async getReferralLeaderboard(limit: number = 10) {
|
||||
const results = await prisma.$queryRaw<{ referrer_id: string; count: bigint }[]>`
|
||||
SELECT referrer_id, COUNT(*) as count
|
||||
FROM referrals
|
||||
GROUP BY referrer_id
|
||||
ORDER BY count DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
if (results.length === 0) return [];
|
||||
|
||||
const userIds = results.map((r) => r.referrer_id);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
return results.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
userId: r.referrer_id,
|
||||
name: userMap.get(r.referrer_id)?.name || null,
|
||||
email: userMap.get(r.referrer_id)?.email || '',
|
||||
referralCount: Number(r.count),
|
||||
}));
|
||||
},
|
||||
};
|
||||
@ -15,6 +15,10 @@ import { groupRouter } from './group.routes';
|
||||
import { achievementsRouter } from './achievements.routes';
|
||||
import { sseRouter } from './sse.routes';
|
||||
import { socialAdminRouter } from './social-admin.routes';
|
||||
import { referralRouter } from './referral.routes';
|
||||
import { impactStoriesRouter } from './impact-stories.routes';
|
||||
import { spotlightRouter } from './spotlight.routes';
|
||||
import { challengeRouter } from './challenge.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -47,5 +51,9 @@ router.use('/integration', integrationRouter);
|
||||
router.use('/groups', groupRouter);
|
||||
router.use('/achievements', achievementsRouter);
|
||||
router.use('/sse', sseRouter);
|
||||
router.use('/referrals', referralRouter);
|
||||
router.use('/stories', impactStoriesRouter);
|
||||
router.use('/spotlight', spotlightRouter);
|
||||
router.use('/challenges', challengeRouter);
|
||||
|
||||
export { router as socialRouter };
|
||||
|
||||
190
api/src/modules/social/spotlight.routes.ts
Normal file
190
api/src/modules/social/spotlight.routes.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { spotlightService } from './spotlight.service';
|
||||
import {
|
||||
nominateSchema,
|
||||
updateSpotlightSchema,
|
||||
featureSchema,
|
||||
listSpotlightsSchema,
|
||||
} from './spotlight.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Public / authenticated user routes ──────────────────────────────
|
||||
|
||||
/** GET /api/social/spotlight/featured — current month's featured spotlights */
|
||||
router.get('/featured', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const spotlights = await spotlightService.getCurrentFeatured();
|
||||
res.json({ spotlights });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/spotlight/leaderboard — public leaderboard (filtered by opt-in) */
|
||||
router.get('/leaderboard', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const type = (req.query.type as string) || 'canvass';
|
||||
if (!['canvass', 'shifts', 'campaigns'].includes(type)) {
|
||||
return res.status(400).json({ error: { message: 'Invalid leaderboard type' } });
|
||||
}
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
const leaderboard = await spotlightService.getPublicLeaderboard(
|
||||
type as 'canvass' | 'shifts' | 'campaigns',
|
||||
limit,
|
||||
);
|
||||
res.json({ leaderboard, type });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/spotlight/wall-of-fame — all featured spotlights */
|
||||
router.get('/wall-of-fame', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const limit = Math.min(Math.max(1, parseInt(req.query.limit as string) || 20), 100);
|
||||
const result = await spotlightService.getWallOfFame(page, limit);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/spotlight/opt-in-status — current user's leaderboard opt-in */
|
||||
router.get('/opt-in-status', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await spotlightService.getOptInStatus(req.user!.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/social/spotlight/opt-in — opt in to leaderboard */
|
||||
router.post('/opt-in', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await spotlightService.optIn(req.user!.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/social/spotlight/opt-out — opt out of leaderboard */
|
||||
router.post('/opt-out', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await spotlightService.optOut(req.user!.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Admin routes ────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/social/spotlight/admin — list all spotlights */
|
||||
router.get(
|
||||
'/admin',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { page, limit, status } = listSpotlightsSchema.parse(req.query);
|
||||
const result = await spotlightService.listAll(page, limit, status as any);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/social/spotlight/admin/nominate — nominate a volunteer */
|
||||
router.post(
|
||||
'/admin/nominate',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = nominateSchema.parse(req.body);
|
||||
const spotlight = await spotlightService.nominate(data, req.user!.id);
|
||||
res.status(201).json(spotlight);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** PUT /api/social/spotlight/admin/:id — update headline/story */
|
||||
router.put(
|
||||
'/admin/:id',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = updateSpotlightSchema.parse(req.body);
|
||||
const spotlight = await spotlightService.update(req.params.id as string, data);
|
||||
res.json(spotlight);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/social/spotlight/admin/:id/approve — approve a nomination */
|
||||
router.post(
|
||||
'/admin/:id/approve',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const spotlight = await spotlightService.approve(req.params.id as string, req.user!.id);
|
||||
res.json(spotlight);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/social/spotlight/admin/:id/feature — feature for a month */
|
||||
router.post(
|
||||
'/admin/:id/feature',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month } = featureSchema.parse(req.body);
|
||||
const spotlight = await spotlightService.feature(req.params.id as string, month);
|
||||
res.json(spotlight);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/social/spotlight/admin/:id/archive — archive a spotlight */
|
||||
router.post(
|
||||
'/admin/:id/archive',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const spotlight = await spotlightService.archive(req.params.id as string);
|
||||
res.json(spotlight);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/social/spotlight/admin/:id — delete a spotlight */
|
||||
router.delete(
|
||||
'/admin/:id',
|
||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await spotlightService.delete(req.params.id as string);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const spotlightRouter = router;
|
||||
27
api/src/modules/social/spotlight.schemas.ts
Normal file
27
api/src/modules/social/spotlight.schemas.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const nominateSchema = z.object({
|
||||
userId: z.string().cuid(),
|
||||
headline: z.string().max(200).optional(),
|
||||
story: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const updateSpotlightSchema = z.object({
|
||||
headline: z.string().max(200).optional(),
|
||||
story: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const featureSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/, 'Month must be in YYYY-MM format'),
|
||||
});
|
||||
|
||||
export const listSpotlightsSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
status: z.enum(['NOMINATED', 'APPROVED', 'FEATURED', 'ARCHIVED']).optional(),
|
||||
});
|
||||
|
||||
export type NominateInput = z.infer<typeof nominateSchema>;
|
||||
export type UpdateSpotlightInput = z.infer<typeof updateSpotlightSchema>;
|
||||
export type FeatureInput = z.infer<typeof featureSchema>;
|
||||
export type ListSpotlightsInput = z.infer<typeof listSpotlightsSchema>;
|
||||
245
api/src/modules/social/spotlight.service.ts
Normal file
245
api/src/modules/social/spotlight.service.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import { prisma } from '../../config/database';
|
||||
import { SpotlightStatus } from '@prisma/client';
|
||||
import { notificationService } from './notification.service';
|
||||
import { achievementsService } from './achievements.service';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
|
||||
export const spotlightService = {
|
||||
/** Admin: nominate a volunteer for spotlight */
|
||||
async nominate(
|
||||
data: { userId: string; headline?: string; story?: string },
|
||||
nominatedByUserId: string,
|
||||
) {
|
||||
// Verify the user exists
|
||||
const user = await prisma.user.findUnique({ where: { id: data.userId } });
|
||||
if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND');
|
||||
|
||||
const spotlight = await prisma.volunteerSpotlight.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
status: SpotlightStatus.NOMINATED,
|
||||
headline: data.headline,
|
||||
story: data.story,
|
||||
nominatedByUserId,
|
||||
},
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
|
||||
logger.info(`Volunteer ${data.userId} nominated for spotlight by ${nominatedByUserId}`);
|
||||
return spotlight;
|
||||
},
|
||||
|
||||
/** Admin: approve a nomination */
|
||||
async approve(id: string, approvedByUserId: string) {
|
||||
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
|
||||
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
|
||||
if (spotlight.status !== SpotlightStatus.NOMINATED) {
|
||||
throw new AppError(400, 'Only nominated spotlights can be approved', 'INVALID_STATUS');
|
||||
}
|
||||
|
||||
const updated = await prisma.volunteerSpotlight.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: SpotlightStatus.APPROVED,
|
||||
approvedByUserId,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
|
||||
await notificationService.createNotification(
|
||||
spotlight.userId,
|
||||
'achievement',
|
||||
'Spotlight Approved',
|
||||
'Your volunteer spotlight nomination has been approved!',
|
||||
{ spotlightId: id },
|
||||
);
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/** Admin: feature a spotlight for a specific month */
|
||||
async feature(id: string, month: string) {
|
||||
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
|
||||
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
|
||||
if (spotlight.status !== SpotlightStatus.APPROVED) {
|
||||
throw new AppError(400, 'Only approved spotlights can be featured', 'INVALID_STATUS');
|
||||
}
|
||||
|
||||
const updated = await prisma.volunteerSpotlight.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: SpotlightStatus.FEATURED,
|
||||
featuredMonth: month,
|
||||
},
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
|
||||
await notificationService.createNotification(
|
||||
spotlight.userId,
|
||||
'achievement',
|
||||
'You\'re Featured!',
|
||||
`You have been featured as a Volunteer Spotlight for ${month}!`,
|
||||
{ spotlightId: id, featuredMonth: month },
|
||||
);
|
||||
|
||||
logger.info(`Spotlight ${id} featured for ${month}`);
|
||||
return updated;
|
||||
},
|
||||
|
||||
/** Admin: archive a spotlight */
|
||||
async archive(id: string) {
|
||||
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
|
||||
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
|
||||
|
||||
return prisma.volunteerSpotlight.update({
|
||||
where: { id },
|
||||
data: { status: SpotlightStatus.ARCHIVED },
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
},
|
||||
|
||||
/** Admin: update headline/story */
|
||||
async update(id: string, data: { headline?: string; story?: string }) {
|
||||
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
|
||||
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
|
||||
|
||||
return prisma.volunteerSpotlight.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
},
|
||||
|
||||
/** Admin: delete a spotlight */
|
||||
async delete(id: string) {
|
||||
const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } });
|
||||
if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND');
|
||||
|
||||
await prisma.volunteerSpotlight.delete({ where: { id } });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/** Admin: list all spotlights (paginated, filterable) */
|
||||
async listAll(page: number, limit: number, status?: SpotlightStatus) {
|
||||
const where = status ? { status } : {};
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [spotlights, total] = await Promise.all([
|
||||
prisma.volunteerSpotlight.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
nominatedBy: { select: { id: true, name: true } },
|
||||
approvedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.volunteerSpotlight.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
spotlights,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
/** Public: get currently featured spotlights (this month) */
|
||||
async getCurrentFeatured() {
|
||||
const now = new Date();
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
return prisma.volunteerSpotlight.findMany({
|
||||
where: {
|
||||
status: SpotlightStatus.FEATURED,
|
||||
featuredMonth: currentMonth,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
},
|
||||
|
||||
/** Public: get leaderboard filtered by showOnLeaderboard privacy setting */
|
||||
async getPublicLeaderboard(type: 'canvass' | 'shifts' | 'campaigns', limit: number) {
|
||||
// Get a larger set from achievements service, then filter by privacy
|
||||
const entries = await achievementsService.getLeaderboard(type, limit * 3);
|
||||
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
// Filter by showOnLeaderboard
|
||||
const userIds = entries.map((e) => e.userId);
|
||||
const hiddenIds = new Set(
|
||||
(await prisma.privacySettings.findMany({
|
||||
where: {
|
||||
userId: { in: userIds },
|
||||
showOnLeaderboard: false,
|
||||
},
|
||||
select: { userId: true },
|
||||
})).map((p) => p.userId),
|
||||
);
|
||||
|
||||
const visible = entries
|
||||
.filter((e) => !hiddenIds.has(e.userId))
|
||||
.slice(0, limit)
|
||||
.map((e, i) => ({ ...e, rank: i + 1 }));
|
||||
|
||||
return visible;
|
||||
},
|
||||
|
||||
/** Public: wall of fame — all featured spotlights */
|
||||
async getWallOfFame(page: number, limit: number) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [spotlights, total] = await Promise.all([
|
||||
prisma.volunteerSpotlight.findMany({
|
||||
where: { status: SpotlightStatus.FEATURED },
|
||||
orderBy: { featuredMonth: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
user: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.volunteerSpotlight.count({ where: { status: SpotlightStatus.FEATURED } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
spotlights,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
/** User: get opt-in status for leaderboard */
|
||||
async getOptInStatus(userId: string) {
|
||||
const settings = await prisma.privacySettings.findUnique({
|
||||
where: { userId },
|
||||
select: { showOnLeaderboard: true },
|
||||
});
|
||||
return { showOnLeaderboard: settings?.showOnLeaderboard ?? true };
|
||||
},
|
||||
|
||||
/** User: opt in to leaderboard */
|
||||
async optIn(userId: string) {
|
||||
await prisma.privacySettings.upsert({
|
||||
where: { userId },
|
||||
update: { showOnLeaderboard: true },
|
||||
create: { userId, showOnLeaderboard: true },
|
||||
});
|
||||
return { showOnLeaderboard: true };
|
||||
},
|
||||
|
||||
/** User: opt out of leaderboard */
|
||||
async optOut(userId: string) {
|
||||
await prisma.privacySettings.upsert({
|
||||
where: { userId },
|
||||
update: { showOnLeaderboard: false },
|
||||
create: { userId, showOnLeaderboard: false },
|
||||
});
|
||||
return { showOnLeaderboard: false };
|
||||
},
|
||||
};
|
||||
67
api/src/modules/ticketed-events/checkin.routes.ts
Normal file
67
api/src/modules/ticketed-events/checkin.routes.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { ticketsService } from './tickets.service';
|
||||
import { validateTokenSchema, confirmCheckinSchema, manualCheckinSchema } from './ticketed-events.schemas';
|
||||
import { prisma } from '../../config/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All check-in routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// POST /validate — validate QR token (preview without marking checked in)
|
||||
router.post('/validate', validate(validateTokenSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await ticketsService.validateToken(req.body.token);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /confirm — confirm check-in via QR token
|
||||
router.post('/confirm', validate(confirmCheckinSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await ticketsService.confirmCheckin(
|
||||
req.body.token,
|
||||
req.user!.id,
|
||||
req.body.notes,
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /manual — manual check-in by code or email
|
||||
router.post('/manual', validate(manualCheckinSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await ticketsService.manualCheckin({
|
||||
eventId: req.body.eventId,
|
||||
ticketCode: req.body.ticketCode,
|
||||
holderEmail: req.body.holderEmail,
|
||||
checkedInByUserId: req.user!.id,
|
||||
notes: req.body.notes,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /event/:eventId/stats — live check-in stats
|
||||
router.get('/event/:eventId/stats', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const eventId = req.params.eventId as string;
|
||||
const [totalTickets, checkedIn, recentCheckIns] = await Promise.all([
|
||||
prisma.ticket.count({ where: { eventId, status: { in: ['VALID', 'CHECKED_IN'] } } }),
|
||||
prisma.ticket.count({ where: { eventId, status: 'CHECKED_IN' } }),
|
||||
prisma.checkIn.findMany({
|
||||
where: { eventId },
|
||||
orderBy: { checkedInAt: 'desc' },
|
||||
take: 5,
|
||||
include: {
|
||||
ticket: { select: { ticketCode: true, holderName: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
res.json({ totalTickets, checkedIn, remaining: totalTickets - checkedIn, recentCheckIns });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export { router as checkinRouter };
|
||||
148
api/src/modules/ticketed-events/ticket-email.service.ts
Normal file
148
api/src/modules/ticketed-events/ticket-email.service.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { emailService } from '../../services/email.service';
|
||||
import { siteSettingsService } from '../settings/settings.service';
|
||||
import { env } from '../../config/env';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export const ticketEmailService = {
|
||||
/** Send ticket confirmation email with QR code */
|
||||
async sendTicketConfirmation(params: {
|
||||
holderEmail: string;
|
||||
holderName?: string | null;
|
||||
ticketCode: string;
|
||||
token: string;
|
||||
eventTitle: string;
|
||||
eventDate: Date;
|
||||
eventStartTime: string;
|
||||
eventEndTime: string;
|
||||
eventSlug: string;
|
||||
venueName?: string | null;
|
||||
venueAddress?: string | null;
|
||||
tierName: string;
|
||||
eventFormat?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const orgName = await this.getOrgName();
|
||||
const baseUrl = env.ADMIN_URL || 'http://localhost:3000';
|
||||
const checkinUrl = `${baseUrl}/event/${params.eventSlug}/checkin?token=${encodeURIComponent(params.token)}`;
|
||||
const qrImageUrl = `${env.API_URL}/api/qr?text=${encodeURIComponent(checkinUrl)}&size=300`;
|
||||
const eventUrl = `${baseUrl}/event/${params.eventSlug}`;
|
||||
const ticketUrl = `${baseUrl}/event/${params.eventSlug}/ticket/${params.ticketCode}`;
|
||||
|
||||
const hasOnlineAccess = params.eventFormat === 'ONLINE' || params.eventFormat === 'HYBRID';
|
||||
const vars: Record<string, string> = {
|
||||
RECIPIENT_NAME: params.holderName || 'Guest',
|
||||
EVENT_TITLE: params.eventTitle,
|
||||
EVENT_DATE: params.eventDate.toLocaleDateString('en-CA', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
}),
|
||||
EVENT_TIME: `${params.eventStartTime} – ${params.eventEndTime}`,
|
||||
VENUE_NAME: params.venueName || '',
|
||||
VENUE_ADDRESS: params.venueAddress || '',
|
||||
TICKET_TIER: params.tierName,
|
||||
TICKET_CODE: params.ticketCode,
|
||||
QR_IMAGE_URL: qrImageUrl,
|
||||
EVENT_URL: eventUrl,
|
||||
TICKET_URL: ticketUrl,
|
||||
ORGANIZATION_NAME: orgName,
|
||||
ONLINE_ACCESS: hasOnlineAccess
|
||||
? 'This event has online access. Visit the event page and enter your ticket code to join the meeting.'
|
||||
: '',
|
||||
};
|
||||
|
||||
// Try DB template first, fall back to inline template
|
||||
const dbTemplate = await emailService['loadTemplateFromDatabase']('ticket-confirmation');
|
||||
|
||||
let html: string, text: string, subject: string;
|
||||
if (dbTemplate) {
|
||||
html = await emailService.processTemplate(dbTemplate.html, vars);
|
||||
text = await emailService.processTextTemplate(dbTemplate.text, vars);
|
||||
subject = emailService.processSubject(dbTemplate.subject, vars);
|
||||
} else {
|
||||
// Inline fallback template
|
||||
subject = `Your Ticket: ${params.eventTitle} — ${orgName}`;
|
||||
html = this.getDefaultHtmlTemplate(vars);
|
||||
text = this.getDefaultTextTemplate(vars);
|
||||
}
|
||||
|
||||
await emailService.sendEmail({ to: params.holderEmail, subject, html, text });
|
||||
logger.info(`Ticket confirmation sent to ${params.holderEmail} for ${params.ticketCode}`);
|
||||
} catch (err) {
|
||||
logger.error('Failed to send ticket confirmation email:', err);
|
||||
}
|
||||
},
|
||||
|
||||
getDefaultHtmlTemplate(vars: Record<string, string>): string {
|
||||
const venue = vars.VENUE_NAME
|
||||
? `<p style="margin:0;color:#999">📍 ${vars.VENUE_NAME}${vars.VENUE_ADDRESS ? `, ${vars.VENUE_ADDRESS}` : ''}</p>`
|
||||
: '';
|
||||
const onlineAccessHtml = vars.ONLINE_ACCESS
|
||||
? `<div style="background:#f3e8ff;padding:15px;border-radius:8px;margin:20px 0;border-left:4px solid #9d4edd"><p style="margin:0;color:#6b21a8">🎥 ${vars.ONLINE_ACCESS}</p></div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#f5f5f5;font-family:Arial,sans-serif">
|
||||
<div style="max-width:600px;margin:0 auto;padding:20px">
|
||||
<div style="background:#1a1025;color:#fff;padding:30px;border-radius:12px 12px 0 0;text-align:center">
|
||||
<h1 style="margin:0;font-size:24px">${vars.EVENT_TITLE}</h1>
|
||||
<p style="margin:10px 0 0;opacity:0.8">${vars.EVENT_DATE} at ${vars.EVENT_TIME}</p>
|
||||
${venue}
|
||||
</div>
|
||||
<div style="background:#fff;padding:30px;border-radius:0 0 12px 12px">
|
||||
<p>Hi ${vars.RECIPIENT_NAME},</p>
|
||||
<p>Your ticket is confirmed! Here are your details:</p>
|
||||
${onlineAccessHtml}
|
||||
<div style="background:#f8f8f8;padding:20px;border-radius:8px;margin:20px 0;text-align:center">
|
||||
<p style="margin:0 0 10px;font-size:14px;color:#666">Ticket Code</p>
|
||||
<p style="margin:0;font-size:28px;font-weight:bold;letter-spacing:2px;color:#9d4edd">${vars.TICKET_CODE}</p>
|
||||
<p style="margin:10px 0 0;font-size:13px;color:#999">${vars.TICKET_TIER}</p>
|
||||
</div>
|
||||
<div style="text-align:center;margin:20px 0">
|
||||
<p style="margin:0 0 10px;font-size:14px;color:#666">Show this QR code at the door:</p>
|
||||
<img src="${vars.QR_IMAGE_URL}" alt="Ticket QR Code" width="200" height="200" style="border:1px solid #eee;border-radius:8px">
|
||||
</div>
|
||||
<div style="text-align:center;margin:20px 0">
|
||||
<a href="${vars.TICKET_URL}" style="display:inline-block;background:#9d4edd;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold">View Your Ticket</a>
|
||||
</div>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:20px 0">
|
||||
<p style="font-size:13px;color:#999;text-align:center">${vars.ORGANIZATION_NAME}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
},
|
||||
|
||||
getDefaultTextTemplate(vars: Record<string, string>): string {
|
||||
const venue = vars.VENUE_NAME
|
||||
? `Venue: ${vars.VENUE_NAME}${vars.VENUE_ADDRESS ? `, ${vars.VENUE_ADDRESS}` : ''}\n`
|
||||
: '';
|
||||
const onlineAccessText = vars.ONLINE_ACCESS ? `\n${vars.ONLINE_ACCESS}\n` : '';
|
||||
|
||||
return `Your Ticket: ${vars.EVENT_TITLE}
|
||||
|
||||
Hi ${vars.RECIPIENT_NAME},
|
||||
|
||||
Your ticket is confirmed!
|
||||
|
||||
Event: ${vars.EVENT_TITLE}
|
||||
Date: ${vars.EVENT_DATE}
|
||||
Time: ${vars.EVENT_TIME}
|
||||
${venue}Ticket: ${vars.TICKET_TIER}
|
||||
Code: ${vars.TICKET_CODE}
|
||||
${onlineAccessText}
|
||||
View your ticket: ${vars.TICKET_URL}
|
||||
|
||||
${vars.ORGANIZATION_NAME}`;
|
||||
},
|
||||
|
||||
async getOrgName(): Promise<string> {
|
||||
try {
|
||||
const settings = await siteSettingsService.get();
|
||||
return settings.organizationName || 'Changemaker Lite';
|
||||
} catch {
|
||||
return 'Changemaker Lite';
|
||||
}
|
||||
},
|
||||
};
|
||||
279
api/src/modules/ticketed-events/ticketed-events-admin.routes.ts
Normal file
279
api/src/modules/ticketed-events/ticketed-events-admin.routes.ts
Normal file
@ -0,0 +1,279 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { ticketedEventsService } from './ticketed-events.service';
|
||||
import { ticketsService } from './tickets.service';
|
||||
import { ticketEmailService } from './ticket-email.service';
|
||||
import {
|
||||
createEventSchema,
|
||||
updateEventSchema,
|
||||
createTierSchema,
|
||||
updateTierSchema,
|
||||
} from './ticketed-events.schemas';
|
||||
import { prisma } from '../../config/database';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
const router = Router();
|
||||
const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
|
||||
|
||||
/** Middleware: require admin role OR canCreateTicketedEvents permission */
|
||||
async function requireEventPermission(req: Request, _res: Response, next: NextFunction) {
|
||||
if (!req.user) return next(new Error('Auth required'));
|
||||
|
||||
const userRoles = req.user.roles || [req.user.role];
|
||||
if (userRoles.some(r => ADMIN_ROLES.includes(r as UserRole))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check user permissions
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: { permissions: true },
|
||||
});
|
||||
const perms = (user?.permissions as Record<string, unknown>) || {};
|
||||
if (perms.canCreateTicketedEvents) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return next({ status: 403, message: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
// All routes require auth + event permission
|
||||
router.use(authenticate, requireEventPermission);
|
||||
|
||||
// GET / — list events (admin sees all, users see own)
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const status = req.query.status as string | undefined;
|
||||
const search = req.query.search as string | undefined;
|
||||
|
||||
const userRoles = req.user!.roles || [req.user!.role];
|
||||
const isAdmin = userRoles.some(r => ADMIN_ROLES.includes(r as UserRole));
|
||||
|
||||
const result = await ticketedEventsService.list({
|
||||
page,
|
||||
limit,
|
||||
status: status as never,
|
||||
search,
|
||||
createdByUserId: isAdmin ? undefined : req.user!.id,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST / — create event
|
||||
router.post('/', validate(createEventSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.create(req.body, req.user!.id);
|
||||
res.status(201).json(event);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:id — event detail
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.findById(req.params.id as string);
|
||||
res.json(event);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /:id — update event
|
||||
router.put('/:id', validate(updateEventSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.update(
|
||||
req.params.id as string,
|
||||
req.body,
|
||||
req.user!.id,
|
||||
req.user!.role,
|
||||
);
|
||||
res.json(event);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id — delete event (draft only)
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await ticketedEventsService.deleteEvent(req.params.id as string, req.user!.id, req.user!.role);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/publish
|
||||
router.post('/:id/publish', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.publish(req.params.id as string, req.user!.id, req.user!.role);
|
||||
res.json(event);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/approve (admin only)
|
||||
router.post('/:id/approve', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.approve(req.params.id as string);
|
||||
res.json(event);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/reject (admin only)
|
||||
router.post('/:id/reject', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.reject(req.params.id as string);
|
||||
res.json(event);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/cancel
|
||||
router.post('/:id/cancel', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.cancel(req.params.id as string, req.user!.id, req.user!.role);
|
||||
res.json(event);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/complete (admin only)
|
||||
router.post('/:id/complete', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.complete(req.params.id as string);
|
||||
res.json(event);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Meeting ---
|
||||
|
||||
// POST /:id/meeting-token — generate moderator JWT for Jitsi
|
||||
router.post('/:id/meeting-token', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
if (!user) throw new Error('User not found');
|
||||
const result = await ticketedEventsService.getModeratorToken(req.params.id as string, user);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Tiers ---
|
||||
|
||||
// POST /:id/tiers
|
||||
router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tier = await ticketedEventsService.addTier(req.params.id as string, req.body);
|
||||
res.status(201).json(tier);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /:id/tiers/:tierId
|
||||
router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tier = await ticketedEventsService.updateTier(req.params.tierId as string, req.body);
|
||||
res.json(tier);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id/tiers/:tierId
|
||||
router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await ticketedEventsService.deleteTier(req.params.tierId as string);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Tickets ---
|
||||
|
||||
// GET /:id/tickets
|
||||
router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const search = req.query.search as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const result = await ticketedEventsService.getTickets(req.params.id as string, { page, limit, search, status });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:id/checkins
|
||||
router.get('/:id/checkins', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const result = await ticketedEventsService.getCheckIns(req.params.id as string, { page, limit });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:id/stats
|
||||
router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await ticketedEventsService.getEventStats(req.params.id as string);
|
||||
res.json(stats);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/resend-ticket/:ticketId
|
||||
router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: req.params.ticketId as string },
|
||||
include: {
|
||||
event: true,
|
||||
tier: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (!ticket) { res.status(404).json({ error: { message: 'Ticket not found' } }); return; }
|
||||
if (ticket.eventId !== req.params.id) { res.status(400).json({ error: { message: 'Ticket does not belong to this event' } }); return; }
|
||||
|
||||
// Re-generate token for the email (token not stored, we need tokenHash lookup)
|
||||
// For resend, we regenerate the token and update the hash
|
||||
const crypto = await import('crypto');
|
||||
const { env: envConfig } = await import('../../config/env');
|
||||
const nonce = crypto.randomBytes(16);
|
||||
const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY || envConfig.JWT_ACCESS_SECRET);
|
||||
hmac.update(ticket.id);
|
||||
hmac.update(nonce);
|
||||
const token = Buffer.concat([
|
||||
Buffer.from(ticket.id),
|
||||
Buffer.from(':'),
|
||||
nonce,
|
||||
Buffer.from(':'),
|
||||
hmac.digest(),
|
||||
]).toString('base64url');
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
await prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { tokenHash },
|
||||
});
|
||||
|
||||
await ticketEmailService.sendTicketConfirmation({
|
||||
holderEmail: ticket.holderEmail,
|
||||
holderName: ticket.holderName,
|
||||
ticketCode: ticket.ticketCode,
|
||||
token,
|
||||
eventTitle: ticket.event.title,
|
||||
eventDate: ticket.event.date,
|
||||
eventStartTime: ticket.event.startTime,
|
||||
eventEndTime: ticket.event.endTime,
|
||||
eventSlug: ticket.event.slug,
|
||||
venueName: ticket.event.venueName,
|
||||
venueAddress: ticket.event.venueAddress,
|
||||
tierName: ticket.tier.name,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/tickets/:ticketId/cancel
|
||||
router.post('/:id/tickets/:ticketId/cancel', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await ticketsService.cancelTicket(req.params.ticketId as string);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export { router as ticketedEventsAdminRouter };
|
||||
261
api/src/modules/ticketed-events/ticketed-events-public.routes.ts
Normal file
261
api/src/modules/ticketed-events/ticketed-events-public.routes.ts
Normal file
@ -0,0 +1,261 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { optionalAuth } from '../../middleware/auth.middleware';
|
||||
import { ticketedEventsService } from './ticketed-events.service';
|
||||
import { ticketsService } from './tickets.service';
|
||||
import { ticketEmailService } from './ticket-email.service';
|
||||
import { checkoutSchema, registerFreeSchema } from './ticketed-events.schemas';
|
||||
import { getStripe } from '../../services/stripe.client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { env } from '../../config/env';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET / — list published events
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const upcoming = req.query.upcoming === 'true';
|
||||
const result = await ticketedEventsService.listPublished({ page, limit, upcoming });
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /my-tickets — authenticated user's tickets (must be before /:slug)
|
||||
router.get('/my-tickets', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required', 'UNAUTHORIZED');
|
||||
}
|
||||
const tickets = await ticketsService.getUserTickets(req.user.id);
|
||||
const ticketsWithQr = tickets.map(t => ({
|
||||
...t,
|
||||
qrUrl: `${env.API_URL}/api/qr?text=${encodeURIComponent(t.ticketCode)}&size=300`,
|
||||
}));
|
||||
res.json({ tickets: ticketsWithQr });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:slug — event detail by slug
|
||||
router.get('/:slug', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const event = await ticketedEventsService.findBySlug(slug);
|
||||
|
||||
// Check private event access
|
||||
if (event.visibility === 'PRIVATE') {
|
||||
const inviteCode = req.query.inviteCode as string | undefined;
|
||||
if (!inviteCode || inviteCode !== event.inviteCode) {
|
||||
// Return limited info without invite code
|
||||
res.json({
|
||||
id: event.id,
|
||||
slug: event.slug,
|
||||
title: event.title,
|
||||
visibility: 'PRIVATE',
|
||||
requiresInviteCode: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only show published events publicly
|
||||
if (event.status !== 'PUBLISHED') {
|
||||
throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
// Include format + hasMeeting (but NOT the room name — that's gated)
|
||||
const { meeting, ...rest } = event;
|
||||
res.json({
|
||||
...rest,
|
||||
hasMeeting: !!(meeting?.isActive),
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:slug/meeting-access — ticket-gated Jitsi room access
|
||||
router.get('/:slug/meeting-access', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const ticketCode = req.query.ticketCode as string;
|
||||
if (!ticketCode) {
|
||||
throw new AppError(400, 'ticketCode query parameter is required', 'MISSING_TICKET_CODE');
|
||||
}
|
||||
const access = await ticketedEventsService.getMeetingAccess(slug, ticketCode);
|
||||
res.json(access);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:slug/availability — ticket availability
|
||||
router.get('/:slug/availability', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { slug } });
|
||||
if (!event || event.status !== 'PUBLISHED') {
|
||||
throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
}
|
||||
const availability = await ticketedEventsService.getAvailability(event.id);
|
||||
res.json(availability);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:slug/checkout — create Stripe checkout for paid ticket
|
||||
router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const { tierId, quantity, buyerEmail, buyerName } = req.body;
|
||||
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { slug } });
|
||||
if (!event || event.status !== 'PUBLISHED') {
|
||||
throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
||||
if (!tier || tier.eventId !== event.id) {
|
||||
throw new AppError(400, 'Invalid tier', 'INVALID_TIER');
|
||||
}
|
||||
if (tier.tierType === 'FREE') {
|
||||
throw new AppError(400, 'Use /register for free tickets', 'USE_REGISTER');
|
||||
}
|
||||
|
||||
// Check availability
|
||||
if (tier.maxQuantity && tier.soldCount + quantity > tier.maxQuantity) {
|
||||
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
|
||||
}
|
||||
if (event.maxAttendees && event.currentAttendees + quantity > event.maxAttendees) {
|
||||
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
|
||||
}
|
||||
|
||||
// Check sales window
|
||||
const now = new Date();
|
||||
if (tier.salesStartAt && tier.salesStartAt > now) {
|
||||
throw new AppError(400, 'Ticket sales have not started yet', 'NOT_ON_SALE');
|
||||
}
|
||||
if (tier.salesEndAt && tier.salesEndAt < now) {
|
||||
throw new AppError(400, 'Ticket sales have ended', 'SALES_ENDED');
|
||||
}
|
||||
|
||||
const unitAmount = tier.tierType === 'DONATION'
|
||||
? Math.max(tier.priceCAD, tier.minDonationCAD || 0)
|
||||
: tier.priceCAD;
|
||||
|
||||
const stripe = await getStripe();
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency: 'cad',
|
||||
product_data: {
|
||||
name: `${event.title} — ${tier.name}`,
|
||||
description: tier.description || `Ticket for ${event.title}`,
|
||||
},
|
||||
unit_amount: unitAmount,
|
||||
},
|
||||
quantity,
|
||||
}],
|
||||
customer_email: buyerEmail,
|
||||
success_url: `${env.ADMIN_URL}/event/${slug}/ticket/{CHECKOUT_SESSION_ID}?success=true`,
|
||||
cancel_url: `${env.ADMIN_URL}/event/${slug}`,
|
||||
metadata: {
|
||||
type: 'event_ticket',
|
||||
eventId: event.id,
|
||||
tierId: tier.id,
|
||||
quantity: String(quantity),
|
||||
buyerEmail,
|
||||
buyerName: buyerName || '',
|
||||
userId: req.user?.id || '',
|
||||
},
|
||||
expires_after: 1800, // 30 minutes
|
||||
} as never);
|
||||
|
||||
// Create pending order
|
||||
await prisma.order.create({
|
||||
data: {
|
||||
userId: req.user?.id || null,
|
||||
amountCAD: unitAmount * quantity,
|
||||
status: 'PENDING',
|
||||
stripeCheckoutSessionId: session.id,
|
||||
type: 'event_ticket',
|
||||
buyerEmail,
|
||||
buyerName: buyerName || null,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ sessionId: session.id, url: session.url });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:slug/register — register for free ticket (no Stripe)
|
||||
router.post('/:slug/register', optionalAuth, validate(registerFreeSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const { tierId, quantity, holderEmail, holderName } = req.body;
|
||||
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { slug } });
|
||||
if (!event || event.status !== 'PUBLISHED') {
|
||||
throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
||||
if (!tier || tier.eventId !== event.id) {
|
||||
throw new AppError(400, 'Invalid tier', 'INVALID_TIER');
|
||||
}
|
||||
if (tier.tierType !== 'FREE') {
|
||||
throw new AppError(400, 'This tier requires payment', 'REQUIRES_PAYMENT');
|
||||
}
|
||||
|
||||
const tickets = await ticketsService.createTickets({
|
||||
eventId: event.id,
|
||||
tierId,
|
||||
quantity,
|
||||
holderEmail,
|
||||
holderName,
|
||||
userId: req.user?.id,
|
||||
});
|
||||
|
||||
// Send confirmation emails (fire-and-forget)
|
||||
for (const ticket of tickets) {
|
||||
ticketEmailService.sendTicketConfirmation({
|
||||
holderEmail: ticket.holderEmail,
|
||||
holderName: ticket.holderName,
|
||||
ticketCode: ticket.ticketCode,
|
||||
token: (ticket as Record<string, unknown>).token as string,
|
||||
eventTitle: event.title,
|
||||
eventDate: event.date,
|
||||
eventStartTime: event.startTime,
|
||||
eventEndTime: event.endTime,
|
||||
eventSlug: event.slug,
|
||||
venueName: event.venueName,
|
||||
venueAddress: event.venueAddress,
|
||||
tierName: tier.name,
|
||||
eventFormat: event.eventFormat,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
tickets: tickets.map(t => ({
|
||||
id: t.id,
|
||||
ticketCode: t.ticketCode,
|
||||
holderEmail: t.holderEmail,
|
||||
holderName: t.holderName,
|
||||
status: t.status,
|
||||
})),
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:slug/ticket/:ticketCode — ticket confirmation page data
|
||||
router.get('/:slug/ticket/:ticketCode', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const ticket = await ticketsService.findByCode(req.params.ticketCode as string);
|
||||
if (ticket.event.slug !== req.params.slug) {
|
||||
throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
|
||||
}
|
||||
// Generate QR URL from ticket code (check-in scanner handles code-based lookup)
|
||||
const qrUrl = `${env.API_URL}/api/qr?text=${encodeURIComponent(ticket.ticketCode)}&size=300`;
|
||||
res.json({ ...ticket, qrUrl });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export { router as ticketedEventsPublicRouter };
|
||||
117
api/src/modules/ticketed-events/ticketed-events.schemas.ts
Normal file
117
api/src/modules/ticketed-events/ticketed-events.schemas.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createEventSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
richDescription: z.string().max(50000).optional(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
doorsOpenTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
|
||||
eventFormat: z.enum(['IN_PERSON', 'ONLINE', 'HYBRID']).default('IN_PERSON'),
|
||||
venueName: z.string().max(200).optional(),
|
||||
venueAddress: z.string().max(500).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
visibility: z.enum(['PUBLIC', 'UNLISTED', 'PRIVATE']).optional(),
|
||||
coverImageUrl: z.string().url().optional(),
|
||||
maxAttendees: z.number().int().positive().optional(),
|
||||
organizerName: z.string().max(200).optional(),
|
||||
organizerEmail: z.string().email().optional(),
|
||||
tiers: z.array(z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
tierType: z.enum(['PAID', 'FREE', 'DONATION']),
|
||||
priceCAD: z.number().int().min(0).default(0),
|
||||
minDonationCAD: z.number().int().min(0).optional(),
|
||||
maxQuantity: z.number().int().positive().optional(),
|
||||
maxPerOrder: z.number().int().min(1).max(100).default(10),
|
||||
salesStartAt: z.string().datetime().optional(),
|
||||
salesEndAt: z.string().datetime().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
})).optional(),
|
||||
});
|
||||
|
||||
export const updateEventSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
richDescription: z.string().max(50000).nullable().optional(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
|
||||
doorsOpenTime: z.string().regex(/^\d{2}:\d{2}$/).nullable().optional(),
|
||||
eventFormat: z.enum(['IN_PERSON', 'ONLINE', 'HYBRID']).optional(),
|
||||
venueName: z.string().max(200).nullable().optional(),
|
||||
venueAddress: z.string().max(500).nullable().optional(),
|
||||
latitude: z.number().min(-90).max(90).nullable().optional(),
|
||||
longitude: z.number().min(-180).max(180).nullable().optional(),
|
||||
visibility: z.enum(['PUBLIC', 'UNLISTED', 'PRIVATE']).optional(),
|
||||
coverImageUrl: z.string().url().nullable().optional(),
|
||||
maxAttendees: z.number().int().positive().nullable().optional(),
|
||||
organizerName: z.string().max(200).nullable().optional(),
|
||||
organizerEmail: z.string().email().nullable().optional(),
|
||||
});
|
||||
|
||||
export const createTierSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
tierType: z.enum(['PAID', 'FREE', 'DONATION']),
|
||||
priceCAD: z.number().int().min(0).default(0),
|
||||
minDonationCAD: z.number().int().min(0).optional(),
|
||||
maxQuantity: z.number().int().positive().optional(),
|
||||
maxPerOrder: z.number().int().min(1).max(100).default(10),
|
||||
salesStartAt: z.string().datetime().optional(),
|
||||
salesEndAt: z.string().datetime().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export const updateTierSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
tierType: z.enum(['PAID', 'FREE', 'DONATION']).optional(),
|
||||
priceCAD: z.number().int().min(0).optional(),
|
||||
minDonationCAD: z.number().int().min(0).nullable().optional(),
|
||||
maxQuantity: z.number().int().positive().nullable().optional(),
|
||||
maxPerOrder: z.number().int().min(1).max(100).optional(),
|
||||
salesStartAt: z.string().datetime().nullable().optional(),
|
||||
salesEndAt: z.string().datetime().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const checkoutSchema = z.object({
|
||||
tierId: z.string().min(1),
|
||||
quantity: z.number().int().min(1).max(100).default(1),
|
||||
buyerEmail: z.string().email(),
|
||||
buyerName: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const registerFreeSchema = z.object({
|
||||
tierId: z.string().min(1),
|
||||
quantity: z.number().int().min(1).max(10).default(1),
|
||||
holderEmail: z.string().email(),
|
||||
holderName: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const validateTokenSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export const confirmCheckinSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
notes: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const manualCheckinSchema = z.object({
|
||||
eventId: z.string().min(1),
|
||||
ticketCode: z.string().optional(),
|
||||
holderEmail: z.string().email().optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
}).refine(
|
||||
data => data.ticketCode || data.holderEmail,
|
||||
{ message: 'Either ticketCode or holderEmail is required' },
|
||||
);
|
||||
|
||||
export const meetingAccessSchema = z.object({
|
||||
ticketCode: z.string().min(1),
|
||||
});
|
||||
829
api/src/modules/ticketed-events/ticketed-events.service.ts
Normal file
829
api/src/modules/ticketed-events/ticketed-events.service.ts
Normal file
@ -0,0 +1,829 @@
|
||||
import { prisma } from '../../config/database';
|
||||
import { TicketedEventStatus, TicketedEventVisibility, EventFormat, Prisma } from '@prisma/client';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { unifiedCalendarService } from '../events/unified-calendar.service';
|
||||
import { siteSettingsService } from '../settings/settings.service';
|
||||
import { generateModeratorToken } from '../jitsi/jitsi.utils';
|
||||
import { generateSlug as generateMeetingSlug } from '../../utils/slug';
|
||||
import { env } from '../../config/env';
|
||||
import crypto from 'crypto';
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
async function uniqueSlug(base: string): Promise<string> {
|
||||
let slug = base;
|
||||
let suffix = 0;
|
||||
while (await prisma.ticketedEvent.findUnique({ where: { slug } })) {
|
||||
suffix++;
|
||||
slug = `${base}-${suffix}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function generateInviteCode(): string {
|
||||
return crypto.randomBytes(6).toString('hex').toUpperCase();
|
||||
}
|
||||
|
||||
const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
|
||||
|
||||
/** Validate that enableMeet is on when format requires Jitsi */
|
||||
async function validateMeetFormat(format: EventFormat | string) {
|
||||
if (format === 'IN_PERSON') return;
|
||||
const settings = await siteSettingsService.get();
|
||||
if (!settings.enableMeet) {
|
||||
throw new AppError(400, 'Jitsi Meet must be enabled in Settings to create ONLINE or HYBRID events', 'MEET_DISABLED');
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a Meeting record for a ticketed event */
|
||||
async function ensureMeeting(event: { id: string; title: string; date: Date; startTime: string; endTime: string }, userId: string): Promise<string> {
|
||||
const jitsiRoom = crypto.randomUUID();
|
||||
const slug = generateMeetingSlug(event.title);
|
||||
|
||||
// Compose start/end DateTimes from event date + time strings
|
||||
const dateStr = event.date.toISOString().split('T')[0];
|
||||
const startTime = new Date(`${dateStr}T${event.startTime}:00`);
|
||||
const endTime = new Date(`${dateStr}T${event.endTime}:00`);
|
||||
|
||||
const meeting = await prisma.meeting.create({
|
||||
data: {
|
||||
slug,
|
||||
title: `${event.title} — Live`,
|
||||
jitsiRoom,
|
||||
isActive: true,
|
||||
createdByUserId: userId,
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
});
|
||||
|
||||
return meeting.id;
|
||||
}
|
||||
|
||||
export const ticketedEventsService = {
|
||||
async create(data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
richDescription?: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
doorsOpenTime?: string;
|
||||
eventFormat?: EventFormat;
|
||||
venueName?: string;
|
||||
venueAddress?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
visibility?: TicketedEventVisibility;
|
||||
coverImageUrl?: string;
|
||||
maxAttendees?: number;
|
||||
organizerName?: string;
|
||||
organizerEmail?: string;
|
||||
tiers?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
tierType: 'PAID' | 'FREE' | 'DONATION';
|
||||
priceCAD?: number;
|
||||
minDonationCAD?: number;
|
||||
maxQuantity?: number;
|
||||
maxPerOrder?: number;
|
||||
salesStartAt?: string;
|
||||
salesEndAt?: string;
|
||||
sortOrder?: number;
|
||||
}>;
|
||||
}, userId: string) {
|
||||
const eventFormat = data.eventFormat || 'IN_PERSON';
|
||||
await validateMeetFormat(eventFormat);
|
||||
|
||||
const slug = await uniqueSlug(generateSlug(data.title));
|
||||
const visibility = data.visibility || 'PUBLIC';
|
||||
const inviteCode = visibility === 'PRIVATE' ? generateInviteCode() : null;
|
||||
|
||||
const event = await prisma.ticketedEvent.create({
|
||||
data: {
|
||||
slug,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
richDescription: data.richDescription,
|
||||
date: new Date(data.date),
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
doorsOpenTime: data.doorsOpenTime,
|
||||
eventFormat,
|
||||
venueName: data.venueName,
|
||||
venueAddress: data.venueAddress,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
status: 'DRAFT',
|
||||
visibility,
|
||||
inviteCode,
|
||||
coverImageUrl: data.coverImageUrl,
|
||||
maxAttendees: data.maxAttendees,
|
||||
createdByUserId: userId,
|
||||
organizerName: data.organizerName,
|
||||
organizerEmail: data.organizerEmail,
|
||||
ticketTiers: data.tiers?.length ? {
|
||||
create: data.tiers.map((t, i) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
tierType: t.tierType,
|
||||
priceCAD: t.priceCAD || 0,
|
||||
minDonationCAD: t.minDonationCAD,
|
||||
maxQuantity: t.maxQuantity,
|
||||
maxPerOrder: t.maxPerOrder || 10,
|
||||
salesStartAt: t.salesStartAt ? new Date(t.salesStartAt) : undefined,
|
||||
salesEndAt: t.salesEndAt ? new Date(t.salesEndAt) : undefined,
|
||||
sortOrder: t.sortOrder ?? i,
|
||||
})),
|
||||
} : undefined,
|
||||
},
|
||||
include: { ticketTiers: true },
|
||||
});
|
||||
|
||||
// Auto-create meeting for ONLINE/HYBRID events
|
||||
if (eventFormat !== 'IN_PERSON') {
|
||||
const meetingId = await ensureMeeting(
|
||||
{ id: event.id, title: event.title, date: event.date, startTime: event.startTime, endTime: event.endTime },
|
||||
userId,
|
||||
);
|
||||
return prisma.ticketedEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { meetingId },
|
||||
include: { ticketTiers: true, meeting: { select: { jitsiRoom: true, isActive: true, slug: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
async update(id: string, data: Record<string, unknown>, userId: string, userRole: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
|
||||
// Non-admin users can only edit their own events
|
||||
if (!ADMIN_ROLES.includes(userRole) && event.createdByUserId !== userId) {
|
||||
throw new AppError(403, 'Cannot edit events you did not create', 'FORBIDDEN');
|
||||
}
|
||||
|
||||
// If switching to PRIVATE, generate invite code if not present
|
||||
if (data.visibility === 'PRIVATE' && !event.inviteCode) {
|
||||
data.inviteCode = generateInviteCode();
|
||||
}
|
||||
|
||||
// Convert date string to Date object
|
||||
if (typeof data.date === 'string') {
|
||||
data.date = new Date(data.date as string);
|
||||
}
|
||||
|
||||
// Handle event format changes
|
||||
const newFormat = data.eventFormat as EventFormat | undefined;
|
||||
if (newFormat) {
|
||||
await validateMeetFormat(newFormat);
|
||||
|
||||
// Switching TO ONLINE/HYBRID — create meeting if not present
|
||||
if (newFormat !== 'IN_PERSON' && !event.meetingId) {
|
||||
const meetingId = await ensureMeeting(
|
||||
{ id: event.id, title: event.title, date: event.date, startTime: event.startTime, endTime: event.endTime },
|
||||
userId,
|
||||
);
|
||||
data.meetingId = meetingId;
|
||||
}
|
||||
|
||||
// Switching TO IN_PERSON — deactivate meeting (keep the record)
|
||||
if (newFormat === 'IN_PERSON' && event.meetingId) {
|
||||
await prisma.meeting.update({
|
||||
where: { id: event.meetingId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
// Re-activate meeting if switching back to ONLINE/HYBRID
|
||||
if (newFormat !== 'IN_PERSON' && event.meetingId) {
|
||||
await prisma.meeting.update({
|
||||
where: { id: event.meetingId },
|
||||
data: { isActive: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.ticketedEvent.update({
|
||||
where: { id },
|
||||
data: data as Prisma.TicketedEventUncheckedUpdateInput,
|
||||
include: {
|
||||
ticketTiers: { orderBy: { sortOrder: 'asc' } },
|
||||
meeting: { select: { jitsiRoom: true, isActive: true, slug: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Bust calendar cache if event is published
|
||||
if (updated.status === 'PUBLISHED') {
|
||||
unifiedCalendarService.bustCache().catch(() => {});
|
||||
}
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async findById(id: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
ticketTiers: { orderBy: { sortOrder: 'asc' } },
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
meeting: { select: { id: true, jitsiRoom: true, isActive: true, slug: true } },
|
||||
_count: { select: { tickets: true, checkIns: true } },
|
||||
},
|
||||
});
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
return event;
|
||||
},
|
||||
|
||||
async findBySlug(slug: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
ticketTiers: {
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
meeting: { select: { isActive: true } },
|
||||
},
|
||||
});
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
return event;
|
||||
},
|
||||
|
||||
async list(filters: {
|
||||
page: number;
|
||||
limit: number;
|
||||
status?: TicketedEventStatus;
|
||||
search?: string;
|
||||
createdByUserId?: string;
|
||||
}) {
|
||||
const where: Prisma.TicketedEventWhereInput = {};
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.createdByUserId) where.createdByUserId = filters.createdByUserId;
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ venueName: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
prisma.ticketedEvent.findMany({
|
||||
where,
|
||||
skip: (filters.page - 1) * filters.limit,
|
||||
take: filters.limit,
|
||||
orderBy: { date: 'desc' },
|
||||
include: {
|
||||
ticketTiers: { orderBy: { sortOrder: 'asc' } },
|
||||
_count: { select: { tickets: true, checkIns: true } },
|
||||
},
|
||||
}),
|
||||
prisma.ticketedEvent.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
events,
|
||||
pagination: {
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / filters.limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async listPublished(filters: {
|
||||
page: number;
|
||||
limit: number;
|
||||
upcoming?: boolean;
|
||||
}) {
|
||||
const where: Prisma.TicketedEventWhereInput = {
|
||||
status: 'PUBLISHED',
|
||||
visibility: { in: ['PUBLIC', 'UNLISTED'] },
|
||||
};
|
||||
if (filters.upcoming) {
|
||||
where.date = { gte: new Date() };
|
||||
}
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
prisma.ticketedEvent.findMany({
|
||||
where,
|
||||
skip: (filters.page - 1) * filters.limit,
|
||||
take: filters.limit,
|
||||
orderBy: { date: 'asc' },
|
||||
include: {
|
||||
ticketTiers: {
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.ticketedEvent.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
events,
|
||||
pagination: {
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / filters.limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async publish(id: string, userId: string, userRole: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({
|
||||
where: { id },
|
||||
include: { ticketTiers: true },
|
||||
});
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
|
||||
if (!ADMIN_ROLES.includes(userRole) && event.createdByUserId !== userId) {
|
||||
throw new AppError(403, 'Cannot publish events you did not create', 'FORBIDDEN');
|
||||
}
|
||||
|
||||
if (event.status !== 'DRAFT' && event.status !== 'PENDING_APPROVAL') {
|
||||
throw new AppError(400, `Cannot publish event in ${event.status} status`, 'INVALID_STATUS');
|
||||
}
|
||||
|
||||
if (!event.ticketTiers.length) {
|
||||
throw new AppError(400, 'Event must have at least one ticket tier before publishing', 'NO_TIERS');
|
||||
}
|
||||
|
||||
// Check if approval is required for non-admin users
|
||||
if (!ADMIN_ROLES.includes(userRole)) {
|
||||
const settings = await prisma.siteSettings.findFirst();
|
||||
if (settings?.requireEventApproval) {
|
||||
const updated = await prisma.ticketedEvent.update({
|
||||
where: { id },
|
||||
data: { status: 'PENDING_APPROVAL' },
|
||||
include: { ticketTiers: true },
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.ticketedEvent.update({
|
||||
where: { id },
|
||||
data: { status: 'PUBLISHED' },
|
||||
include: { ticketTiers: true },
|
||||
});
|
||||
|
||||
// Gancio sync + calendar cache bust (fire-and-forget)
|
||||
this.syncToGancio(updated).catch(() => {});
|
||||
unifiedCalendarService.bustCache().catch(() => {});
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async approve(id: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
if (event.status !== 'PENDING_APPROVAL') {
|
||||
throw new AppError(400, 'Event is not pending approval', 'INVALID_STATUS');
|
||||
}
|
||||
|
||||
const updated = await prisma.ticketedEvent.update({
|
||||
where: { id },
|
||||
data: { status: 'PUBLISHED' },
|
||||
include: { ticketTiers: true },
|
||||
});
|
||||
|
||||
this.syncToGancio(updated).catch(() => {});
|
||||
unifiedCalendarService.bustCache().catch(() => {});
|
||||
return updated;
|
||||
},
|
||||
|
||||
async reject(id: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
if (event.status !== 'PENDING_APPROVAL') {
|
||||
throw new AppError(400, 'Event is not pending approval', 'INVALID_STATUS');
|
||||
}
|
||||
|
||||
return prisma.ticketedEvent.update({
|
||||
where: { id },
|
||||
data: { status: 'DRAFT' },
|
||||
});
|
||||
},
|
||||
|
||||
async cancel(id: string, userId: string, userRole: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
|
||||
if (!ADMIN_ROLES.includes(userRole) && event.createdByUserId !== userId) {
|
||||
throw new AppError(403, 'Cannot cancel events you did not create', 'FORBIDDEN');
|
||||
}
|
||||
|
||||
if (event.status === 'CANCELLED' || event.status === 'COMPLETED') {
|
||||
throw new AppError(400, `Cannot cancel event in ${event.status} status`, 'INVALID_STATUS');
|
||||
}
|
||||
|
||||
// Cancel all valid tickets
|
||||
await prisma.ticket.updateMany({
|
||||
where: { eventId: id, status: 'VALID' },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
|
||||
const updated = await prisma.ticketedEvent.update({
|
||||
where: { id },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
|
||||
// Delete from Gancio if synced + bust calendar cache
|
||||
if (event.gancioEventId) {
|
||||
this.deleteFromGancio(event.gancioEventId).catch(() => {});
|
||||
}
|
||||
unifiedCalendarService.bustCache().catch(() => {});
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async complete(id: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { id } });
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
if (event.status !== 'PUBLISHED') {
|
||||
throw new AppError(400, 'Only published events can be completed', 'INVALID_STATUS');
|
||||
}
|
||||
|
||||
const updated = await prisma.ticketedEvent.update({
|
||||
where: { id },
|
||||
data: { status: 'COMPLETED' },
|
||||
});
|
||||
unifiedCalendarService.bustCache().catch(() => {});
|
||||
return updated;
|
||||
},
|
||||
|
||||
async deleteEvent(id: string, userId: string, userRole: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { tickets: true } } },
|
||||
});
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
|
||||
if (!ADMIN_ROLES.includes(userRole) && event.createdByUserId !== userId) {
|
||||
throw new AppError(403, 'Cannot delete events you did not create', 'FORBIDDEN');
|
||||
}
|
||||
|
||||
if (event.status !== 'DRAFT') {
|
||||
throw new AppError(400, 'Only draft events can be deleted. Cancel the event instead.', 'INVALID_STATUS');
|
||||
}
|
||||
|
||||
if (event.gancioEventId) {
|
||||
this.deleteFromGancio(event.gancioEventId).catch(() => {});
|
||||
}
|
||||
|
||||
await prisma.ticketedEvent.delete({ where: { id } });
|
||||
},
|
||||
|
||||
// --- Tier Management ---
|
||||
|
||||
async addTier(eventId: string, data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
tierType: 'PAID' | 'FREE' | 'DONATION';
|
||||
priceCAD?: number;
|
||||
minDonationCAD?: number;
|
||||
maxQuantity?: number;
|
||||
maxPerOrder?: number;
|
||||
salesStartAt?: string;
|
||||
salesEndAt?: string;
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
|
||||
return prisma.ticketTier.create({
|
||||
data: {
|
||||
eventId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
tierType: data.tierType,
|
||||
priceCAD: data.priceCAD || 0,
|
||||
minDonationCAD: data.minDonationCAD,
|
||||
maxQuantity: data.maxQuantity,
|
||||
maxPerOrder: data.maxPerOrder || 10,
|
||||
salesStartAt: data.salesStartAt ? new Date(data.salesStartAt) : undefined,
|
||||
salesEndAt: data.salesEndAt ? new Date(data.salesEndAt) : undefined,
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async updateTier(tierId: string, data: Record<string, unknown>) {
|
||||
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
||||
if (!tier) throw new AppError(404, 'Tier not found', 'NOT_FOUND');
|
||||
|
||||
// Convert date strings
|
||||
if (typeof data.salesStartAt === 'string') data.salesStartAt = new Date(data.salesStartAt as string);
|
||||
if (typeof data.salesEndAt === 'string') data.salesEndAt = new Date(data.salesEndAt as string);
|
||||
|
||||
return prisma.ticketTier.update({
|
||||
where: { id: tierId },
|
||||
data: data as Prisma.TicketTierUncheckedUpdateInput,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTier(tierId: string) {
|
||||
const tier = await prisma.ticketTier.findUnique({
|
||||
where: { id: tierId },
|
||||
include: { _count: { select: { tickets: true } } },
|
||||
});
|
||||
if (!tier) throw new AppError(404, 'Tier not found', 'NOT_FOUND');
|
||||
if (tier._count.tickets > 0) {
|
||||
throw new AppError(400, 'Cannot delete a tier that has sold tickets', 'HAS_TICKETS');
|
||||
}
|
||||
|
||||
await prisma.ticketTier.delete({ where: { id: tierId } });
|
||||
},
|
||||
|
||||
// --- Stats ---
|
||||
|
||||
async getEventStats(eventId: string) {
|
||||
const [ticketCounts, revenue, checkInCount, tierStats] = await Promise.all([
|
||||
prisma.ticket.groupBy({
|
||||
by: ['status'],
|
||||
where: { eventId },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.order.aggregate({
|
||||
where: {
|
||||
tickets: { some: { eventId } },
|
||||
status: 'COMPLETED',
|
||||
type: 'event_ticket',
|
||||
},
|
||||
_sum: { amountCAD: true },
|
||||
}),
|
||||
prisma.checkIn.count({ where: { eventId } }),
|
||||
prisma.ticketTier.findMany({
|
||||
where: { eventId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
tierType: true,
|
||||
priceCAD: true,
|
||||
maxQuantity: true,
|
||||
soldCount: true,
|
||||
isActive: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const statusMap: Record<string, number> = {};
|
||||
for (const g of ticketCounts) statusMap[g.status] = g._count;
|
||||
|
||||
return {
|
||||
totalTickets: Object.values(statusMap).reduce((a, b) => a + b, 0),
|
||||
validTickets: statusMap['VALID'] || 0,
|
||||
checkedIn: statusMap['CHECKED_IN'] || 0,
|
||||
cancelled: statusMap['CANCELLED'] || 0,
|
||||
refunded: statusMap['REFUNDED'] || 0,
|
||||
totalRevenue: revenue._sum.amountCAD || 0,
|
||||
checkInCount,
|
||||
tierStats,
|
||||
};
|
||||
},
|
||||
|
||||
async getTickets(eventId: string, filters: {
|
||||
page: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
const where: Prisma.TicketWhereInput = { eventId };
|
||||
if (filters.status) where.status = filters.status as never;
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ holderEmail: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ holderName: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ ticketCode: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [tickets, total] = await Promise.all([
|
||||
prisma.ticket.findMany({
|
||||
where,
|
||||
skip: (filters.page - 1) * filters.limit,
|
||||
take: filters.limit,
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
include: {
|
||||
tier: { select: { name: true, tierType: true } },
|
||||
order: { select: { id: true, amountCAD: true, status: true } },
|
||||
},
|
||||
}),
|
||||
prisma.ticket.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
tickets,
|
||||
pagination: {
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / filters.limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async getCheckIns(eventId: string, filters: { page: number; limit: number }) {
|
||||
const [checkIns, total] = await Promise.all([
|
||||
prisma.checkIn.findMany({
|
||||
where: { eventId },
|
||||
skip: (filters.page - 1) * filters.limit,
|
||||
take: filters.limit,
|
||||
orderBy: { checkedInAt: 'desc' },
|
||||
include: {
|
||||
ticket: {
|
||||
select: { ticketCode: true, holderName: true, holderEmail: true },
|
||||
},
|
||||
checkedInBy: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.checkIn.count({ where: { eventId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
checkIns,
|
||||
pagination: {
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / filters.limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// --- Availability ---
|
||||
|
||||
async getAvailability(eventId: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({
|
||||
where: { id: eventId },
|
||||
include: {
|
||||
ticketTiers: {
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
|
||||
const now = new Date();
|
||||
const tiers = event.ticketTiers.map(t => {
|
||||
const available = t.maxQuantity ? t.maxQuantity - t.soldCount : null;
|
||||
const onSale = (!t.salesStartAt || t.salesStartAt <= now) &&
|
||||
(!t.salesEndAt || t.salesEndAt >= now);
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tierType: t.tierType,
|
||||
priceCAD: t.priceCAD,
|
||||
minDonationCAD: t.minDonationCAD,
|
||||
maxPerOrder: t.maxPerOrder,
|
||||
available,
|
||||
soldOut: available !== null && available <= 0,
|
||||
onSale,
|
||||
};
|
||||
});
|
||||
|
||||
const eventSoldOut = event.maxAttendees
|
||||
? event.currentAttendees >= event.maxAttendees
|
||||
: false;
|
||||
|
||||
return { eventId, eventSoldOut, tiers };
|
||||
},
|
||||
|
||||
// --- Meeting Access ---
|
||||
|
||||
async getMeetingAccess(slug: string, ticketCode: string) {
|
||||
const event = await prisma.ticketedEvent.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
meeting: { select: { jitsiRoom: true, isActive: true } },
|
||||
},
|
||||
});
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
if (event.status !== 'PUBLISHED') throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
if (event.eventFormat === 'IN_PERSON') throw new AppError(400, 'This event does not have online access', 'NO_ONLINE_ACCESS');
|
||||
if (!event.meeting || !event.meeting.isActive) throw new AppError(400, 'Meeting room is not active', 'MEETING_INACTIVE');
|
||||
|
||||
// Validate ticket code
|
||||
const ticket = await prisma.ticket.findUnique({ where: { ticketCode } });
|
||||
if (!ticket || ticket.eventId !== event.id) {
|
||||
throw new AppError(403, 'Invalid ticket code', 'INVALID_TICKET');
|
||||
}
|
||||
if (ticket.status !== 'VALID' && ticket.status !== 'CHECKED_IN') {
|
||||
throw new AppError(403, 'Ticket is not valid', 'TICKET_INVALID_STATUS');
|
||||
}
|
||||
|
||||
const domain = env.DOMAIN || 'cmlite.org';
|
||||
return {
|
||||
jitsiRoom: event.meeting.jitsiRoom,
|
||||
domain: `meet.${domain}`,
|
||||
eventTitle: event.title,
|
||||
};
|
||||
},
|
||||
|
||||
async getModeratorToken(eventId: string, user: { id: string; email: string; name: string | null }) {
|
||||
const event = await prisma.ticketedEvent.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { meeting: true },
|
||||
});
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
if (!event.meeting) throw new AppError(400, 'No meeting room associated with this event', 'NO_MEETING');
|
||||
|
||||
const token = generateModeratorToken(user, event.meeting.jitsiRoom);
|
||||
const domain = env.DOMAIN || 'cmlite.org';
|
||||
|
||||
return {
|
||||
token,
|
||||
jitsiUrl: `https://meet.${domain}/${event.meeting.jitsiRoom}?jwt=${token}`,
|
||||
};
|
||||
},
|
||||
|
||||
// --- Gancio Sync ---
|
||||
|
||||
async syncToGancio(event: {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
venueAddress?: string | null;
|
||||
venueName?: string | null;
|
||||
eventFormat?: EventFormat;
|
||||
date: Date;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
gancioEventId?: number | null;
|
||||
}) {
|
||||
try {
|
||||
const { gancioClient } = await import('../../services/gancio.client');
|
||||
if (!gancioClient.enabled) return;
|
||||
|
||||
// Determine location based on event format
|
||||
let location: string | null;
|
||||
const format = event.eventFormat || 'IN_PERSON';
|
||||
if (format === 'ONLINE') {
|
||||
location = 'Online Event';
|
||||
} else if (format === 'HYBRID') {
|
||||
const venue = event.venueAddress || event.venueName || '';
|
||||
location = venue ? `${venue} (also streaming online)` : 'Online + In-Person';
|
||||
} else {
|
||||
location = event.venueAddress || event.venueName || null;
|
||||
}
|
||||
|
||||
const tags = ['ticketed', 'community'];
|
||||
if (format === 'ONLINE') tags.push('online');
|
||||
if (format === 'HYBRID') tags.push('hybrid');
|
||||
|
||||
if (event.gancioEventId) {
|
||||
await gancioClient.updateEvent(event.gancioEventId, {
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
location,
|
||||
date: event.date,
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
});
|
||||
} else {
|
||||
const gancioId = await gancioClient.createEvent({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
location,
|
||||
date: event.date,
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
tags,
|
||||
});
|
||||
if (gancioId) {
|
||||
await prisma.ticketedEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { gancioEventId: gancioId },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Gancio sync failed for ticketed event:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteFromGancio(gancioEventId: number) {
|
||||
try {
|
||||
const { gancioClient } = await import('../../services/gancio.client');
|
||||
if (!gancioClient.enabled) return;
|
||||
await gancioClient.deleteEvent(gancioEventId);
|
||||
} catch (err) {
|
||||
logger.warn(`Gancio delete failed for event ${gancioEventId}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
},
|
||||
};
|
||||
373
api/src/modules/ticketed-events/tickets.service.ts
Normal file
373
api/src/modules/ticketed-events/tickets.service.ts
Normal file
@ -0,0 +1,373 @@
|
||||
import crypto from 'crypto';
|
||||
import { prisma } from '../../config/database';
|
||||
import { env } from '../../config/env';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
function getEncryptionKey(): string {
|
||||
return env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET;
|
||||
}
|
||||
|
||||
/** Generate a human-readable ticket code like "ABCD-1234" */
|
||||
function generateTicketCode(): string {
|
||||
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // No I, O for readability
|
||||
const part1 = Array.from({ length: 4 }, () => letters[crypto.randomInt(letters.length)]).join('');
|
||||
const part2 = String(crypto.randomInt(1000, 9999));
|
||||
return `${part1}-${part2}`;
|
||||
}
|
||||
|
||||
/** Generate HMAC token for QR code validation */
|
||||
function generateToken(ticketId: string): { token: string; tokenHash: string } {
|
||||
const nonce = crypto.randomBytes(16);
|
||||
const hmac = crypto.createHmac('sha256', getEncryptionKey());
|
||||
hmac.update(ticketId);
|
||||
hmac.update(nonce);
|
||||
const token = Buffer.concat([
|
||||
Buffer.from(ticketId),
|
||||
Buffer.from(':'),
|
||||
nonce,
|
||||
Buffer.from(':'),
|
||||
hmac.digest(),
|
||||
]).toString('base64url');
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
return { token, tokenHash };
|
||||
}
|
||||
|
||||
/** Hash an incoming token for lookup */
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export const ticketsService = {
|
||||
/**
|
||||
* Create one or more tickets for an event + tier.
|
||||
* Used by both free registration and post-checkout webhook.
|
||||
*/
|
||||
async createTickets(params: {
|
||||
eventId: string;
|
||||
tierId: string;
|
||||
quantity: number;
|
||||
holderEmail: string;
|
||||
holderName?: string;
|
||||
userId?: string;
|
||||
orderId?: string;
|
||||
}) {
|
||||
const { eventId, tierId, quantity, holderEmail, holderName, userId, orderId } = params;
|
||||
|
||||
// Validate tier exists and belongs to event
|
||||
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
||||
if (!tier || tier.eventId !== eventId) {
|
||||
throw new AppError(400, 'Invalid tier for this event', 'INVALID_TIER');
|
||||
}
|
||||
|
||||
// Check quantity limits
|
||||
if (quantity > tier.maxPerOrder) {
|
||||
throw new AppError(400, `Maximum ${tier.maxPerOrder} tickets per order`, 'MAX_PER_ORDER');
|
||||
}
|
||||
|
||||
// Atomically check + reserve capacity using a transaction
|
||||
const tickets = await prisma.$transaction(async (tx) => {
|
||||
// Lock the tier row and check availability
|
||||
const currentTier = await tx.ticketTier.findUnique({ where: { id: tierId } });
|
||||
if (!currentTier) throw new AppError(400, 'Tier not found', 'NOT_FOUND');
|
||||
|
||||
if (currentTier.maxQuantity && currentTier.soldCount + quantity > currentTier.maxQuantity) {
|
||||
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
|
||||
}
|
||||
|
||||
// Check event-level capacity
|
||||
const event = await tx.ticketedEvent.findUnique({ where: { id: eventId } });
|
||||
if (!event) throw new AppError(404, 'Event not found', 'NOT_FOUND');
|
||||
|
||||
if (event.maxAttendees && event.currentAttendees + quantity > event.maxAttendees) {
|
||||
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
|
||||
}
|
||||
|
||||
// Generate tickets
|
||||
const ticketData = [];
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
let ticketCode = generateTicketCode();
|
||||
// Ensure uniqueness (extremely unlikely collision but safe)
|
||||
while (await tx.ticket.findUnique({ where: { ticketCode } })) {
|
||||
ticketCode = generateTicketCode();
|
||||
}
|
||||
|
||||
const { token, tokenHash } = generateToken(`${eventId}:${ticketCode}`);
|
||||
ticketData.push({
|
||||
ticketCode,
|
||||
tokenHash,
|
||||
token, // Not stored — returned for QR generation
|
||||
eventId,
|
||||
tierId,
|
||||
orderId,
|
||||
holderEmail,
|
||||
holderName,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
// Create all tickets
|
||||
const created = [];
|
||||
for (const td of ticketData) {
|
||||
const { token, ...dbData } = td;
|
||||
const ticket = await tx.ticket.create({ data: dbData });
|
||||
created.push({ ...ticket, token });
|
||||
}
|
||||
|
||||
// Increment sold count + attendee count
|
||||
await tx.ticketTier.update({
|
||||
where: { id: tierId },
|
||||
data: { soldCount: { increment: quantity } },
|
||||
});
|
||||
await tx.ticketedEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { currentAttendees: { increment: quantity } },
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return tickets;
|
||||
},
|
||||
|
||||
/** Validate a QR token — returns ticket info without marking checked in */
|
||||
async validateToken(token: string) {
|
||||
const hash = hashToken(token);
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { tokenHash: hash },
|
||||
include: {
|
||||
event: { select: { id: true, title: true, slug: true, date: true, startTime: true } },
|
||||
tier: { select: { name: true, tierType: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return { valid: false, error: 'Invalid ticket' };
|
||||
}
|
||||
|
||||
if (ticket.status === 'CHECKED_IN') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Already checked in',
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
ticketCode: ticket.ticketCode,
|
||||
holderName: ticket.holderName,
|
||||
holderEmail: ticket.holderEmail,
|
||||
status: ticket.status,
|
||||
checkedInAt: ticket.checkedInAt,
|
||||
event: ticket.event,
|
||||
tier: ticket.tier,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (ticket.status === 'CANCELLED' || ticket.status === 'REFUNDED') {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Ticket is ${ticket.status.toLowerCase()}`,
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
ticketCode: ticket.ticketCode,
|
||||
status: ticket.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
ticketCode: ticket.ticketCode,
|
||||
holderName: ticket.holderName,
|
||||
holderEmail: ticket.holderEmail,
|
||||
status: ticket.status,
|
||||
event: ticket.event,
|
||||
tier: ticket.tier,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/** Confirm check-in — marks ticket as CHECKED_IN */
|
||||
async confirmCheckin(token: string, checkedInByUserId?: string, notes?: string) {
|
||||
const hash = hashToken(token);
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { tokenHash: hash },
|
||||
include: {
|
||||
event: { select: { id: true, title: true } },
|
||||
tier: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) throw new AppError(400, 'Invalid ticket', 'INVALID_TICKET');
|
||||
if (ticket.status === 'CHECKED_IN') throw new AppError(400, 'Ticket already checked in', 'ALREADY_CHECKED_IN');
|
||||
if (ticket.status !== 'VALID') throw new AppError(400, `Ticket is ${ticket.status.toLowerCase()}`, 'INVALID_STATUS');
|
||||
|
||||
const [updatedTicket, checkIn] = await prisma.$transaction([
|
||||
prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
status: 'CHECKED_IN',
|
||||
checkedInAt: new Date(),
|
||||
checkedInByUserId,
|
||||
},
|
||||
}),
|
||||
prisma.checkIn.create({
|
||||
data: {
|
||||
ticketId: ticket.id,
|
||||
eventId: ticket.eventId,
|
||||
checkedInByUserId,
|
||||
method: 'QR',
|
||||
notes,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
ticket: updatedTicket,
|
||||
checkIn,
|
||||
event: ticket.event,
|
||||
tier: ticket.tier,
|
||||
};
|
||||
},
|
||||
|
||||
/** Manual check-in by ticket code or email */
|
||||
async manualCheckin(params: {
|
||||
eventId: string;
|
||||
ticketCode?: string;
|
||||
holderEmail?: string;
|
||||
checkedInByUserId?: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const { eventId, ticketCode, holderEmail, checkedInByUserId, notes } = params;
|
||||
|
||||
let ticket;
|
||||
if (ticketCode) {
|
||||
ticket = await prisma.ticket.findUnique({
|
||||
where: { ticketCode },
|
||||
include: {
|
||||
event: { select: { id: true, title: true } },
|
||||
tier: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
} else if (holderEmail) {
|
||||
ticket = await prisma.ticket.findFirst({
|
||||
where: { eventId, holderEmail, status: 'VALID' },
|
||||
include: {
|
||||
event: { select: { id: true, title: true } },
|
||||
tier: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!ticket) throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
|
||||
if (ticket.eventId !== eventId) throw new AppError(400, 'Ticket does not belong to this event', 'WRONG_EVENT');
|
||||
if (ticket.status === 'CHECKED_IN') throw new AppError(400, 'Ticket already checked in', 'ALREADY_CHECKED_IN');
|
||||
if (ticket.status !== 'VALID') throw new AppError(400, `Ticket is ${ticket.status.toLowerCase()}`, 'INVALID_STATUS');
|
||||
|
||||
const [updatedTicket, checkIn] = await prisma.$transaction([
|
||||
prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
status: 'CHECKED_IN',
|
||||
checkedInAt: new Date(),
|
||||
checkedInByUserId,
|
||||
},
|
||||
}),
|
||||
prisma.checkIn.create({
|
||||
data: {
|
||||
ticketId: ticket.id,
|
||||
eventId,
|
||||
checkedInByUserId,
|
||||
method: ticketCode ? 'CODE' : 'MANUAL',
|
||||
notes,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
ticket: updatedTicket,
|
||||
checkIn,
|
||||
event: ticket.event,
|
||||
tier: ticket.tier,
|
||||
};
|
||||
},
|
||||
|
||||
/** Cancel a specific ticket */
|
||||
async cancelTicket(ticketId: string) {
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
|
||||
if (!ticket) throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
|
||||
if (ticket.status !== 'VALID') {
|
||||
throw new AppError(400, `Cannot cancel ticket in ${ticket.status} status`, 'INVALID_STATUS');
|
||||
}
|
||||
|
||||
return prisma.$transaction([
|
||||
prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: { status: 'CANCELLED' },
|
||||
}),
|
||||
prisma.ticketTier.update({
|
||||
where: { id: ticket.tierId },
|
||||
data: { soldCount: { decrement: 1 } },
|
||||
}),
|
||||
prisma.ticketedEvent.update({
|
||||
where: { id: ticket.eventId },
|
||||
data: { currentAttendees: { decrement: 1 } },
|
||||
}),
|
||||
]);
|
||||
},
|
||||
|
||||
/** Get user's tickets */
|
||||
async getUserTickets(userId: string) {
|
||||
return prisma.ticket.findMany({
|
||||
where: { userId },
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true, slug: true, title: true, date: true,
|
||||
startTime: true, endTime: true, venueName: true, venueAddress: true,
|
||||
},
|
||||
},
|
||||
tier: { select: { name: true, tierType: true, priceCAD: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** Get user's tickets for a specific email (for guests) */
|
||||
async getTicketsByEmail(email: string) {
|
||||
return prisma.ticket.findMany({
|
||||
where: { holderEmail: email },
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true, slug: true, title: true, date: true,
|
||||
startTime: true, endTime: true, venueName: true,
|
||||
},
|
||||
},
|
||||
tier: { select: { name: true, tierType: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** Find a ticket by code (for confirmation page) */
|
||||
async findByCode(ticketCode: string) {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { ticketCode },
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true, slug: true, title: true, date: true,
|
||||
startTime: true, endTime: true, venueName: true, venueAddress: true,
|
||||
organizerName: true,
|
||||
},
|
||||
},
|
||||
tier: { select: { name: true, tierType: true, priceCAD: true } },
|
||||
},
|
||||
});
|
||||
if (!ticket) throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
|
||||
return ticket;
|
||||
},
|
||||
};
|
||||
@ -103,6 +103,10 @@ import { homepageRouter } from './modules/homepage/homepage.routes';
|
||||
import { ogRouter } from './modules/og/og.routes';
|
||||
import { socialRouter } from './modules/social/social.routes';
|
||||
import { errorReportRouter } from './modules/reports/error-report.routes';
|
||||
import calendarRoutes from './modules/calendar/calendar.routes';
|
||||
import { ticketedEventsPublicRouter } from './modules/ticketed-events/ticketed-events-public.routes';
|
||||
import { ticketedEventsAdminRouter } from './modules/ticketed-events/ticketed-events-admin.routes';
|
||||
import { checkinRouter } from './modules/ticketed-events/checkin.routes';
|
||||
import { sseService } from './modules/social/sse.service';
|
||||
import { presenceService } from './modules/social/presence.service';
|
||||
import { upgradeService } from './modules/upgrade/upgrade.service';
|
||||
@ -265,7 +269,11 @@ app.use('/api/events', eventsListPublicRouter); // Public event
|
||||
app.use('/api/homepage', homepageRouter); // Public homepage aggregation (no auth, cached)
|
||||
app.use('/api/og', ogRouter); // OG meta tags for social sharing bots (no auth, cached)
|
||||
app.use('/api/social', socialRouter); // Social connections (auth required)
|
||||
app.use('/api/ticketed-events/admin', ticketedEventsAdminRouter); // Admin ticketed event CRUD (auth + permission) — MUST be before public /:slug
|
||||
app.use('/api/ticketed-events/checkin', checkinRouter); // Check-in scanner routes (auth required)
|
||||
app.use('/api/ticketed-events', ticketedEventsPublicRouter); // Public ticketed event listing + checkout (no auth)
|
||||
app.use('/api/public/error-report', errorReportRouter); // Public 404 error reporting (rate-limited)
|
||||
app.use('/api/calendar', calendarRoutes); // Personal calendar layers + items (auth required)
|
||||
|
||||
// --- API 404 Handler (catch unmatched /api/* routes) ---
|
||||
app.use('/api/*', (_req, res) => {
|
||||
@ -369,6 +377,13 @@ async function start() {
|
||||
sseService.startHeartbeat();
|
||||
setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min
|
||||
|
||||
// Challenge lifecycle: activate/complete/score every 5 minutes
|
||||
import('./services/challenge-scoring.service').then(({ challengeScoringService }) => {
|
||||
challengeScoringService.processLifecycle().catch(() => {});
|
||||
setInterval(() => challengeScoringService.processLifecycle().catch(() => {}), 5 * 60 * 1000);
|
||||
logger.info('Challenge lifecycle processor started (every 5min)');
|
||||
}).catch(() => {});
|
||||
|
||||
// Clean up stale upgrade progress on startup
|
||||
upgradeService.clearStaleProgress();
|
||||
|
||||
|
||||
167
api/src/services/challenge-scoring.service.ts
Normal file
167
api/src/services/challenge-scoring.service.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { ChallengeMetric } from '@prisma/client';
|
||||
|
||||
async function computeScore(
|
||||
userId: string,
|
||||
metric: ChallengeMetric,
|
||||
startsAt: Date,
|
||||
endsAt: Date,
|
||||
): Promise<number> {
|
||||
const between = { gte: startsAt, lte: endsAt };
|
||||
|
||||
switch (metric) {
|
||||
case 'DOORS_KNOCKED':
|
||||
return prisma.canvassVisit.count({
|
||||
where: {
|
||||
session: { userId },
|
||||
visitedAt: between,
|
||||
},
|
||||
});
|
||||
|
||||
case 'EMAILS_SENT':
|
||||
return prisma.campaignEmail.count({
|
||||
where: {
|
||||
userId,
|
||||
sentAt: between,
|
||||
},
|
||||
});
|
||||
|
||||
case 'SHIFTS_ATTENDED':
|
||||
return prisma.shiftSignup.count({
|
||||
where: {
|
||||
userId,
|
||||
status: 'CONFIRMED',
|
||||
shift: { startTime: { gte: startsAt.toISOString(), lte: endsAt.toISOString() } },
|
||||
},
|
||||
});
|
||||
|
||||
case 'RESPONSES_SUBMITTED':
|
||||
return prisma.representativeResponse.count({
|
||||
where: {
|
||||
submittedByUserId: userId,
|
||||
createdAt: between,
|
||||
},
|
||||
});
|
||||
|
||||
case 'REFERRALS_MADE':
|
||||
return prisma.referral.count({
|
||||
where: {
|
||||
referrerId: userId,
|
||||
completedAt: between,
|
||||
},
|
||||
});
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function scoreChallenge(challengeId: string): Promise<void> {
|
||||
const challenge = await prisma.challenge.findUnique({
|
||||
where: { id: challengeId },
|
||||
include: {
|
||||
teams: {
|
||||
include: {
|
||||
members: { select: { id: true, userId: true, score: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!challenge) return;
|
||||
|
||||
let anyChanged = false;
|
||||
const participantUserIds: string[] = [];
|
||||
|
||||
for (const team of challenge.teams) {
|
||||
let teamTotal = 0;
|
||||
|
||||
for (const member of team.members) {
|
||||
const newScore = await computeScore(
|
||||
member.userId,
|
||||
challenge.metric,
|
||||
challenge.startsAt,
|
||||
challenge.endsAt,
|
||||
);
|
||||
|
||||
if (newScore !== member.score) {
|
||||
await prisma.challengeTeamMember.update({
|
||||
where: { id: member.id },
|
||||
data: { score: newScore },
|
||||
});
|
||||
anyChanged = true;
|
||||
}
|
||||
|
||||
teamTotal += newScore;
|
||||
participantUserIds.push(member.userId);
|
||||
}
|
||||
|
||||
if (teamTotal !== team.score) {
|
||||
await prisma.challengeTeam.update({
|
||||
where: { id: team.id },
|
||||
data: { score: teamTotal, lastScoredAt: new Date() },
|
||||
});
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyChanged && participantUserIds.length > 0) {
|
||||
try {
|
||||
const { sseService } = await import('../modules/social/sse.service');
|
||||
sseService.sendToUsers(participantUserIds, 'challenge_scores_updated', {
|
||||
challengeId,
|
||||
});
|
||||
} catch {
|
||||
// SSE not available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processLifecycle(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
// UPCOMING -> ACTIVE
|
||||
const toActivate = await prisma.challenge.findMany({
|
||||
where: { status: 'UPCOMING', startsAt: { lte: now } },
|
||||
});
|
||||
for (const c of toActivate) {
|
||||
await prisma.challenge.update({
|
||||
where: { id: c.id },
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
logger.info(`Challenge ${c.id} "${c.title}" activated`);
|
||||
}
|
||||
|
||||
// ACTIVE -> COMPLETED (past end date)
|
||||
const toComplete = await prisma.challenge.findMany({
|
||||
where: { status: 'ACTIVE', endsAt: { lte: now } },
|
||||
});
|
||||
for (const c of toComplete) {
|
||||
await scoreChallenge(c.id);
|
||||
await prisma.challenge.update({
|
||||
where: { id: c.id },
|
||||
data: { status: 'COMPLETED' },
|
||||
});
|
||||
logger.info(`Challenge ${c.id} "${c.title}" completed`);
|
||||
}
|
||||
|
||||
// Score remaining ACTIVE challenges
|
||||
const active = await prisma.challenge.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const c of active) {
|
||||
await scoreChallenge(c.id);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Challenge lifecycle: ${toActivate.length} activated, ${toComplete.length} completed, ${active.length} active scored`,
|
||||
);
|
||||
}
|
||||
|
||||
export const challengeScoringService = {
|
||||
computeScore,
|
||||
scoreChallenge,
|
||||
processLifecycle,
|
||||
};
|
||||
82
api/src/services/env-writer.service.ts
Normal file
82
api/src/services/env-writer.service.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Service to auto-update the .env file with Pangolin credentials.
|
||||
* Reads the existing .env, updates/adds specific keys, preserves everything else.
|
||||
*/
|
||||
|
||||
const ENV_FILE_PATH = join(__dirname, '../../../.env');
|
||||
|
||||
/**
|
||||
* Update specific keys in the .env file.
|
||||
* - Preserves all existing keys and comments
|
||||
* - Updates keys if they already exist (replaces the value)
|
||||
* - Appends new keys at the end with a section comment
|
||||
* - Does NOT remove any existing keys
|
||||
*/
|
||||
export function updateEnvFile(updates: Record<string, string>): { success: boolean; updated: string[]; added: string[]; error?: string } {
|
||||
const updated: string[] = [];
|
||||
const added: string[] = [];
|
||||
|
||||
try {
|
||||
if (!existsSync(ENV_FILE_PATH)) {
|
||||
return { success: false, updated: [], added: [], error: `.env file not found at ${ENV_FILE_PATH}` };
|
||||
}
|
||||
|
||||
const content = readFileSync(ENV_FILE_PATH, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const keysToUpdate = new Set(Object.keys(updates));
|
||||
const processedKeys = new Set<string>();
|
||||
|
||||
// Update existing keys in-place
|
||||
const updatedLines = lines.map(line => {
|
||||
// Skip comments and empty lines
|
||||
if (line.startsWith('#') || line.trim() === '') {
|
||||
return line;
|
||||
}
|
||||
|
||||
// Match KEY=value pattern (handle quoted values too)
|
||||
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)/);
|
||||
if (match && match[1] && keysToUpdate.has(match[1])) {
|
||||
const key = match[1];
|
||||
const oldValue = match[2];
|
||||
const newValue = updates[key]!;
|
||||
processedKeys.add(key);
|
||||
|
||||
if (oldValue !== newValue) {
|
||||
updated.push(key);
|
||||
logger.info(`env-writer: updated ${key}`);
|
||||
return `${key}=${newValue}`;
|
||||
}
|
||||
return line; // Value unchanged
|
||||
}
|
||||
|
||||
return line;
|
||||
});
|
||||
|
||||
// Append any keys that weren't already in the file
|
||||
const keysToAdd = [...keysToUpdate].filter(k => !processedKeys.has(k));
|
||||
if (keysToAdd.length > 0) {
|
||||
// Add a blank line and comment section if we're adding new keys
|
||||
updatedLines.push('');
|
||||
updatedLines.push('# --- Pangolin Tunnel (auto-configured) ---');
|
||||
for (const key of keysToAdd) {
|
||||
updatedLines.push(`${key}=${updates[key]}`);
|
||||
added.push(key);
|
||||
logger.info(`env-writer: added ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back
|
||||
writeFileSync(ENV_FILE_PATH, updatedLines.join('\n'), 'utf8');
|
||||
|
||||
logger.info(`env-writer: ${updated.length} updated, ${added.length} added`);
|
||||
return { success: true, updated, added };
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error(`env-writer failed: ${errorMsg}`);
|
||||
return { success: false, updated: [], added: [], error: errorMsg };
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ export interface PangolinSite {
|
||||
lastSeen?: string;
|
||||
online?: boolean;
|
||||
type?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface PangolinExitNode {
|
||||
@ -28,10 +29,7 @@ export interface PangolinExitNode {
|
||||
}
|
||||
|
||||
export interface PangolinResource {
|
||||
siteResourceId: string; // Fixed: Pangolin API uses "siteResourceId", not "resourceId"
|
||||
resourceId?: string; // Legacy alias for backwards compatibility
|
||||
siteId: string;
|
||||
orgId: string;
|
||||
resourceId: string;
|
||||
name: string;
|
||||
subdomain?: string;
|
||||
fullDomain?: string;
|
||||
@ -41,6 +39,19 @@ export interface PangolinResource {
|
||||
proxyPort?: number;
|
||||
protocol?: string;
|
||||
domainBindings?: string[];
|
||||
http?: boolean;
|
||||
// Target info (returned by list endpoints)
|
||||
targets?: PangolinTarget[];
|
||||
}
|
||||
|
||||
export interface PangolinTarget {
|
||||
targetId: string;
|
||||
resourceId: string;
|
||||
siteId: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
method: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PangolinNewt {
|
||||
@ -49,47 +60,45 @@ export interface PangolinNewt {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export interface PangolinSiteDefaults {
|
||||
newtId: string;
|
||||
newtSecret: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface CreateSitePayload {
|
||||
name: string;
|
||||
type?: string;
|
||||
subnet?: string; // CIDR notation subnet (e.g., "100.90.128.0/24")
|
||||
exitNodeId?: string; // Exit node ID for tunneled sites
|
||||
subnet?: string;
|
||||
exitNodeId?: string;
|
||||
// Newt credentials from pickSiteDefaults
|
||||
newtId?: string;
|
||||
secret?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
// HTTP Resource (for web services) - Correct Pangolin API schema for CREATE
|
||||
// Uses endpoint: PUT /org/{orgId}/site-resource
|
||||
// The 'http: true' field differentiates HTTP resources from client-only resources
|
||||
// HTTP Resource (public proxy) - Correct Pangolin API schema
|
||||
// Uses endpoint: PUT /org/{orgId}/resource (NOT /site-resource)
|
||||
export interface CreateHttpResourcePayload {
|
||||
name: string;
|
||||
type: 'http'; // Internal routing flag only, not sent to API
|
||||
domainId: string; // Domain ID from listDomains()
|
||||
domainId: string;
|
||||
subdomain: string; // Subdomain only (e.g., "app") or empty string for root
|
||||
http: true; // REQUIRED: Set to true to create HTTP proxy resource (shows in Public Resources)
|
||||
ssl?: boolean; // Enable SSL/TLS (default: false)
|
||||
enabled?: boolean; // Enable the resource (default: false)
|
||||
// To make resource publicly accessible (not protected), use updateResource() with blockAccess: false
|
||||
http: true; // REQUIRED: marks as HTTP proxy resource
|
||||
protocol: 'tcp'; // REQUIRED for HTTP resources
|
||||
// Note: ssl and enabled are NOT valid creation fields — set via updateResource()
|
||||
}
|
||||
|
||||
// Raw TCP/UDP Resource
|
||||
export interface CreateRawResourcePayload {
|
||||
name: string;
|
||||
type: 'tcp' | 'udp';
|
||||
proxyPort: number;
|
||||
stickySession?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type CreateResourcePayload = CreateHttpResourcePayload | CreateRawResourcePayload;
|
||||
|
||||
export interface CreateTargetPayload {
|
||||
siteId: string | number; // REQUIRED: which site routes this traffic (Pangolin expects numeric)
|
||||
ip: string; // Target hostname/IP (e.g., "nginx")
|
||||
port: number; // Target port (e.g., 80)
|
||||
method: 'http' | 'https';
|
||||
host: string;
|
||||
port: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PangolinDomain {
|
||||
domainId: string;
|
||||
baseDomain: string; // Fixed: was "domain"
|
||||
baseDomain: string;
|
||||
verified: boolean;
|
||||
type?: string;
|
||||
failed?: boolean;
|
||||
@ -160,9 +169,11 @@ class PangolinClient {
|
||||
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
try {
|
||||
logger.debug(`Pangolin ${method} ${path}${body ? ` body=${JSON.stringify(body)}` : ''}`);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
@ -175,12 +186,16 @@ class PangolinClient {
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
logger.error(`Pangolin API ${method} ${path} returned ${res.status}: ${text}`);
|
||||
throw new Error(`Pangolin API ${method} ${path} returned ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return await res.json() as T;
|
||||
const json = await res.json();
|
||||
// Pangolin wraps responses in { data: {...}, success, status }
|
||||
// Unwrap if present
|
||||
return this.unwrapResponse<T>(json);
|
||||
}
|
||||
return {} as T;
|
||||
} finally {
|
||||
@ -188,6 +203,22 @@ class PangolinClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap Pangolin's response envelope: { data: {...}, success, status }
|
||||
* Returns the inner data object, or the raw response if not wrapped.
|
||||
*/
|
||||
private unwrapResponse<T>(json: unknown): T {
|
||||
if (json && typeof json === 'object' && !Array.isArray(json)) {
|
||||
const obj = json as Record<string, unknown>;
|
||||
// If it has a 'data' key and 'success' key, it's a wrapped response
|
||||
if ('data' in obj && 'success' in obj) {
|
||||
logger.debug('Unwrapped Pangolin response envelope');
|
||||
return obj.data as T;
|
||||
}
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
@ -207,105 +238,48 @@ class PangolinClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Site Defaults ---
|
||||
|
||||
/**
|
||||
* Get pre-generated Newt credentials for a new site.
|
||||
* Must be called BEFORE createSite() and passed into it.
|
||||
* Endpoint: GET /org/{orgId}/pick-site-defaults
|
||||
*/
|
||||
async pickSiteDefaults(): Promise<PangolinSiteDefaults> {
|
||||
const res = await this.request<unknown>('GET', `/org/${this.orgId}/pick-site-defaults`);
|
||||
const obj = res as Record<string, unknown>;
|
||||
|
||||
// Response format: { newtId, newtSecret, clientAddress, subnet, ... }
|
||||
// Note: `address` is the exit node address, `clientAddress` is the site address
|
||||
const newtId = obj.newtId as string || '';
|
||||
const newtSecret = obj.newtSecret as string || obj.secret as string || '';
|
||||
const address = obj.clientAddress as string || obj.address as string || '';
|
||||
|
||||
if (!newtId || !newtSecret) {
|
||||
logger.warn('pickSiteDefaults response missing newtId/newtSecret:', JSON.stringify(res));
|
||||
throw new Error('Pangolin did not return Newt credentials from pick-site-defaults');
|
||||
}
|
||||
|
||||
logger.info(`pickSiteDefaults: newtId=${newtId}, address=${address}`);
|
||||
return { newtId, newtSecret, address };
|
||||
}
|
||||
|
||||
// --- Sites ---
|
||||
|
||||
async listSites(): Promise<PangolinSite[]> {
|
||||
const res = await this.request<unknown>('GET', `/org/${this.orgId}/sites`);
|
||||
|
||||
// Handle direct array (edge case)
|
||||
if (Array.isArray(res)) {
|
||||
logger.info('listSites: received direct array');
|
||||
return res as PangolinSite[];
|
||||
}
|
||||
|
||||
const obj = res as Record<string, unknown>;
|
||||
|
||||
// Official Pangolin format: { data: { sites: [...], pagination: {...} } }
|
||||
if (obj.data && typeof obj.data === 'object') {
|
||||
const dataObj = obj.data as Record<string, unknown>;
|
||||
if (Array.isArray(dataObj.sites)) {
|
||||
logger.info(`listSites: extracted ${dataObj.sites.length} sites from data.sites`);
|
||||
return dataObj.sites as PangolinSite[];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: { sites: [...] } or { data: [...] }
|
||||
if (Array.isArray(obj.sites)) {
|
||||
logger.info(`listSites: extracted ${obj.sites.length} sites from sites`);
|
||||
return obj.sites as PangolinSite[];
|
||||
}
|
||||
if (Array.isArray(obj.data)) {
|
||||
logger.info(`listSites: extracted ${obj.data.length} sites from data`);
|
||||
return obj.data as PangolinSite[];
|
||||
}
|
||||
|
||||
logger.warn('listSites: could not extract sites array from response, returning empty');
|
||||
return [];
|
||||
return this.extractArray(res, 'sites', 'listSites');
|
||||
}
|
||||
|
||||
async listExitNodes(): Promise<PangolinExitNode[]> {
|
||||
try {
|
||||
const res = await this.request<unknown>(
|
||||
'GET',
|
||||
`/org/${this.orgId}/exit-nodes`,
|
||||
);
|
||||
|
||||
// Handle direct array (edge case)
|
||||
if (Array.isArray(res)) {
|
||||
logger.info('listExitNodes: received direct array');
|
||||
return res.filter(node =>
|
||||
node &&
|
||||
typeof node.exitNodeId === 'string' &&
|
||||
typeof node.name === 'string' &&
|
||||
typeof node.online === 'boolean'
|
||||
) as PangolinExitNode[];
|
||||
}
|
||||
|
||||
const obj = res as Record<string, unknown>;
|
||||
|
||||
// Official Pangolin format: { data: { exitNodes: [...], pagination: {...} } }
|
||||
if (obj.data && typeof obj.data === 'object') {
|
||||
const dataObj = obj.data as Record<string, unknown>;
|
||||
if (Array.isArray(dataObj.exitNodes)) {
|
||||
logger.info(`listExitNodes: extracted ${dataObj.exitNodes.length} exit nodes from data.exitNodes`);
|
||||
return dataObj.exitNodes.filter(node =>
|
||||
node &&
|
||||
typeof node.exitNodeId === 'string' &&
|
||||
typeof node.name === 'string' &&
|
||||
typeof node.online === 'boolean'
|
||||
) as PangolinExitNode[];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: { exitNodes: [...] } or { data: [...] }
|
||||
if (Array.isArray(obj.exitNodes)) {
|
||||
logger.info(`listExitNodes: extracted ${obj.exitNodes.length} exit nodes from exitNodes`);
|
||||
return obj.exitNodes.filter(node =>
|
||||
node &&
|
||||
typeof node.exitNodeId === 'string' &&
|
||||
typeof node.name === 'string' &&
|
||||
typeof node.online === 'boolean'
|
||||
) as PangolinExitNode[];
|
||||
}
|
||||
if (Array.isArray(obj.data)) {
|
||||
logger.info(`listExitNodes: extracted ${obj.data.length} exit nodes from data`);
|
||||
return obj.data.filter(node =>
|
||||
node &&
|
||||
typeof node.exitNodeId === 'string' &&
|
||||
typeof node.name === 'string' &&
|
||||
typeof node.online === 'boolean'
|
||||
) as PangolinExitNode[];
|
||||
}
|
||||
|
||||
logger.warn('listExitNodes: could not extract exit nodes array from response, returning empty');
|
||||
return [];
|
||||
const res = await this.request<unknown>('GET', `/org/${this.orgId}/exit-nodes`);
|
||||
return this.extractArray(res, 'exitNodes', 'listExitNodes');
|
||||
} catch (err) {
|
||||
// Exit nodes endpoint not available (404) - this is OK for self-hosted
|
||||
if (err instanceof Error && err.message.includes('404')) {
|
||||
logger.info('Pangolin exit-nodes endpoint not available (self-hosted mode without separate exit nodes)');
|
||||
logger.info('Pangolin exit-nodes endpoint not available (self-hosted mode)');
|
||||
return [];
|
||||
}
|
||||
// Other errors - log but don't fail
|
||||
logger.warn('Failed to fetch exit nodes:', err);
|
||||
return [];
|
||||
}
|
||||
@ -327,186 +301,81 @@ class PangolinClient {
|
||||
await this.request<void>('DELETE', `/site/${siteId}`);
|
||||
}
|
||||
|
||||
// --- Resources ---
|
||||
// --- HTTP Resources (public proxy) ---
|
||||
|
||||
/**
|
||||
* List HTTP proxy resources (public resources).
|
||||
* Endpoint: GET /org/{orgId}/resources (NOT /site-resources)
|
||||
*/
|
||||
async listResources(): Promise<PangolinResource[]> {
|
||||
const res = await this.request<unknown>(
|
||||
'GET',
|
||||
`/org/${this.orgId}/site-resources`,
|
||||
);
|
||||
|
||||
// DEBUG: Log full response structure
|
||||
logger.info(`listResources raw response: ${JSON.stringify(res, null, 2)}`);
|
||||
|
||||
// Handle direct array (edge case)
|
||||
if (Array.isArray(res)) {
|
||||
logger.info(`listResources: received direct array with ${res.length} items`);
|
||||
if (res.length > 0) {
|
||||
logger.info(`First resource: ${JSON.stringify(res[0], null, 2)}`);
|
||||
}
|
||||
return res as PangolinResource[];
|
||||
}
|
||||
|
||||
const obj = res as Record<string, unknown>;
|
||||
|
||||
// Official Pangolin format: { data: { siteResources: [...], pagination: {...} } }
|
||||
if (obj.data && typeof obj.data === 'object') {
|
||||
const dataObj = obj.data as Record<string, unknown>;
|
||||
if (Array.isArray(dataObj.siteResources)) {
|
||||
logger.info(`listResources: extracted ${dataObj.siteResources.length} resources from data.siteResources`);
|
||||
if (dataObj.siteResources.length > 0) {
|
||||
logger.info(`First resource from data.siteResources: ${JSON.stringify(dataObj.siteResources[0], null, 2)}`);
|
||||
}
|
||||
return dataObj.siteResources as PangolinResource[];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: { resources: [...] } or { data: [...] }
|
||||
if (Array.isArray(obj.resources)) {
|
||||
logger.info(`listResources: extracted ${obj.resources.length} resources from .resources`);
|
||||
if (obj.resources.length > 0) {
|
||||
logger.info(`First resource from .resources: ${JSON.stringify(obj.resources[0], null, 2)}`);
|
||||
}
|
||||
return obj.resources as PangolinResource[];
|
||||
}
|
||||
if (Array.isArray(obj.data)) {
|
||||
logger.info(`listResources: extracted ${obj.data.length} resources from .data`);
|
||||
if (obj.data.length > 0) {
|
||||
logger.info(`First resource from .data: ${JSON.stringify(obj.data[0], null, 2)}`);
|
||||
}
|
||||
return obj.data as PangolinResource[];
|
||||
}
|
||||
|
||||
logger.warn('listResources: could not extract resources array from response, returning empty');
|
||||
return [];
|
||||
const res = await this.request<unknown>('GET', `/org/${this.orgId}/resources`);
|
||||
return this.extractArray(res, 'resources', 'listResources');
|
||||
}
|
||||
|
||||
async getResource(resourceId: string): Promise<PangolinResource> {
|
||||
return this.request<PangolinResource>('GET', `/site-resource/${resourceId}`);
|
||||
return this.request<PangolinResource>('GET', `/resource/${resourceId}`);
|
||||
}
|
||||
|
||||
async createResource(siteId: string, data: CreateResourcePayload): Promise<PangolinResource> {
|
||||
// All resources use the same endpoint: PUT /org/{orgId}/site-resource
|
||||
// HTTP resources are differentiated by the 'http: true' field in the payload
|
||||
const endpoint = `/org/${this.orgId}/site-resource`;
|
||||
|
||||
// Remove 'type' from payload - it's only for internal routing logic, not sent to API
|
||||
const { type, ...payload } = data;
|
||||
|
||||
const isHttpResource = (data as CreateHttpResourcePayload).http === true;
|
||||
logger.info(`createResource endpoint: ${endpoint} (${isHttpResource ? 'HTTP' : 'CLIENT'} resource)`);
|
||||
logger.info(`createResource payload: ${JSON.stringify(payload, null, 2)}`);
|
||||
|
||||
const result = await this.request<PangolinResource>(
|
||||
/**
|
||||
* Create an HTTP proxy resource (public resource).
|
||||
* Endpoint: PUT /org/{orgId}/resource (NOT /site-resource)
|
||||
*/
|
||||
async createResource(data: CreateHttpResourcePayload): Promise<PangolinResource> {
|
||||
logger.info(`createResource: ${data.name} (subdomain: ${data.subdomain || '(root)'})`);
|
||||
return this.request<PangolinResource>(
|
||||
'PUT',
|
||||
endpoint,
|
||||
payload,
|
||||
);
|
||||
|
||||
// DEBUG: Log the response to see what fields are returned
|
||||
logger.info(`createResource response: ${JSON.stringify(result, null, 2)}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateResource(resourceId: string, data: UpdateResourcePayload): Promise<PangolinResource> {
|
||||
return this.request<PangolinResource>('POST', `/site-resource/${resourceId}`, data);
|
||||
}
|
||||
|
||||
async deleteResource(resourceId: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/site-resource/${resourceId}`);
|
||||
}
|
||||
|
||||
async createTarget(resourceId: string, data: CreateTargetPayload): Promise<unknown> {
|
||||
// Try the standard endpoint first
|
||||
return this.request<unknown>(
|
||||
'POST',
|
||||
`/resource/${resourceId}/target`,
|
||||
`/org/${this.orgId}/resource`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
async updateResource(resourceId: string, data: UpdateResourcePayload): Promise<PangolinResource> {
|
||||
return this.request<PangolinResource>('POST', `/resource/${resourceId}`, data);
|
||||
}
|
||||
|
||||
async deleteResource(resourceId: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/resource/${resourceId}`);
|
||||
}
|
||||
|
||||
// --- Targets ---
|
||||
|
||||
/**
|
||||
* Alternative target creation method that tries different endpoint formats
|
||||
* Used for debugging target creation failures
|
||||
* Create a target for a resource (routes traffic to a backend).
|
||||
* Endpoint: PUT /resource/{resourceId}/target (PUT, not POST)
|
||||
*/
|
||||
async createTargetAlt(resourceId: string, data: CreateTargetPayload, format: 'standard' | 'site-resource' = 'standard'): Promise<{ success: boolean; endpoint: string; response: unknown; error?: string }> {
|
||||
const endpoints = {
|
||||
'standard': `/resource/${resourceId}/target`,
|
||||
'site-resource': `/site-resource/${resourceId}/target`,
|
||||
};
|
||||
async createTarget(resourceId: string, data: CreateTargetPayload): Promise<PangolinTarget> {
|
||||
logger.info(`createTarget: resource=${resourceId}, ip=${data.ip}:${data.port}, method=${data.method}`);
|
||||
// Pangolin expects siteId as a number, not a string
|
||||
const payload = { ...data, siteId: Number(data.siteId) };
|
||||
return this.request<PangolinTarget>(
|
||||
'PUT',
|
||||
`/resource/${resourceId}/target`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
const endpoint = endpoints[format];
|
||||
logger.info(`Trying target creation with format "${format}" at ${endpoint}`);
|
||||
/**
|
||||
* List targets for a resource.
|
||||
* Endpoint: GET /resource/{resourceId}/targets (plural — NOT /target)
|
||||
*/
|
||||
async listTargets(resourceId: string): Promise<PangolinTarget[]> {
|
||||
const res = await this.request<unknown>('GET', `/resource/${resourceId}/targets`);
|
||||
return this.extractArray(res, 'targets', 'listTargets');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.request<unknown>('POST', endpoint, data);
|
||||
logger.info(`✅ Target creation succeeded at ${endpoint}`);
|
||||
return {
|
||||
success: true,
|
||||
endpoint,
|
||||
response,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(`❌ Target creation failed at ${endpoint}: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
endpoint,
|
||||
response: null,
|
||||
error,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Delete a target by ID.
|
||||
* Endpoint: DELETE /target/{targetId} (NOT nested under /resource/)
|
||||
*/
|
||||
async deleteTarget(targetId: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `/target/${targetId}`);
|
||||
}
|
||||
|
||||
// --- Domains ---
|
||||
|
||||
async listDomains(): Promise<PangolinDomain[]> {
|
||||
const res = await this.request<unknown>(
|
||||
'GET',
|
||||
`/org/${this.orgId}/domains`,
|
||||
);
|
||||
|
||||
// Handle direct array
|
||||
if (Array.isArray(res)) {
|
||||
return res as PangolinDomain[];
|
||||
}
|
||||
|
||||
const obj = res as Record<string, unknown>;
|
||||
|
||||
// Check nested data.domains
|
||||
if (obj.data && typeof obj.data === 'object') {
|
||||
const dataObj = obj.data as Record<string, unknown>;
|
||||
if (Array.isArray(dataObj.domains)) {
|
||||
return dataObj.domains as PangolinDomain[];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
if (Array.isArray(obj.domains)) {
|
||||
return obj.domains as PangolinDomain[];
|
||||
}
|
||||
if (Array.isArray(obj.data)) {
|
||||
return obj.data as PangolinDomain[];
|
||||
}
|
||||
|
||||
logger.warn('listDomains: could not extract domains array, returning empty');
|
||||
return [];
|
||||
}
|
||||
|
||||
async listResourcesForSite(siteId: string): Promise<PangolinResource[]> {
|
||||
const res = await this.request<{ data: PangolinResource[] } | PangolinResource[]>(
|
||||
'GET',
|
||||
`/org/${this.orgId}/site/${siteId}/resources`,
|
||||
);
|
||||
return Array.isArray(res) ? res : (res.data || []);
|
||||
}
|
||||
|
||||
async getResourceByNiceId(siteId: string, niceId: string): Promise<PangolinResource> {
|
||||
return this.request<PangolinResource>(
|
||||
'GET',
|
||||
`/org/${this.orgId}/site/${siteId}/resource/nice/${niceId}`,
|
||||
);
|
||||
const res = await this.request<unknown>('GET', `/org/${this.orgId}/domains`);
|
||||
return this.extractArray(res, 'domains', 'listDomains');
|
||||
}
|
||||
|
||||
// --- Certificates ---
|
||||
@ -524,12 +393,48 @@ class PangolinClient {
|
||||
|
||||
// --- Clients ---
|
||||
|
||||
async listClients(siteResourceId: string): Promise<PangolinConnectedClient[]> {
|
||||
const res = await this.request<{ data: PangolinConnectedClient[] } | PangolinConnectedClient[]>(
|
||||
'GET',
|
||||
`/site-resource/${siteResourceId}/clients`,
|
||||
);
|
||||
return Array.isArray(res) ? res : (res.data || []);
|
||||
async listClients(resourceId: string): Promise<PangolinConnectedClient[]> {
|
||||
const res = await this.request<unknown>('GET', `/resource/${resourceId}/clients`);
|
||||
return this.extractArray(res, 'clients', 'listClients');
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
* Extract an array from a Pangolin response that may be:
|
||||
* - A direct array
|
||||
* - An object with the array under a named key (e.g., { sites: [...] })
|
||||
* - An object with { data: [...] }
|
||||
*/
|
||||
private extractArray<T>(res: unknown, key: string, context: string): T[] {
|
||||
if (Array.isArray(res)) {
|
||||
return res as T[];
|
||||
}
|
||||
|
||||
if (res && typeof res === 'object') {
|
||||
const obj = res as Record<string, unknown>;
|
||||
|
||||
// Check named key (e.g., "sites", "resources", "domains")
|
||||
if (Array.isArray(obj[key])) {
|
||||
return obj[key] as T[];
|
||||
}
|
||||
|
||||
// Check nested data.{key}
|
||||
if (obj.data && typeof obj.data === 'object') {
|
||||
const dataObj = obj.data as Record<string, unknown>;
|
||||
if (Array.isArray(dataObj[key])) {
|
||||
return dataObj[key] as T[];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: { data: [...] }
|
||||
if (Array.isArray(obj.data)) {
|
||||
return obj.data as T[];
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`${context}: could not extract array from response, returning empty`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
5
api/upgrade/trigger.json
Normal file
5
api/upgrade/trigger.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"action": "check",
|
||||
"triggeredAt": "2026-03-03T22:28:23.863Z",
|
||||
"triggeredBy": "admin@bnkops.ca"
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
# Pangolin Resource Definitions
|
||||
# All resources route through Nginx (port 80)
|
||||
# All resources route through Nginx (port 80) by default
|
||||
# Newt tunnel → Nginx (port 80) → Backend containers (various ports)
|
||||
#
|
||||
# target_ip: the hostname/IP that Newt sends traffic to (default: nginx)
|
||||
# target_port: the port on the target host (default: 80)
|
||||
|
||||
resources:
|
||||
# Required services (fail if down)
|
||||
@ -8,18 +11,24 @@ resources:
|
||||
name: Admin GUI
|
||||
container: changemaker-v2-admin
|
||||
port: 3000
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: true
|
||||
|
||||
- subdomain: api
|
||||
name: API Server
|
||||
container: changemaker-v2-api
|
||||
port: 4000
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: true
|
||||
|
||||
- subdomain: "" # Root domain
|
||||
name: Public Site
|
||||
container: mkdocs-site-server-changemaker
|
||||
port: 80
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: true
|
||||
|
||||
# Optional services (warn and skip if down)
|
||||
@ -27,84 +36,112 @@ resources:
|
||||
name: NocoDB
|
||||
container: changemaker-v2-nocodb
|
||||
port: 8080
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: docs
|
||||
name: Documentation
|
||||
container: mkdocs-changemaker
|
||||
port: 8000
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: code
|
||||
name: Code Server
|
||||
container: code-server-changemaker
|
||||
port: 8080
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: n8n
|
||||
name: Workflows
|
||||
container: n8n-changemaker
|
||||
port: 5678
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: git
|
||||
name: Gitea
|
||||
container: gitea-changemaker
|
||||
port: 3000
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: home
|
||||
name: Homepage
|
||||
container: homepage-changemaker
|
||||
port: 3000
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: listmonk
|
||||
name: Newsletter
|
||||
container: listmonk-app
|
||||
port: 9000
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: qr
|
||||
name: Mini QR
|
||||
container: mini-qr
|
||||
port: 8080
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: draw
|
||||
name: Excalidraw
|
||||
container: excalidraw-changemaker
|
||||
port: 80
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: vault
|
||||
name: Vaultwarden
|
||||
container: vaultwarden-changemaker
|
||||
port: 80
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: mail
|
||||
name: MailHog
|
||||
container: mailhog-changemaker
|
||||
port: 8025
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: chat
|
||||
name: Rocket.Chat
|
||||
container: rocketchat-changemaker
|
||||
port: 3000
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: events
|
||||
name: Gancio Events
|
||||
container: gancio-changemaker
|
||||
port: 13120
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
- subdomain: meet
|
||||
name: Jitsi Meet
|
||||
container: jitsi-web-changemaker
|
||||
port: 80
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
|
||||
# Monitoring services (auto-detect profile)
|
||||
@ -112,5 +149,7 @@ resources:
|
||||
name: Grafana
|
||||
container: grafana-changemaker
|
||||
port: 3000
|
||||
target_ip: nginx
|
||||
target_port: 80
|
||||
required: false
|
||||
profile: monitoring # Auto-detect if monitoring profile active
|
||||
|
||||
@ -17,6 +17,7 @@ import { smsPack } from './tools/packs/sms.js';
|
||||
import { paymentsPack } from './tools/packs/payments.js';
|
||||
import { mediaPack } from './tools/packs/media.js';
|
||||
import { adminPack } from './tools/packs/admin.js';
|
||||
import { eventsPack } from './tools/packs/events.js';
|
||||
|
||||
// Tier 3 composite workflows
|
||||
import { dailyBriefing } from './tools/composite/daily-briefing.js';
|
||||
@ -76,8 +77,9 @@ export async function createServer(config: ServerConfig) {
|
||||
registry.registerPack(paymentsPack);
|
||||
registry.registerPack(mediaPack);
|
||||
registry.registerPack(adminPack);
|
||||
registry.registerPack(eventsPack);
|
||||
|
||||
console.error('[MCP] Server initialized with core tools + 5 on-demand packs');
|
||||
console.error('[MCP] Server initialized with core tools + 6 on-demand packs');
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user