Merge branch 'main' of https://gitea.stretchy.ca/rick/jama
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
|
**RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
|
||||||
|
|
||||||
**Current version:** 0.12.28
|
**Current version:** 0.13.1
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,729 +0,0 @@
|
|||||||
# jama — Complete Vibe-Code Build Prompt (v0.1.0)
|
|
||||||
|
|
||||||
> **How to use this document**
|
|
||||||
> Paste the contents of any single section (or the whole document) as your opening prompt when starting a new AI coding session. The more context you give upfront, the fewer clarifying rounds you need. This document was reverse-engineered from the real build history of jama across ~9 sessions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 1 — What to Build (Product Brief)
|
|
||||||
|
|
||||||
Build a **self-hosted team chat Progressive Web App** called **jama**.
|
|
||||||
|
|
||||||
It is a full-stack, single-container application that runs entirely inside Docker. Users install it on a private server and access it via a browser or as an installed PWA on desktop/mobile. There is no cloud dependency — everything (database, uploads, API, frontend) lives in one Docker image.
|
|
||||||
|
|
||||||
### Core philosophy
|
|
||||||
- Simple to self-host (one `docker compose up`)
|
|
||||||
- No external services required (no Firebase, no Pusher, no S3)
|
|
||||||
- Works as an installed PWA on Android, iOS, and desktop Chrome/Edge
|
|
||||||
- Instant real-time messaging via WebSockets (Socket.io)
|
|
||||||
- Push notifications via Web Push (VAPID), works when app is backgrounded
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 2 — Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|---|---|
|
|
||||||
| Backend runtime | Node.js 20 (Alpine) |
|
|
||||||
| Backend framework | Express.js |
|
|
||||||
| Real-time | Socket.io (server + client) |
|
|
||||||
| Database | SQLite via `better-sqlite3` |
|
|
||||||
| Auth | JWT in HTTP-only cookies + `jsonwebtoken` |
|
|
||||||
| Password hashing | `bcryptjs` |
|
|
||||||
| Push notifications | Web Push (`web-push` npm package, VAPID) |
|
|
||||||
| Image processing | `sharp` (avatar/logo resizing) |
|
|
||||||
| Frontend framework | React 18 + Vite |
|
|
||||||
| Frontend styling | Plain CSS with CSS custom properties (no Tailwind, no CSS modules) |
|
|
||||||
| Frontend fonts | Google Sans + Roboto (via Google Fonts CDN) |
|
|
||||||
| Emoji picker | `@emoji-mart/react` + `@emoji-mart/data` |
|
|
||||||
| Markdown rendering | `marked` (for help modal) |
|
|
||||||
| Container | Docker multi-stage build (builder stage for Vite, runtime stage for Node) |
|
|
||||||
| Orchestration | `docker-compose.yaml` with named volumes |
|
|
||||||
|
|
||||||
### Key npm packages (backend)
|
|
||||||
```
|
|
||||||
express, socket.io, better-sqlite3, bcryptjs, jsonwebtoken,
|
|
||||||
cookie-parser, cors, multer, sharp, web-push
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key npm packages (frontend)
|
|
||||||
```
|
|
||||||
react, react-dom, vite, socket.io-client, @emoji-mart/react,
|
|
||||||
@emoji-mart/data, marked
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 3 — Project File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
jama/
|
|
||||||
├── Dockerfile # Multi-stage: Vite build → Node runtime
|
|
||||||
├── docker-compose.yaml # Single service, two named volumes
|
|
||||||
├── build.sh # Docker build + optional push script
|
|
||||||
├── .env.example # All configurable env vars documented
|
|
||||||
├── about.json.example # Optional About modal customisation
|
|
||||||
├── data/ # Gitignored — runtime DB lives in Docker volume
|
|
||||||
├── backend/
|
|
||||||
│ ├── package.json
|
|
||||||
│ └── src/
|
|
||||||
│ ├── index.js # Express app, Socket.io server, all socket events
|
|
||||||
│ ├── middleware/
|
|
||||||
│ │ └── auth.js # JWT cookie auth middleware — exports { authMiddleware }
|
|
||||||
│ ├── models/
|
|
||||||
│ │ └── db.js # Schema init, migrations, seed functions
|
|
||||||
│ ├── routes/
|
|
||||||
│ │ ├── auth.js # Login, logout, register, change password
|
|
||||||
│ │ ├── users.js # User CRUD, avatar upload, profile update
|
|
||||||
│ │ ├── groups.js # Group CRUD, members, direct messages
|
|
||||||
│ │ ├── messages.js # Message history, image upload
|
|
||||||
│ │ ├── settings.js # App settings (name, logo, icons)
|
|
||||||
│ │ ├── push.js # VAPID keys, subscription save, send helper
|
|
||||||
│ │ ├── about.js # Serves about.json for About modal
|
|
||||||
│ │ └── help.js # Serves help.md for Getting Started modal
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ └── linkPreview.js # Fetches og: meta for URL previews
|
|
||||||
│ └── data/
|
|
||||||
│ └── help.md # Help content (NOT in /app/data volume)
|
|
||||||
└── frontend/
|
|
||||||
├── package.json
|
|
||||||
├── vite.config.js
|
|
||||||
├── index.html
|
|
||||||
└── src/
|
|
||||||
├── main.jsx
|
|
||||||
├── App.jsx
|
|
||||||
├── index.css # Global styles, CSS variables, dark mode
|
|
||||||
├── contexts/
|
|
||||||
│ ├── AuthContext.jsx # User state, login/logout
|
|
||||||
│ ├── SocketContext.jsx # Socket.io connection, reconnect logic
|
|
||||||
│ └── ToastContext.jsx # Global toast notifications
|
|
||||||
├── pages/
|
|
||||||
│ ├── Login.jsx
|
|
||||||
│ ├── Login.css
|
|
||||||
│ ├── Chat.jsx # Main app shell — groups state, socket events
|
|
||||||
│ ├── Chat.css
|
|
||||||
│ └── ChangePassword.jsx
|
|
||||||
├── components/
|
|
||||||
│ ├── Sidebar.jsx # Group/DM list, unread badges
|
|
||||||
│ ├── Sidebar.css
|
|
||||||
│ ├── ChatWindow.jsx # Message list, typing indicator, header
|
|
||||||
│ ├── ChatWindow.css
|
|
||||||
│ ├── Message.jsx # Individual message — reactions, reply, delete, link preview
|
|
||||||
│ ├── Message.css
|
|
||||||
│ ├── MessageInput.jsx # Text input, image upload, @mention, emoji
|
|
||||||
│ ├── MessageInput.css
|
|
||||||
│ ├── Avatar.jsx # Circular avatar with initials fallback
|
|
||||||
│ ├── GlobalBar.jsx # Top bar (mobile only)
|
|
||||||
│ ├── GroupInfoModal.jsx # Group details, members, custom name
|
|
||||||
│ ├── ProfileModal.jsx # User profile, display name, avatar, about
|
|
||||||
│ ├── SettingsModal.jsx # Admin: app name, logo, icons, VAPID, users
|
|
||||||
│ ├── UserManagerModal.jsx # Admin: user list, create, suspend, reset pw
|
|
||||||
│ ├── NewChatModal.jsx # Create group or start DM
|
|
||||||
│ ├── HelpModal.jsx # Getting Started modal (renders help.md)
|
|
||||||
│ ├── AboutModal.jsx # About this app modal
|
|
||||||
│ ├── SupportModal.jsx # Contact support (sends message to Support group)
|
|
||||||
│ ├── ImageLightbox.jsx # Full-screen image viewer
|
|
||||||
│ └── UserProfilePopup.jsx # Click avatar → see profile, start DM
|
|
||||||
└── utils/
|
|
||||||
└── api.js # All fetch calls to /api/*, parseTS helper
|
|
||||||
└── public/
|
|
||||||
├── sw.js # Service worker: cache, push, badge
|
|
||||||
├── manifest.json # Static fallback (dynamic manifest served by Express)
|
|
||||||
├── favicon.ico
|
|
||||||
└── icons/ # PWA icons: 192, 192-maskable, 512, 512-maskable, jama.png, logo-64.png
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 4 — Database Schema
|
|
||||||
|
|
||||||
Use SQLite with WAL mode and foreign keys enabled.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Core tables (CREATE TABLE IF NOT EXISTS for all)
|
|
||||||
|
|
||||||
users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL, -- login/real name
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
password TEXT NOT NULL, -- bcrypt hash
|
|
||||||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
|
||||||
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'suspended'
|
|
||||||
is_default_admin INTEGER DEFAULT 0,
|
|
||||||
must_change_password INTEGER DEFAULT 1,
|
|
||||||
avatar TEXT, -- relative URL e.g. /uploads/avatars/x.webp
|
|
||||||
about_me TEXT,
|
|
||||||
display_name TEXT, -- user-set public display name (nullable)
|
|
||||||
hide_admin_tag INTEGER DEFAULT 0,
|
|
||||||
last_online TEXT, -- datetime('now') string
|
|
||||||
help_dismissed INTEGER DEFAULT 0,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
|
|
||||||
groups (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
type TEXT DEFAULT 'public', -- 'public' | 'private'
|
|
||||||
owner_id INTEGER REFERENCES users(id),
|
|
||||||
is_default INTEGER DEFAULT 0, -- 1 = General Chat (cannot be deleted)
|
|
||||||
is_readonly INTEGER DEFAULT 0,
|
|
||||||
is_direct INTEGER DEFAULT 0, -- 1 = user-to-user DM
|
|
||||||
direct_peer1_id INTEGER, -- DM: first user ID
|
|
||||||
direct_peer2_id INTEGER, -- DM: second user ID
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
|
|
||||||
group_members (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
joined_at TEXT DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(group_id, user_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
content TEXT,
|
|
||||||
type TEXT DEFAULT 'text', -- 'text' | 'system'
|
|
||||||
image_url TEXT,
|
|
||||||
reply_to_id INTEGER REFERENCES messages(id),
|
|
||||||
is_deleted INTEGER DEFAULT 0, -- soft delete
|
|
||||||
link_preview TEXT, -- JSON string of og: meta
|
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
|
|
||||||
reactions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
emoji TEXT NOT NULL,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(message_id, user_id, emoji) -- one reaction type per user per message
|
|
||||||
)
|
|
||||||
|
|
||||||
notifications (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
message_id INTEGER,
|
|
||||||
group_id INTEGER,
|
|
||||||
from_user_id INTEGER,
|
|
||||||
is_read INTEGER DEFAULT 0,
|
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
|
|
||||||
active_sessions (
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
device TEXT NOT NULL DEFAULT 'desktop', -- 'desktop' | 'mobile'
|
|
||||||
token TEXT NOT NULL,
|
|
||||||
ua TEXT,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
PRIMARY KEY (user_id, device)
|
|
||||||
)
|
|
||||||
|
|
||||||
push_subscriptions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
endpoint TEXT NOT NULL,
|
|
||||||
p256dh TEXT NOT NULL,
|
|
||||||
auth TEXT NOT NULL,
|
|
||||||
device TEXT DEFAULT 'desktop',
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(user_id, device)
|
|
||||||
)
|
|
||||||
|
|
||||||
settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
-- Default settings keys: app_name, logo_url, pw_reset_active, icon_newchat,
|
|
||||||
-- icon_groupinfo, pwa_icon_192, pwa_icon_512, vapid_public, vapid_private
|
|
||||||
|
|
||||||
user_group_names (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
group_id INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, group_id)
|
|
||||||
)
|
|
||||||
-- Allows each user to set a personal display name for any group/DM
|
|
||||||
```
|
|
||||||
|
|
||||||
### Migration pattern
|
|
||||||
All schema changes must be additive `ALTER TABLE` statements wrapped in try/catch (column-already-exists errors are ignored). Never drop or recreate tables in migrations — this is a live production DB.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
try {
|
|
||||||
db.exec("ALTER TABLE users ADD COLUMN last_online TEXT");
|
|
||||||
} catch (e) { /* already exists */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 5 — Auth & Session System
|
|
||||||
|
|
||||||
- JWT stored in **HTTP-only cookie** (`token`), 30-day expiry
|
|
||||||
- `authMiddleware` in `middleware/auth.js` — verifies JWT, attaches `req.user`
|
|
||||||
- **Per-device sessions**: one session per `(user_id, device)` pair — logging in on mobile doesn't kick out desktop
|
|
||||||
- Device is detected from User-Agent: mobile if `/Mobile|Android|iPhone/i.test(ua)`
|
|
||||||
- `active_sessions` table stores current tokens for invalidation on logout/suspend
|
|
||||||
- `must_change_password = 1` redirects to `/change-password` after login
|
|
||||||
- Admin can reset any user's password; `ADMPW_RESET=true` env var resets default admin password on container start
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 6 — Real-time Architecture (Socket.io)
|
|
||||||
|
|
||||||
### Connection
|
|
||||||
- Socket auth: JWT token in `socket.handshake.auth.token`
|
|
||||||
- On connect: user joins `group:{id}` rooms for all their groups, and `user:{id}` for direct notifications
|
|
||||||
- `onlineUsers` Map: `userId → Set<socketId>` (multiple tabs/devices)
|
|
||||||
|
|
||||||
### Socket events (server → client)
|
|
||||||
| Event | Payload | When |
|
|
||||||
|---|---|---|
|
|
||||||
| `message:new` | full message object with reactions | new message in a group |
|
|
||||||
| `message:deleted` | `{ messageId, groupId }` | message soft-deleted |
|
|
||||||
| `reaction:updated` | `{ messageId, reactions[] }` | reaction toggled |
|
|
||||||
| `typing:start` | `{ userId, groupId, user }` | user started typing |
|
|
||||||
| `typing:stop` | `{ userId, groupId }` | user stopped typing |
|
|
||||||
| `notification:new` | `{ type, groupId, fromUser }` | mention or private message |
|
|
||||||
| `group:updated` | group object | group renamed, settings changed |
|
|
||||||
| `group:removed` | `{ groupId }` | user removed from group |
|
|
||||||
|
|
||||||
### Socket events (client → server)
|
|
||||||
| Event | Payload |
|
|
||||||
|---|---|
|
|
||||||
| `message:send` | `{ groupId, content, replyToId, imageUrl }` |
|
|
||||||
| `message:delete` | `{ messageId }` |
|
|
||||||
| `reaction:toggle` | `{ messageId, emoji }` |
|
|
||||||
| `typing:start` | `{ groupId }` |
|
|
||||||
| `typing:stop` | `{ groupId }` |
|
|
||||||
| `group:join-room` | `{ groupId }` |
|
|
||||||
| `group:leave-room` | `{ groupId }` |
|
|
||||||
|
|
||||||
### Reconnect strategy (SocketContext.jsx)
|
|
||||||
```javascript
|
|
||||||
// Aggressive reconnect for mobile PWA (which drops connections when backgrounded)
|
|
||||||
reconnectionDelay: 500,
|
|
||||||
reconnectionDelayMax: 3000,
|
|
||||||
timeout: 8000
|
|
||||||
// Also: on visibilitychange → visible, call socket.connect() if disconnected
|
|
||||||
// Also: on socket 'connect' event, reload groups (catches missed messages)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 7 — Push Notifications (Web Push / VAPID)
|
|
||||||
|
|
||||||
- Generate VAPID keys once (stored in `settings` table as `vapid_public` / `vapid_private`)
|
|
||||||
- Endpoint: `POST /api/push/subscribe` — saves subscription per `(user_id, device)`
|
|
||||||
- Push is sent when the target user has **no active socket connections** (truly offline)
|
|
||||||
- Push payload: `{ title, body, url, groupId }`
|
|
||||||
- `groupId` in payload enables per-conversation notification grouping via `tag` in `showNotification`
|
|
||||||
- Service worker skips notification if app window is currently visible (already open)
|
|
||||||
- Re-register push subscription on every `visibilitychange → visible` event (mobile browsers drop subscriptions when PWA is backgrounded)
|
|
||||||
- `navigator.setAppBadge(count)` updates home screen badge (Chrome/Edge/Android — not iOS)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 8 — API Routes
|
|
||||||
|
|
||||||
All routes require `authMiddleware` except login/register/health.
|
|
||||||
|
|
||||||
### Auth (`/api/auth`)
|
|
||||||
- `POST /login` — returns JWT cookie, user object
|
|
||||||
- `POST /logout` — clears cookie, removes active_session
|
|
||||||
- `POST /register` — admin only
|
|
||||||
- `POST /change-password`
|
|
||||||
- `GET /me` — returns current user from JWT
|
|
||||||
|
|
||||||
### Users (`/api/users`)
|
|
||||||
- `GET /` — admin only: full user list with last_online
|
|
||||||
- `POST /` — admin: create user
|
|
||||||
- `PATCH /:id` — admin: update role/status/password
|
|
||||||
- `PATCH /me/profile` — update own display_name, about_me, hide_admin_tag
|
|
||||||
- `POST /me/avatar` — multipart: upload + sharp-resize to 200×200 webp
|
|
||||||
- `GET /check-display-name?name=` — returns `{ taken: bool }`
|
|
||||||
|
|
||||||
### Groups (`/api/groups`)
|
|
||||||
- `GET /` — returns `{ publicGroups, privateGroups }` with last_message, peer data for DMs
|
|
||||||
- `POST /` — create group or start DM (checks for existing DM between pair)
|
|
||||||
- `PATCH /:id` — rename group
|
|
||||||
- `DELETE /:id` — delete group (admin only, not default group)
|
|
||||||
- `POST /:id/members` — add member
|
|
||||||
- `DELETE /:id/members/:userId` — remove member
|
|
||||||
- `GET /:id/members` — list members
|
|
||||||
- `POST /:id/custom-name` — set per-user custom group name
|
|
||||||
- `DELETE /:id/custom-name` — remove custom name
|
|
||||||
|
|
||||||
### Messages (`/api/messages`)
|
|
||||||
- `GET /?groupId=&before=&limit=` — paginated message history (50 per page)
|
|
||||||
- `POST /image` — multipart image upload, returns `{ url }`
|
|
||||||
|
|
||||||
### Settings (`/api/settings`)
|
|
||||||
- `GET /` — returns all settings (public: app_name, logo_url; admin: everything)
|
|
||||||
- `PATCH /` — admin: update settings
|
|
||||||
- `POST /logo` — admin: upload logo
|
|
||||||
- `POST /icon/:key` — admin: upload PWA icon
|
|
||||||
|
|
||||||
### Push (`/api/push`)
|
|
||||||
- `GET /vapid-public` — returns VAPID public key (unauthenticated)
|
|
||||||
- `POST /subscribe` — save push subscription
|
|
||||||
|
|
||||||
### Help (`/api/help`)
|
|
||||||
- `GET /` — returns rendered help.md content
|
|
||||||
- `GET /status` — returns `{ dismissed: bool }` for current user
|
|
||||||
- `POST /dismiss` — set help_dismissed flag
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 9 — Frontend Architecture
|
|
||||||
|
|
||||||
### State management
|
|
||||||
No Redux. Three React contexts:
|
|
||||||
1. **AuthContext** — `user`, `login()`, `logout()`, `updateUser()`
|
|
||||||
2. **SocketContext** — `socket` instance, handles connection lifecycle
|
|
||||||
3. **ToastContext** — `toast(message, type)` for notifications
|
|
||||||
|
|
||||||
### Chat.jsx (main shell)
|
|
||||||
- Holds `groups` state: `{ publicGroups[], privateGroups[] }`
|
|
||||||
- Handles all Socket.io group-level events and dispatches to child components
|
|
||||||
- On `message:new`: updates `last_message`, `last_message_at`, `last_message_user_id`, re-sorts private groups newest-first
|
|
||||||
- Tracks `unreadGroups: Map<groupId, count>` — increments on new messages in non-active groups (or active group when window is hidden)
|
|
||||||
- Manages `activeGroupId`, `notifications[]`, PWA badge count
|
|
||||||
- Re-registers push on `visibilitychange → visible`
|
|
||||||
- Shows HelpModal if user hasn't dismissed it
|
|
||||||
|
|
||||||
### Sidebar.jsx
|
|
||||||
- Renders public groups (fixed order: default first, then alphabetical)
|
|
||||||
- Renders private groups (sorted newest-message first)
|
|
||||||
- DM entries show peer's avatar (if set), `Display Name (real name)` format when peer has a display name
|
|
||||||
- Unread badge counts (blue dot for unread, numbered badge for @mentions)
|
|
||||||
- Page title format: `(N) App Name` where N is total unread count
|
|
||||||
|
|
||||||
### ChatWindow.jsx
|
|
||||||
- Renders message list with infinite scroll upward (load older messages on scroll to top)
|
|
||||||
- Typing indicator with bouncing dots animation
|
|
||||||
- Header: for DMs shows peer avatar + `Display Name (real name)` title
|
|
||||||
- Marks messages as read when group becomes active
|
|
||||||
|
|
||||||
### Message.jsx features
|
|
||||||
- Date separators between days
|
|
||||||
- Consecutive messages from same user collapse avatar (show once)
|
|
||||||
- Emoji-only messages render larger
|
|
||||||
- Quick reactions bar (👍❤️😂😮😢🙏) + full emoji picker
|
|
||||||
- Reply-to preview with quoted content
|
|
||||||
- Link preview cards (og: meta, fetched server-side to avoid CORS)
|
|
||||||
- Image messages with lightbox on click
|
|
||||||
- @mention highlighting
|
|
||||||
- Delete button (own messages + admin/owner can delete any)
|
|
||||||
- Long-press on mobile = show action menu
|
|
||||||
|
|
||||||
### MessageInput.jsx
|
|
||||||
- Auto-expanding textarea
|
|
||||||
- `@` triggers mention picker (searches group members)
|
|
||||||
- Image attach button (uploads immediately, inserts URL into content)
|
|
||||||
- Emoji picker button
|
|
||||||
- `Enter` to send, `Shift+Enter` for newline
|
|
||||||
- Typing events: emit `typing:start` on input, `typing:stop` after 2s idle
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 10 — CSS Design System
|
|
||||||
|
|
||||||
### Variables (`:root` — day mode)
|
|
||||||
```css
|
|
||||||
--primary: #1a73e8;
|
|
||||||
--primary-dark: #1557b0;
|
|
||||||
--primary-light: #e8f0fe;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface-variant: #f8f9fa;
|
|
||||||
--background: #f1f3f4;
|
|
||||||
--border: #e0e0e0;
|
|
||||||
--text-primary: #202124;
|
|
||||||
--text-secondary: #5f6368;
|
|
||||||
--text-tertiary: #9aa0a6;
|
|
||||||
--error: #d93025;
|
|
||||||
--success: #188038;
|
|
||||||
--bubble-out: #1a73e8; /* own message bubble */
|
|
||||||
--bubble-in: #f1f3f4; /* other's message bubble */
|
|
||||||
--radius: 8px;
|
|
||||||
--radius-lg: 16px;
|
|
||||||
--radius-xl: 24px;
|
|
||||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.12);
|
|
||||||
--shadow-md: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
--font: 'Google Sans', 'Roboto', sans-serif;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dark mode (`[data-theme="dark"]`)
|
|
||||||
```css
|
|
||||||
--primary: #4d8fd4;
|
|
||||||
--primary-light: #1a2d4a;
|
|
||||||
--surface: #1e1e2e;
|
|
||||||
--surface-variant: #252535;
|
|
||||||
--background: #13131f;
|
|
||||||
--border: #2e2e45;
|
|
||||||
--text-primary: #e2e2f0;
|
|
||||||
--text-secondary: #9898b8;
|
|
||||||
--text-tertiary: #606080; /* NOTE: exactly 6 hex digits — a common typo is 7 */
|
|
||||||
--bubble-out: #4d8fd4;
|
|
||||||
--bubble-in: #252535;
|
|
||||||
```
|
|
||||||
|
|
||||||
Dark mode is toggled by setting `document.documentElement.setAttribute('data-theme', 'dark')` and saved to `localStorage`.
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
- Desktop: sidebar (320px fixed) + chat area (flex-1) side by side
|
|
||||||
- Mobile (≤768px): sidebar and chat stack — only one visible at a time, back button navigates
|
|
||||||
- Chat area: header + scrollable message list (flex-1, overflow-y: auto) + input fixed at bottom
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 11 — Docker & Deployment
|
|
||||||
|
|
||||||
### Dockerfile (multi-stage)
|
|
||||||
```dockerfile
|
|
||||||
# Stage 1: Build frontend
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY frontend/package*.json ./frontend/
|
|
||||||
RUN cd frontend && npm install
|
|
||||||
COPY frontend/ ./frontend/
|
|
||||||
RUN cd frontend && npm run build
|
|
||||||
|
|
||||||
# Stage 2: Runtime
|
|
||||||
FROM node:20-alpine
|
|
||||||
RUN apk add --no-cache sqlite python3 make g++
|
|
||||||
WORKDIR /app
|
|
||||||
COPY backend/package*.json ./
|
|
||||||
RUN npm install --omit=dev
|
|
||||||
RUN apk del python3 make g++
|
|
||||||
COPY backend/ ./
|
|
||||||
COPY --from=builder /app/frontend/dist ./public
|
|
||||||
RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["node", "src/index.js"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Critical**: `help.md` lives at `backend/src/data/help.md` → copied to `/app/src/data/help.md` in the image. It must NOT be placed in `/app/data/` which is volume-mounted and will hide baked-in files.
|
|
||||||
|
|
||||||
### docker-compose.yaml
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
jama:
|
|
||||||
image: jama:${JAMA_VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "${PORT:-3000}:3000"
|
|
||||||
environment:
|
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
|
||||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
|
||||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
|
||||||
- ADMPW_RESET=${ADMPW_RESET:-false} # set true to reset admin pw on next start
|
|
||||||
- JWT_SECRET=${JWT_SECRET:-changeme}
|
|
||||||
- APP_NAME=${APP_NAME:-jama}
|
|
||||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
|
||||||
volumes:
|
|
||||||
- jama_db:/app/data # SQLite + uploads persist here
|
|
||||||
- jama_uploads:/app/uploads
|
|
||||||
volumes:
|
|
||||||
jama_db:
|
|
||||||
jama_uploads:
|
|
||||||
```
|
|
||||||
|
|
||||||
### Volume gotcha
|
|
||||||
Named Docker volumes shadow any files baked into that directory at image build time. Any file that must survive across rebuilds AND be editable at runtime goes in the volume. Any file that is bundled with the app and should not be overridden by the volume (like `help.md`) must be stored outside the volume mount path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 12 — PWA / Service Worker
|
|
||||||
|
|
||||||
- `manifest.json` is **dynamically served by Express** (not static) so app name and icons update without a rebuild
|
|
||||||
- Service worker (`sw.js`) handles:
|
|
||||||
- Asset caching for offline support
|
|
||||||
- Web Push notifications
|
|
||||||
- Per-conversation notification grouping by `tag: 'jama-group-{groupId}'`
|
|
||||||
- Skip notification if app window is currently visible (`clients.matchAll`)
|
|
||||||
- App badge sync via `SET_BADGE` message from the app
|
|
||||||
- Clear badge on notification click
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 13 — Features Checklist
|
|
||||||
|
|
||||||
### Messaging
|
|
||||||
- [x] Text messages with URL auto-linking
|
|
||||||
- [x] @mention with inline picker (type `@` to trigger)
|
|
||||||
- [x] Image upload + display with lightbox
|
|
||||||
- [x] Link preview cards (og: meta, server-side fetch)
|
|
||||||
- [x] Reply-to with quoted preview
|
|
||||||
- [x] Emoji reactions (quick bar + full picker)
|
|
||||||
- [x] Message delete (soft delete — shows "deleted" placeholder)
|
|
||||||
- [x] Typing indicator with animated dots
|
|
||||||
- [x] Date separators
|
|
||||||
- [x] System messages (e.g. "User joined")
|
|
||||||
- [x] Emoji-only messages render at larger size
|
|
||||||
- [x] Infinite scroll (load older messages on scroll to top)
|
|
||||||
|
|
||||||
### Groups & DMs
|
|
||||||
- [x] Public channels (# icon, auto-join for all users)
|
|
||||||
- [x] Private groups (lock icon, invite-only)
|
|
||||||
- [x] Read-only channels (📢, only admins can post)
|
|
||||||
- [x] User-to-user direct messages
|
|
||||||
- [x] Support group (private, all admins auto-added)
|
|
||||||
- [x] Per-user custom group display name (only visible to that user)
|
|
||||||
- [x] Group message list sorted newest-first (re-sorted on live messages)
|
|
||||||
- [x] DM shows peer's avatar and `Display Name (real name)` format
|
|
||||||
|
|
||||||
### Users & Profiles
|
|
||||||
- [x] User display name (separate from login name)
|
|
||||||
- [x] User avatar (upload, resize to 200×200 webp)
|
|
||||||
- [x] About me text
|
|
||||||
- [x] Hide admin tag option
|
|
||||||
- [x] Last online timestamp in admin user list
|
|
||||||
- [x] Suspend/unsuspend users
|
|
||||||
|
|
||||||
### Admin
|
|
||||||
- [x] Settings modal: app name, logo, PWA icons, VAPID key generation
|
|
||||||
- [x] User manager: create, edit, suspend, reset password
|
|
||||||
- [x] Admin can delete any message
|
|
||||||
- [x] Custom icons for UI elements (new chat button, group info button)
|
|
||||||
|
|
||||||
### PWA / Notifications
|
|
||||||
- [x] Installable PWA (manifest, service worker, icons)
|
|
||||||
- [x] Web Push notifications (VAPID, works when app is closed)
|
|
||||||
- [x] App badge on home screen icon (Chrome/Edge/Android)
|
|
||||||
- [x] Page title unread count `(N) App Name`
|
|
||||||
- [x] Per-conversation notification grouping
|
|
||||||
- [x] Push re-registration on mobile resume (prevents subscription loss)
|
|
||||||
|
|
||||||
### UX
|
|
||||||
- [x] Day / dark mode toggle (saved to localStorage)
|
|
||||||
- [x] Getting Started help modal (markdown, per-user dismiss)
|
|
||||||
- [x] About modal (customisable via about.json)
|
|
||||||
- [x] Support modal (sends message to Support group)
|
|
||||||
- [x] User profile popup (click any avatar)
|
|
||||||
- [x] Mobile-responsive layout (sidebar ↔ chat toggle)
|
|
||||||
- [x] `overscroll-behavior-y: none` (prevents pull-to-refresh viewport shift in PWA)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 14 — Known Gotchas & Decisions
|
|
||||||
|
|
||||||
These are things that will catch you out if you don't know them upfront.
|
|
||||||
|
|
||||||
| Gotcha | Solution |
|
|
||||||
|---|---|
|
|
||||||
| `auth.js` exports `{ authMiddleware }` as an object | Always destructure: `const { authMiddleware } = require('../middleware/auth')` |
|
|
||||||
| Docker named volume shadows baked-in files | Never put runtime-required bundled files in `/app/data/` |
|
|
||||||
| Dark mode `--text-tertiary` hex typo | Value must be exactly 6 hex digits: `#606080` not `#6060808` |
|
|
||||||
| `last_message_user_id` not set on live updates | Must include `last_message_user_id: msg.user_id` in the `setGroups` update — not just content and timestamp |
|
|
||||||
| Mobile push subscriptions drop when PWA is backgrounded | Re-register push on every `visibilitychange → visible` event |
|
|
||||||
| Socket.io reconnect on mobile PWA resume | Add `visibilitychange` handler that calls `socket.connect()` if disconnected |
|
|
||||||
| Minimized window doesn't count unread in active chat | Check `document.visibilityState === 'hidden'` before skipping unread increment |
|
|
||||||
| JSX edits with `sed` break on special characters | Use Python string replacement for all JSX file edits |
|
|
||||||
| Multiple named exports vs default exports | Routes that need to share `io` accept it as a parameter: `module.exports = (io) => router` |
|
|
||||||
| Page title overwritten on settings refresh | Preserve `(N)` prefix: `const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] \|\| ''` |
|
|
||||||
| `navigator.setAppBadge` not in service worker scope | Use `self.navigator.setAppBadge` inside `sw.js` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 15 — Prompt Engineering Notes
|
|
||||||
|
|
||||||
When using this prompt with an AI coding assistant, these practices produced the best results across the 9 build sessions:
|
|
||||||
|
|
||||||
### Do
|
|
||||||
- **Give the full schema upfront** — the AI can design all routes consistently without asking
|
|
||||||
- **Specify the exact npm packages** — prevents the AI choosing different libs mid-build
|
|
||||||
- **Name every socket event** — socket event naming inconsistencies between client/server are silent bugs
|
|
||||||
- **Describe the volume gotcha explicitly** — this caused a real production bug
|
|
||||||
- **Ask for migrations, not table drops** — always say "wrap in try/catch, column-already-exists is OK"
|
|
||||||
- **Specify `module.exports` patterns** — named vs default exports is a common source of `is not a function` crashes
|
|
||||||
- **Request CSS variable names explicitly** — prevents the AI inventing colour values instead of using the system
|
|
||||||
- **For JSX edits, request Python string replacement** — sed with special characters destroys JSX
|
|
||||||
|
|
||||||
### Avoid
|
|
||||||
- Asking for partial implementations and filling in later — the AI will make different assumptions
|
|
||||||
- Letting the AI choose the DB (it will pick Postgres — SQLite is intentional here)
|
|
||||||
- Letting the AI choose the state management (it will add Redux/Zustand — contexts are intentional)
|
|
||||||
- Vague feature requests like "add notifications" — specify: socket-based for online users, push for offline, badge for all
|
|
||||||
|
|
||||||
### Iteration pattern that worked
|
|
||||||
1. **Session 1**: Stack + schema + auth + basic messaging
|
|
||||||
2. **Session 2**: Groups, DMs, reactions, replies
|
|
||||||
3. **Session 3**: Push notifications, service worker, PWA manifest
|
|
||||||
4. **Session 4**: Admin panel, user management, settings
|
|
||||||
5. **Session 5**: Mobile fixes, reconnect logic, offline resilience
|
|
||||||
6. **Session 6+**: Feature additions and bug fixes
|
|
||||||
|
|
||||||
Each session started by re-reading the previous session summary so the AI had full context on decisions made.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 16 — One-Shot Prompt (Copy-Paste to Start)
|
|
||||||
|
|
||||||
> Use this if you want to try building jama in a single session. It is dense by design — the AI needs all of it.
|
|
||||||
|
|
||||||
```
|
|
||||||
Build a self-hosted team chat PWA called "jama". Single Docker container.
|
|
||||||
No external services.
|
|
||||||
|
|
||||||
STACK: Node 20 + Express + Socket.io + SQLite (better-sqlite3) + JWT
|
|
||||||
(HTTP-only cookie) + bcryptjs + React 18 + Vite.
|
|
||||||
Push via web-push (VAPID). Images via multer + sharp.
|
|
||||||
Frontend: plain CSS with CSS custom properties, no Tailwind.
|
|
||||||
|
|
||||||
STRUCTURE:
|
|
||||||
- backend/src/index.js — Express app + all Socket.io events
|
|
||||||
- backend/src/middleware/auth.js — exports { authMiddleware }
|
|
||||||
- backend/src/models/db.js — schema, migrations (additive ALTER TABLE in try/catch only, never drop), seed
|
|
||||||
- backend/src/routes/ — auth, users, groups, messages, settings, push, about, help
|
|
||||||
- frontend/src/ — React app with AuthContext, SocketContext, ToastContext
|
|
||||||
- frontend/public/sw.js — service worker for push + badge + cache
|
|
||||||
- Dockerfile — multi-stage (Vite builder → Node runtime)
|
|
||||||
- docker-compose.yaml — single service, volumes: jama_db:/app/data, jama_uploads:/app/uploads
|
|
||||||
|
|
||||||
DATABASE TABLES: users (name, email, password, role, status, is_default_admin,
|
|
||||||
must_change_password, avatar, about_me, display_name, hide_admin_tag, last_online,
|
|
||||||
help_dismissed), groups (name, type, owner_id, is_default, is_readonly, is_direct,
|
|
||||||
direct_peer1_id, direct_peer2_id), group_members, messages (content, type, image_url,
|
|
||||||
reply_to_id, is_deleted, link_preview), reactions (UNIQUE message+user+emoji),
|
|
||||||
notifications, active_sessions (PRIMARY KEY user_id+device), push_subscriptions
|
|
||||||
(UNIQUE user_id+device), settings, user_group_names (PRIMARY KEY user_id+group_id).
|
|
||||||
|
|
||||||
AUTH: JWT in HTTP-only cookie. Per-device sessions (desktop/mobile detected from UA).
|
|
||||||
must_change_password redirects to /change-password. ADMPW_RESET env var resets admin.
|
|
||||||
|
|
||||||
SOCKET EVENTS: message:new, message:deleted, reaction:updated, typing:start/stop,
|
|
||||||
notification:new, group:updated, group:removed. Server tracks onlineUsers Map for
|
|
||||||
push fallback. Reconnect: reconnectionDelay 500ms, re-register push on visibilitychange.
|
|
||||||
|
|
||||||
FEATURES: Public/private/readonly channels, user-to-user DMs, @mentions, emoji
|
|
||||||
reactions (toggle — same emoji off, different emoji replaces), reply-to, image
|
|
||||||
upload, link previews (server-side og: fetch), message soft-delete, typing indicator
|
|
||||||
(bouncing dots — use CSS var(--text-tertiary) for dot colour), unread badges,
|
|
||||||
page title (N) count, PWA badge via navigator.setAppBadge, Web Push with per-group
|
|
||||||
notification tags, per-user custom group names, user display names, avatars (200x200
|
|
||||||
webp), day/dark mode (CSS vars, saved localStorage), getting-started help modal
|
|
||||||
(renders help.md via marked), admin settings (app name, logo, PWA icons, VAPID
|
|
||||||
keygen), admin user manager, Support group (auto-created, all admins added).
|
|
||||||
|
|
||||||
LAYOUT: Desktop: 320px sidebar + flex chat. Mobile ≤768px: sidebar/chat toggle.
|
|
||||||
CSS system: Google Sans font, --primary #1a73e8, dark mode on [data-theme="dark"].
|
|
||||||
|
|
||||||
GOTCHAS:
|
|
||||||
- help.md must be at backend/src/data/help.md (NOT /app/data — that path is
|
|
||||||
volume-mounted and will shadow baked-in files)
|
|
||||||
- auth.js must export { authMiddleware } as named export
|
|
||||||
- dark mode --text-tertiary must be exactly 6 hex digits: #606080
|
|
||||||
- last_message_user_id must be included in real-time setGroups update
|
|
||||||
- Use Python string replacement for JSX file edits, never sed
|
|
||||||
- self.navigator.setAppBadge (not navigator) inside service worker scope
|
|
||||||
```
|
|
||||||
```
|
|
||||||
880
Reference/vibecode-prompt.md
Normal file
880
Reference/vibecode-prompt.md
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
# RosterChirp — Complete Vibe-Code Build Prompt (v0.13.1)
|
||||||
|
|
||||||
|
> **How to use this document**
|
||||||
|
> Paste the contents of any single section (or the whole document) as your opening prompt when starting a new AI coding session. The more context you give upfront, the fewer clarifying rounds you need. This document reflects the real production build of RosterChirp as of v0.12.53.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — What to Build (Product Brief)
|
||||||
|
|
||||||
|
Build a **self-hosted team chat Progressive Web App** called **RosterChirp**.
|
||||||
|
|
||||||
|
It is a full-stack, single-container application that runs entirely inside Docker. Users install it on a private server and access it via a browser or as an installed PWA on desktop/mobile. It supports two deployment modes:
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
|---|---|
|
||||||
|
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
|
||||||
|
| `host` | Multi-tenant — one Postgres schema per tenant, provisioned at `{slug}.{HOST_DOMAIN}`. |
|
||||||
|
|
||||||
|
### Core philosophy
|
||||||
|
- Simple to self-host (one `docker compose up`)
|
||||||
|
- Works as an installed PWA on Android, iOS, and desktop Chrome/Edge
|
||||||
|
- Instant real-time messaging via WebSockets (Socket.io)
|
||||||
|
- Push notifications via Firebase Cloud Messaging (FCM), works when app is backgrounded
|
||||||
|
- Multi-tenant via Postgres schema isolation (not separate databases)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend runtime | Node.js 20 (Alpine) |
|
||||||
|
| Backend framework | Express.js |
|
||||||
|
| Real-time | Socket.io (server + client) |
|
||||||
|
| Database | PostgreSQL 16 via `pg` npm package |
|
||||||
|
| Auth | JWT in HTTP-only cookies + localStorage, `jsonwebtoken`, `bcryptjs` |
|
||||||
|
| Push notifications | Firebase Cloud Messaging (FCM) — Firebase Admin SDK (backend) + Firebase JS SDK (frontend) |
|
||||||
|
| Image processing | `sharp` (avatar/logo resizing) |
|
||||||
|
| Frontend framework | React 18 + Vite (PWA) |
|
||||||
|
| Frontend styling | Plain CSS with CSS custom properties (no Tailwind, no CSS modules) |
|
||||||
|
| Emoji picker | `@emoji-mart/react` + `@emoji-mart/data` |
|
||||||
|
| Markdown rendering | `marked` (for help modal) |
|
||||||
|
| Container | Docker multi-stage build (builder stage for Vite, runtime stage for Node) |
|
||||||
|
| Orchestration | `docker-compose.yaml` (selfhost) + `docker-compose.host.yaml` (multi-tenant) |
|
||||||
|
| Reverse proxy | Caddy (SSL termination) |
|
||||||
|
|
||||||
|
### Key npm packages (backend)
|
||||||
|
```
|
||||||
|
express, socket.io, pg, bcryptjs, jsonwebtoken,
|
||||||
|
cookie-parser, cors, multer, sharp,
|
||||||
|
firebase-admin, node-fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key npm packages (frontend)
|
||||||
|
```
|
||||||
|
react, react-dom, vite, socket.io-client,
|
||||||
|
@emoji-mart/react, @emoji-mart/data, marked,
|
||||||
|
firebase
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Project File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
rosterchirp/
|
||||||
|
├── CLAUDE.md
|
||||||
|
├── Dockerfile
|
||||||
|
├── build.sh # VERSION="${1:-X.Y.Z}" — bump here + both package.json files
|
||||||
|
├── docker-compose.yaml # selfhost
|
||||||
|
├── docker-compose.host.yaml # multi-tenant host mode
|
||||||
|
├── Caddyfile.example
|
||||||
|
├── .env.example
|
||||||
|
├── about.json.example
|
||||||
|
├── backend/
|
||||||
|
│ ├── package.json # version bump required
|
||||||
|
│ └── src/
|
||||||
|
│ ├── index.js # Express app, Socket.io, tenant middleware wiring
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ └── auth.js # JWT auth, teamManagerMiddleware
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── db.js # Postgres pool, query helpers, migrations, seeding
|
||||||
|
│ │ └── migrations/ # 001–008 SQL files, auto-applied on startup
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── auth.js # receives io
|
||||||
|
│ ├── groups.js # receives io
|
||||||
|
│ ├── messages.js # receives io
|
||||||
|
│ ├── usergroups.js # receives io
|
||||||
|
│ ├── schedule.js # receives io
|
||||||
|
│ ├── users.js
|
||||||
|
│ ├── settings.js
|
||||||
|
│ ├── push.js
|
||||||
|
│ ├── host.js # RosterChirp-Host control plane only
|
||||||
|
│ ├── about.js
|
||||||
|
│ └── help.js
|
||||||
|
└── frontend/
|
||||||
|
├── package.json # version bump required
|
||||||
|
├── vite.config.js
|
||||||
|
├── index.html # viewport: user-scalable=no (pinch handled via JS)
|
||||||
|
├── public/
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ ├── sw.js # service worker / FCM push
|
||||||
|
│ └── icons/
|
||||||
|
└── src/
|
||||||
|
├── App.jsx
|
||||||
|
├── main.jsx # pinch→font-scale handler, pull-to-refresh blocker, iOS keyboard fix
|
||||||
|
├── index.css # CSS vars, dark mode, --font-scale, mobile autofill fixes
|
||||||
|
├── contexts/
|
||||||
|
│ ├── AuthContext.jsx
|
||||||
|
│ ├── SocketContext.jsx # force transports: ['websocket']
|
||||||
|
│ └── ToastContext.jsx
|
||||||
|
├── pages/
|
||||||
|
│ ├── Chat.jsx # main shell, page routing, all socket wiring
|
||||||
|
│ ├── Login.jsx
|
||||||
|
│ ├── ChangePassword.jsx
|
||||||
|
│ ├── UserManagerPage.jsx
|
||||||
|
│ └── GroupManagerPage.jsx
|
||||||
|
└── components/
|
||||||
|
├── Sidebar.jsx # groupMessagesMode prop
|
||||||
|
├── ChatWindow.jsx
|
||||||
|
├── MessageInput.jsx # onTextChange prop, fixed font size (no --font-scale)
|
||||||
|
├── Message.jsx # fonts scaled via --font-scale
|
||||||
|
├── NavDrawer.jsx
|
||||||
|
├── SchedulePage.jsx # ~1600 lines, desktop+mobile views
|
||||||
|
├── MobileEventForm.jsx
|
||||||
|
├── Avatar.jsx # consistent colour algorithm — must match Sidebar + ChatWindow
|
||||||
|
├── PasswordInput.jsx
|
||||||
|
├── GroupInfoModal.jsx
|
||||||
|
├── ProfileModal.jsx # appearance tab: font-scale slider (saved), pinch is session-only
|
||||||
|
├── SettingsModal.jsx
|
||||||
|
├── BrandingModal.jsx
|
||||||
|
├── HostPanel.jsx
|
||||||
|
├── NewChatModal.jsx
|
||||||
|
├── UserFooter.jsx
|
||||||
|
├── GlobalBar.jsx
|
||||||
|
├── ImageLightbox.jsx
|
||||||
|
├── UserProfilePopup.jsx
|
||||||
|
├── AddChildAliasModal.jsx
|
||||||
|
├── ScheduleManagerModal.jsx
|
||||||
|
├── ColourPickerSheet.jsx
|
||||||
|
└── SupportModal.jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dead code (safe to delete)
|
||||||
|
- `frontend/src/pages/HostAdmin.jsx`
|
||||||
|
- `frontend/src/components/UserManagerModal.jsx`
|
||||||
|
- `frontend/src/components/GroupManagerModal.jsx`
|
||||||
|
- `frontend/src/components/MobileGroupManager.jsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4 — Database Architecture
|
||||||
|
|
||||||
|
### Connection pool (`db.js`)
|
||||||
|
```javascript
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'db',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
database: process.env.DB_NAME || 'rosterchirp',
|
||||||
|
user: process.env.DB_USER || 'rosterchirp',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query helpers
|
||||||
|
```javascript
|
||||||
|
query(schema, sql, params) // SET search_path then SELECT
|
||||||
|
queryOne(schema, sql, params) // returns first row or null
|
||||||
|
queryResult(schema, sql, params) // returns full result object
|
||||||
|
exec(schema, sql, params) // INSERT/UPDATE/DELETE
|
||||||
|
withTransaction(schema, async (client) => { ... })
|
||||||
|
```
|
||||||
|
|
||||||
|
`SET search_path TO {schema}` is called before every query. `assertSafeSchema(name)` validates all schema names against `/^[a-z_][a-z0-9_]*$/`.
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` are applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
|
||||||
|
|
||||||
|
Current migrations: 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users) → 007 (FCM push) → 008 (rebrand)
|
||||||
|
|
||||||
|
### Seeding order
|
||||||
|
`seedSettings → seedEventTypes → seedAdmin → seedUserGroups`
|
||||||
|
|
||||||
|
All seed functions use `ON CONFLICT DO NOTHING`.
|
||||||
|
|
||||||
|
### Core tables (PostgreSQL — schema-qualified at query time)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'suspended'
|
||||||
|
is_default_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
must_change_password BOOLEAN DEFAULT TRUE,
|
||||||
|
avatar TEXT,
|
||||||
|
about_me TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
hide_admin_tag BOOLEAN DEFAULT FALSE,
|
||||||
|
allow_dm INTEGER DEFAULT 1,
|
||||||
|
last_online TIMESTAMPTZ,
|
||||||
|
help_dismissed BOOLEAN DEFAULT FALSE,
|
||||||
|
date_of_birth DATE,
|
||||||
|
phone TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
|
||||||
|
groups (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT DEFAULT 'public',
|
||||||
|
owner_id INTEGER REFERENCES users(id),
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
is_readonly BOOLEAN DEFAULT FALSE,
|
||||||
|
is_direct BOOLEAN DEFAULT FALSE,
|
||||||
|
is_managed BOOLEAN DEFAULT FALSE, -- managed private groups (Group Messages mode)
|
||||||
|
direct_peer1_id INTEGER,
|
||||||
|
direct_peer2_id INTEGER,
|
||||||
|
track_availability BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
|
||||||
|
group_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(group_id, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
messages (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
content TEXT,
|
||||||
|
type TEXT DEFAULT 'text', -- 'text' | 'system'
|
||||||
|
image_url TEXT,
|
||||||
|
reply_to_id INTEGER REFERENCES messages(id),
|
||||||
|
is_deleted BOOLEAN DEFAULT FALSE,
|
||||||
|
is_readonly BOOLEAN DEFAULT FALSE,
|
||||||
|
link_preview JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
|
||||||
|
reactions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(message_id, user_id, emoji)
|
||||||
|
)
|
||||||
|
|
||||||
|
notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
message_id INTEGER,
|
||||||
|
group_id INTEGER,
|
||||||
|
from_user_id INTEGER,
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
|
||||||
|
active_sessions (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device TEXT NOT NULL DEFAULT 'desktop', -- 'mobile' | 'desktop'
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
ua TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, device)
|
||||||
|
)
|
||||||
|
-- One session per device type per user. New login on same device displaces old session.
|
||||||
|
-- Displaced socket receives 'session:displaced' event.
|
||||||
|
|
||||||
|
push_subscriptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device TEXT DEFAULT 'desktop',
|
||||||
|
fcm_token TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, device)
|
||||||
|
)
|
||||||
|
|
||||||
|
settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
-- Feature flag keys: feature_branding ('true'/'false'), feature_group_manager,
|
||||||
|
-- feature_schedule_manager, app_type ('RosterChirp-Chat'/'RosterChirp-Brand'/'RosterChirp-Team')
|
||||||
|
|
||||||
|
user_group_names (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_groups (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
colour TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
|
||||||
|
user_group_members (
|
||||||
|
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_group_id, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
group_user_groups (
|
||||||
|
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (group_id, user_group_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
location TEXT,
|
||||||
|
start_at TIMESTAMPTZ NOT NULL,
|
||||||
|
end_at TIMESTAMPTZ NOT NULL,
|
||||||
|
all_day BOOLEAN DEFAULT FALSE,
|
||||||
|
is_public BOOLEAN DEFAULT TRUE,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
event_type_id INTEGER REFERENCES event_types(id),
|
||||||
|
recurrence_rule JSONB,
|
||||||
|
track_availability BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
|
||||||
|
event_types (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
colour TEXT NOT NULL DEFAULT '#1a73e8',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
|
||||||
|
event_availability (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL, -- 'going' | 'maybe' | 'not_going'
|
||||||
|
note TEXT,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(event_id, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
tenants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
schema_name TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
custom_domain TEXT,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
plan TEXT DEFAULT 'team',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5 — Multi-Tenant Architecture (Host Mode)
|
||||||
|
|
||||||
|
### Tenant resolution
|
||||||
|
`tenantMiddleware` in `index.js` sets `req.schema` from the HTTP `Host` header before any route runs:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Subdomain tenants: {slug}.{HOST_DOMAIN} → schema 'tenant_{slug}'
|
||||||
|
// Custom domains: looked up in tenants table custom_domain column
|
||||||
|
// Host admin: HOST_DOMAIN itself → schema 'public'
|
||||||
|
const tenantDomainCache = new Map(); // in-process cache, cleared on tenant update
|
||||||
|
```
|
||||||
|
|
||||||
|
### Socket room naming (tenant-isolated)
|
||||||
|
All socket rooms are prefixed with the tenant schema:
|
||||||
|
```javascript
|
||||||
|
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||||||
|
// e.g. R('tenant_acme', 'group', 42) → 'tenant_acme:group:42'
|
||||||
|
// Room types: group:{id}, user:{id}, schema:all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Online user tracking
|
||||||
|
```javascript
|
||||||
|
const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
|
||||||
|
// Key includes schema to prevent cross-tenant ID collisions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 6 — Auth & Session System
|
||||||
|
|
||||||
|
- JWT stored in **HTTP-only cookie** (`token`) AND `localStorage` (for PWA/mobile fallback)
|
||||||
|
- `authMiddleware` in `middleware/auth.js` — verifies JWT, attaches `req.user`
|
||||||
|
- `teamManagerMiddleware` — checks if user is a team manager (role-based feature access)
|
||||||
|
- **Per-device sessions**: `active_sessions` PK is `(user_id, device)` — logging in on mobile doesn't kick out desktop
|
||||||
|
- Device: `mobile` if `/Mobile|Android|iPhone/i.test(ua)`, else `desktop`
|
||||||
|
- `must_change_password = true` redirects to `/change-password` after login
|
||||||
|
- `ADMPW_RESET=true` env var resets default admin password on container start
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 7 — Real-time Architecture (Socket.io)
|
||||||
|
|
||||||
|
### Connection
|
||||||
|
- Socket auth: JWT in `socket.handshake.auth.token`
|
||||||
|
- On connect: user joins `R(schema, 'group', id)` for all their groups, and `R(schema, 'user', userId)` for direct notifications, and `R(schema, 'schema', 'all')` for tenant-wide broadcasts
|
||||||
|
|
||||||
|
### Routes that receive `io`
|
||||||
|
```javascript
|
||||||
|
// All of these are called as: require('./routes/foo')(io)
|
||||||
|
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key socket events (server → client)
|
||||||
|
| Event | When |
|
||||||
|
|---|---|
|
||||||
|
| `message:new` | new message in a group |
|
||||||
|
| `message:deleted` | soft delete |
|
||||||
|
| `reaction:updated` | reaction toggled |
|
||||||
|
| `typing:start` / `typing:stop` | typing indicator |
|
||||||
|
| `notification:new` | mention or private message |
|
||||||
|
| `group:updated` | group settings changed |
|
||||||
|
| `group:removed` | user removed from group |
|
||||||
|
| `user:online` / `user:offline` | presence change |
|
||||||
|
| `users:online` | full online user list (on request) |
|
||||||
|
| `session:displaced` | same device logged in elsewhere |
|
||||||
|
| `schedule:event-created/updated/deleted` | schedule changes |
|
||||||
|
|
||||||
|
### Reconnect strategy (SocketContext.jsx)
|
||||||
|
```javascript
|
||||||
|
const socket = io({ transports: ['websocket'] }); // websocket only — no polling
|
||||||
|
// reconnectionDelay: 500, reconnectionDelayMax: 3000, timeout: 8000
|
||||||
|
// visibilitychange → visible: call socket.connect() if disconnected
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 8 — FCM Push Notifications
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
```
|
||||||
|
Frontend (browser/PWA)
|
||||||
|
└─ Chat.jsx
|
||||||
|
├─ GET /api/push/firebase-config → fetches SDK config
|
||||||
|
├─ Initialises Firebase JS SDK + getMessaging()
|
||||||
|
├─ getToken(messaging, { vapidKey }) → FCM token
|
||||||
|
└─ POST /api/push/subscribe → registers in push_subscriptions
|
||||||
|
|
||||||
|
Backend (push.js)
|
||||||
|
├─ sendPushToUser(schema, userId, payload) → called from messages.js (primary)
|
||||||
|
│ and index.js socket handler (fallback)
|
||||||
|
└─ Firebase Admin SDK → Google FCM servers → device
|
||||||
|
```
|
||||||
|
|
||||||
|
### Message payload
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
token: sub.fcm_token,
|
||||||
|
notification: { title, body },
|
||||||
|
data: { url: '/', groupId: '42' },
|
||||||
|
android: { priority: 'high', notification: { sound: 'default' } },
|
||||||
|
webpush: { headers: { Urgency: 'high' }, fcm_options: { link: url } },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Push trigger logic (messages.js)
|
||||||
|
- Frontend sends messages via `POST /api/messages/group/:groupId` (REST), not socket
|
||||||
|
- **Push must be fired from messages.js**, not just socket handler
|
||||||
|
- Private group: push to all `group_members` except sender
|
||||||
|
- Public group: push to all `DISTINCT user_id FROM push_subscriptions WHERE user_id != sender`
|
||||||
|
- Image messages: body `'📷 Image'`
|
||||||
|
|
||||||
|
### Stale token cleanup
|
||||||
|
`sendPushToUser` catches FCM errors and deletes the `push_subscriptions` row for:
|
||||||
|
`messaging/registration-token-not-registered`, `messaging/invalid-registration-token`, `messaging/invalid-argument`
|
||||||
|
|
||||||
|
### Required env vars
|
||||||
|
```
|
||||||
|
FIREBASE_API_KEY=
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_APP_ID=
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
FIREBASE_VAPID_KEY= # Web Push certificate public key (Cloud Messaging tab)
|
||||||
|
FIREBASE_SERVICE_ACCOUNT= # Full service account JSON, stringified (backend only)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 9 — API Routes
|
||||||
|
|
||||||
|
All routes require `authMiddleware` except login/health.
|
||||||
|
|
||||||
|
### Auth (`/api/auth`)
|
||||||
|
- `POST /login`, `POST /logout`, `POST /change-password`, `GET /me`
|
||||||
|
|
||||||
|
### Users (`/api/users`)
|
||||||
|
- `GET /` — admin: full user list
|
||||||
|
- `POST /` — admin: create user
|
||||||
|
- `PATCH /:id` — admin: update role/status/password
|
||||||
|
- `PATCH /me/profile` — own profile (displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone)
|
||||||
|
- `POST /me/avatar` — multipart: resize to 200×200 webp
|
||||||
|
- `GET /check-display-name?name=`
|
||||||
|
|
||||||
|
### Groups (`/api/groups`)
|
||||||
|
- `GET /` — returns `{ publicGroups, privateGroups }` with last_message, peer data for DMs
|
||||||
|
- `POST /` — create group or DM
|
||||||
|
- `PATCH /:id`, `DELETE /:id`
|
||||||
|
- `POST /:id/members`, `DELETE /:id/members/:userId`, `GET /:id/members`
|
||||||
|
- `POST /:id/custom-name`, `DELETE /:id/custom-name`
|
||||||
|
|
||||||
|
### Messages (`/api/messages`)
|
||||||
|
- `GET /?groupId=&before=&limit=` — 50 per page, cursor-based
|
||||||
|
- `POST /group/:groupId` — send message (REST path, fires push)
|
||||||
|
- `POST /image` — image upload
|
||||||
|
|
||||||
|
### User Groups (`/api/usergroups`)
|
||||||
|
- CRUD for user groups (team roster groupings), member management
|
||||||
|
|
||||||
|
### Schedule (`/api/schedule`)
|
||||||
|
- CRUD for events, event types, availability tracking
|
||||||
|
- `GET /events` — with date range, keyword filter, type filter, availability filter
|
||||||
|
- `POST /events/:id/availability` — set own availability
|
||||||
|
- `GET /events/:id/availability` — get all availability for an event
|
||||||
|
|
||||||
|
### Settings (`/api/settings`)
|
||||||
|
- `GET /`, `PATCH /`, `POST /logo`, `POST /icon/:key`
|
||||||
|
|
||||||
|
### Push (`/api/push`)
|
||||||
|
- `GET /firebase-config` — returns FCM SDK config
|
||||||
|
- `POST /subscribe` — save FCM token
|
||||||
|
- `GET /debug` — admin: list tokens + firebase status
|
||||||
|
- `POST /test` — send test push to own device
|
||||||
|
|
||||||
|
### Host (`/api/host`) — host mode only
|
||||||
|
- Tenant provisioning, plan management, host admin panel
|
||||||
|
|
||||||
|
### About (`/api/about`), Help (`/api/help`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 10 — Frontend Architecture
|
||||||
|
|
||||||
|
### Page navigation (Chat.jsx)
|
||||||
|
`page` state: `'chat'` | `'groupmessages'` | `'schedule'` | `'users'` | `'groups'` | `'hostpanel'`
|
||||||
|
|
||||||
|
**Rule:** Every page navigation must call `setActiveGroupId(null)` and `setChatHasText(false)`.
|
||||||
|
|
||||||
|
### Group Messages vs Messages (Sidebar)
|
||||||
|
- `groupMessagesMode={false}` → public groups + non-managed private groups
|
||||||
|
- `groupMessagesMode={true}` → only `is_managed` private groups
|
||||||
|
|
||||||
|
### Unsaved text guard (Chat.jsx → ChatWindow.jsx → MessageInput.jsx)
|
||||||
|
- `MessageInput` fires `onTextChange(val)` on every keystroke and after send
|
||||||
|
- `ChatWindow` converts to boolean: `onHasTextChange?.(!!val.trim())`
|
||||||
|
- `Chat.jsx` stores as `chatHasText`; `selectGroup()` shows `window.confirm` if switching with unsaved text
|
||||||
|
- `MessageInput` resets all state on `group?.id` change via `useEffect`
|
||||||
|
|
||||||
|
### Font scale system
|
||||||
|
- CSS var `--font-scale` on `<html>` element (default `1`, range `0.8`–`2.0`)
|
||||||
|
- **Message fonts** use `calc(Xrem * var(--font-scale))` — they scale
|
||||||
|
- **MessageInput font** is fixed (`0.875rem`) — it does NOT scale
|
||||||
|
- **Slider** (ProfileModal appearance tab) is the saved setting — persisted to `localStorage`
|
||||||
|
- **Pinch zoom** (main.jsx touch handler) is session-only — updates `--font-scale` but does NOT write to localStorage
|
||||||
|
- On startup, `--font-scale` is initialised from the saved localStorage value
|
||||||
|
|
||||||
|
### Avatar colour algorithm
|
||||||
|
Must be **identical** across `Avatar.jsx`, `Sidebar.jsx`, `ChatWindow.jsx`:
|
||||||
|
```javascript
|
||||||
|
const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
|
||||||
|
const bg = AVATAR_COLORS[(user.name || '').charCodeAt(0) % AVATAR_COLORS.length];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notification rules (group member changes, usergroups.js)
|
||||||
|
- 1 user added/removed → named system message: `"{Name} has joined/been removed from the conversation."`
|
||||||
|
- 2+ users added/removed → generic: `"N new members have joined/been removed from the conversation."`
|
||||||
|
|
||||||
|
### User deletion (v0.11.11+)
|
||||||
|
Email → `deleted_{id}@deleted`, name → `'Deleted User'`, all messages `is_deleted=TRUE`, DMs `is_readonly=TRUE`, sessions/subscriptions/availability purged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 11 — Schedule / Events
|
||||||
|
|
||||||
|
- All date/time stored as `TIMESTAMPTZ`
|
||||||
|
- `buildISO(date, time)` — builds timezone-aware ISO string from date + HH:MM input
|
||||||
|
- `toTimeIn(iso)` — extracts exact HH:MM (no rounding) for edit forms
|
||||||
|
- `roundUpToHalfHour()` — default start time for new events
|
||||||
|
- New events cannot have a start date/time in the past
|
||||||
|
- Recurring events: `expandRecurringEvent` returns occurrences within requested range only
|
||||||
|
- Keyword filter: unquoted = `\bterm` (prefix match), quoted = `\bterm\b` (exact word)
|
||||||
|
- Type filter does NOT shift date window to today (unlike keyword/availability filters)
|
||||||
|
- Clearing keyword also resets `filterFromDate`
|
||||||
|
|
||||||
|
Both `SchedulePage.jsx` and `MobileEventForm.jsx` maintain their own copies of the time utilities (`roundUpToHalfHour`, `parseTypedTime`, `fmt12`, `toTimeIn`, `buildISO`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 12 — CSS Design System
|
||||||
|
|
||||||
|
### Variables (`:root` — light mode)
|
||||||
|
```css
|
||||||
|
--primary: #1a73e8;
|
||||||
|
--primary-dark: #1557b0;
|
||||||
|
--primary-light: #e8f0fe;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-variant: #f8f9fa;
|
||||||
|
--background: #f1f3f4;
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--text-primary: #202124;
|
||||||
|
--text-secondary: #5f6368;
|
||||||
|
--text-tertiary: #9aa0a6;
|
||||||
|
--error: #d93025;
|
||||||
|
--success: #188038;
|
||||||
|
--bubble-out: #1a73e8;
|
||||||
|
--bubble-in: #f1f3f4;
|
||||||
|
--radius: 8px;
|
||||||
|
--font: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
--font-scale: 1; /* adjusted by pinch or slider */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark mode (`[data-theme="dark"]`)
|
||||||
|
```css
|
||||||
|
--primary: #4d8fd4;
|
||||||
|
--primary-light: #1a2d4a;
|
||||||
|
--surface: #1e1e2e;
|
||||||
|
--surface-variant: #252535;
|
||||||
|
--background: #13131f;
|
||||||
|
--border: #2e2e45;
|
||||||
|
--text-primary: #e2e2f0;
|
||||||
|
--text-secondary: #9898b8;
|
||||||
|
--text-tertiary: #606080; /* exactly 6 hex digits — a common typo is 7 */
|
||||||
|
--bubble-out: #4d8fd4;
|
||||||
|
--bubble-in: #252535;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile input fixes
|
||||||
|
```css
|
||||||
|
/* Prevent iOS zoom on input focus (requires font-size >= 16px) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
input:focus, textarea:focus, select:focus { font-size: 16px !important; }
|
||||||
|
}
|
||||||
|
/* Autofill styling */
|
||||||
|
input:-webkit-autofill {
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px var(--surface) inset !important;
|
||||||
|
-webkit-text-fill-color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- Desktop: sidebar (320px fixed) + chat area (flex-1)
|
||||||
|
- Mobile (≤768px): sidebar and chat stack — one visible at a time
|
||||||
|
- `--visual-viewport-height` and `--visual-viewport-offset` CSS vars exposed by main.jsx for iOS keyboard handling
|
||||||
|
- `.keyboard-open` class toggled on `<html>` when iOS keyboard is visible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 13 — Docker & Deployment
|
||||||
|
|
||||||
|
### Dockerfile (multi-stage)
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY frontend/package*.json ./frontend/
|
||||||
|
RUN cd frontend && npm install
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
RUN cd frontend && npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY backend/ ./
|
||||||
|
COPY --from=builder /app/frontend/dist ./public
|
||||||
|
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version bump — all three locations
|
||||||
|
```
|
||||||
|
backend/package.json "version": "X.Y.Z"
|
||||||
|
frontend/package.json "version": "X.Y.Z"
|
||||||
|
build.sh VERSION="${1:-X.Y.Z}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables (key ones)
|
||||||
|
```
|
||||||
|
APP_TYPE=selfhost|host
|
||||||
|
HOST_DOMAIN= # host mode only
|
||||||
|
HOST_ADMIN_KEY= # host mode only
|
||||||
|
JWT_SECRET=
|
||||||
|
DB_HOST=db # set to 'pgbouncer' after Phase 1 scaling
|
||||||
|
DB_NAME=rosterchirp
|
||||||
|
DB_USER=rosterchirp
|
||||||
|
DB_PASSWORD= # avoid ! (shell interpolation issue in docker-compose)
|
||||||
|
ADMIN_EMAIL=
|
||||||
|
ADMIN_NAME=
|
||||||
|
ADMIN_PASS=
|
||||||
|
ADMPW_RESET=true|false
|
||||||
|
APP_NAME=rosterchirp
|
||||||
|
USER_PASS= # default password for bulk-created users
|
||||||
|
DEFCHAT_NAME=General Chat
|
||||||
|
FIREBASE_API_KEY=
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_APP_ID=
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
FIREBASE_VAPID_KEY=
|
||||||
|
FIREBASE_SERVICE_ACCOUNT= # stringified JSON, no newlines
|
||||||
|
VAPID_PUBLIC= # legacy, auto-generated, no longer used for push delivery
|
||||||
|
VAPID_PRIVATE=
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 14 — Scale Architecture (Planned)
|
||||||
|
|
||||||
|
### Phase 1 — PgBouncer (zero code changes)
|
||||||
|
Add PgBouncer service to `docker-compose.host.yaml`. Point `DB_HOST=pgbouncer`. Increase Node pool `max` to 100. Use `POOL_MODE=transaction`. Eliminates the 20-connection bottleneck.
|
||||||
|
|
||||||
|
### Phase 2 — Redis (horizontal scaling)
|
||||||
|
Required for multiple Node instances:
|
||||||
|
1. `@socket.io/redis-adapter` — cross-instance socket fan-out
|
||||||
|
2. Replace `onlineUsers` Map with Redis `SADD`/`SREM` presence keys (`presence:{schema}:{userId}`)
|
||||||
|
3. Replace `tenantDomainCache` Map with Redis hash + TTL
|
||||||
|
4. Move uploads to Cloudflare R2 (S3-compatible) — `@aws-sdk/client-s3`
|
||||||
|
5. Force WebSocket transport only (eliminates polling sticky-session concern)
|
||||||
|
|
||||||
|
**Note on Phase 2:** `SET search_path` per query is safe with PgBouncer in transaction mode. Do NOT use `LISTEN/NOTIFY` or session-level state through PgBouncer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 15 — Known Gotchas & Decisions
|
||||||
|
|
||||||
|
| Gotcha | Solution |
|
||||||
|
|---|---|
|
||||||
|
| Multi-tenant schema isolation | Every query must go through `query(schema, ...)` — never raw `pool.query` |
|
||||||
|
| `assertSafeSchema()` | Always validate schema names before use — injection risk |
|
||||||
|
| Socket room names include schema | `R(schema, 'group', id)` not bare `group:{id}` — cross-tenant leakage otherwise |
|
||||||
|
| `onlineUsers` key is `${schema}:${userId}` | Two tenants can share the same integer user ID |
|
||||||
|
| FCM push fired from messages.js REST route | Frontend uses REST POST, not socket, for sending messages |
|
||||||
|
| Pinch zoom is session-only | Remove `localStorage.setItem` from touchend — slider is the saved setting |
|
||||||
|
| MessageInput font is fixed | Do not apply `--font-scale` to `.msg-input` font-size |
|
||||||
|
| iOS keyboard layout | Use `--visual-viewport-height` CSS var, not `100vh`, for the chat layout height |
|
||||||
|
| Avatar colour algorithm | Must be identical in Avatar.jsx, Sidebar.jsx, and ChatWindow.jsx |
|
||||||
|
| `is_managed` groups | Managed private groups appear in Group Messages view, not regular Messages view |
|
||||||
|
| Migrations are SQL files | Not try/catch ALTER TABLE — numbered SQL files in `migrations/` applied in order |
|
||||||
|
| DB_PASSWORD must not contain `!` | Shell interpolation breaks docker-compose env parsing |
|
||||||
|
| Routes accept `io` as parameter | `module.exports = (io) => router` — not default export |
|
||||||
|
| `session:displaced` socket event | Sent to the old socket when a new login displaces a session on the same device type |
|
||||||
|
| `help.md` is NOT in the volume path | Must be at `backend/src/data/help.md` — not in `/app/data/` which is volume-mounted |
|
||||||
|
| Dark mode `--text-tertiary` | Exactly 6 hex digits: `#606080` not `#6060808` |
|
||||||
|
| Web Share API for mobile file download | Use `navigator.share({ files: [...] })` on mobile; fall back to `a.click()` download on desktop |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 16 — Features Checklist
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
- [x] Text messages with URL auto-linking and @mentions
|
||||||
|
- [x] Image upload + lightbox
|
||||||
|
- [x] Link preview cards (og: meta, server-side fetch)
|
||||||
|
- [x] Reply-to with quoted preview
|
||||||
|
- [x] Emoji reactions (quick bar + full picker)
|
||||||
|
- [x] Message soft-delete
|
||||||
|
- [x] Typing indicator
|
||||||
|
- [x] Date separators, consecutive-message collapsing
|
||||||
|
- [x] System messages
|
||||||
|
- [x] Emoji-only messages render larger
|
||||||
|
- [x] Infinite scroll / cursor-based pagination (50 per page)
|
||||||
|
|
||||||
|
### Groups & DMs
|
||||||
|
- [x] Public channels, private groups, read-only channels
|
||||||
|
- [x] Managed private groups (Group Messages view)
|
||||||
|
- [x] User-to-user direct messages
|
||||||
|
- [x] Per-user custom group display name
|
||||||
|
- [x] User groups (team roster groupings) with colour coding
|
||||||
|
- [x] Group availability tracking (events)
|
||||||
|
|
||||||
|
### Users & Profiles
|
||||||
|
- [x] Display name, avatar, about me, date of birth, phone
|
||||||
|
- [x] Hide admin tag option
|
||||||
|
- [x] Allow/block DMs toggle
|
||||||
|
- [x] Child/alias user accounts (`AddChildAliasModal`)
|
||||||
|
- [x] Bulk user import via CSV
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
- [x] Settings modal: app name, logo, PWA icons
|
||||||
|
- [x] Branding modal (Brand+ plan)
|
||||||
|
- [x] User manager (full page): create, edit, suspend, reset password, bulk import
|
||||||
|
- [x] Group manager (full page): create groups, manage members, assign user groups
|
||||||
|
- [x] Schedule manager modal: event types with custom colours
|
||||||
|
- [x] Admin can delete any message
|
||||||
|
|
||||||
|
### Schedule
|
||||||
|
- [x] Event creation (one-time + recurring)
|
||||||
|
- [x] Event types with colour coding
|
||||||
|
- [x] Availability tracking (Going / Maybe / Not Going)
|
||||||
|
- [x] Download availability list (Web Share API on mobile, download link on desktop)
|
||||||
|
- [x] Keyword filter (prefix and exact-word modes)
|
||||||
|
- [x] Type filter, date range filter
|
||||||
|
- [x] Desktop and mobile views (separate implementations)
|
||||||
|
|
||||||
|
### PWA / Notifications
|
||||||
|
- [x] Installable PWA (manifest, service worker, icons)
|
||||||
|
- [x] FCM push notifications (Android working; iOS in progress)
|
||||||
|
- [x] App badge on home screen icon
|
||||||
|
- [x] Page title unread count `(N) App Name`
|
||||||
|
- [x] Per-conversation notification grouping
|
||||||
|
|
||||||
|
### UX
|
||||||
|
- [x] Light / dark mode (CSS vars, saved localStorage)
|
||||||
|
- [x] Font scale slider (saved setting) + pinch zoom (session only)
|
||||||
|
- [x] Mobile-responsive layout
|
||||||
|
- [x] Pull-to-refresh blocked in PWA standalone mode
|
||||||
|
- [x] iOS keyboard layout fix (`--visual-viewport-height`)
|
||||||
|
- [x] Getting Started help modal
|
||||||
|
- [x] About modal, Support modal
|
||||||
|
- [x] User profile popup (click any avatar)
|
||||||
|
- [x] NavDrawer (hamburger menu)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 17 — One-Shot Prompt (Copy-Paste to Start)
|
||||||
|
|
||||||
|
```
|
||||||
|
Build a self-hosted team chat PWA called "RosterChirp". Single Docker container.
|
||||||
|
Supports selfhost (single tenant) and host (multi-tenant via Postgres schema per tenant) modes.
|
||||||
|
|
||||||
|
STACK: Node 20 + Express + Socket.io + PostgreSQL 16 (pg npm package) + JWT
|
||||||
|
(HTTP-only cookie + localStorage) + bcryptjs + React 18 + Vite.
|
||||||
|
Push via Firebase Cloud Messaging (firebase-admin backend, firebase frontend SDK).
|
||||||
|
Images via multer + sharp. Frontend: plain CSS with CSS custom properties, no Tailwind.
|
||||||
|
|
||||||
|
MULTI-TENANT: tenantMiddleware sets req.schema from Host header. assertSafeSchema()
|
||||||
|
validates all schema names. Socket rooms prefixed: `${schema}:${type}:${id}`.
|
||||||
|
onlineUsers Map key is `${schema}:${userId}` to prevent cross-tenant ID collisions.
|
||||||
|
Every DB query calls SET search_path TO {schema} first.
|
||||||
|
|
||||||
|
MIGRATIONS: Numbered SQL files in backend/src/models/migrations/ (001, 002, ...).
|
||||||
|
Auto-applied on startup via runMigrations(schema). Never edit applied migrations.
|
||||||
|
|
||||||
|
ROUTES accept io as parameter: module.exports = (io) => router
|
||||||
|
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
|
||||||
|
|
||||||
|
KEY FEATURES:
|
||||||
|
- Public/private/readonly channels, managed private groups (Group Messages view),
|
||||||
|
user-to-user DMs, @mentions, emoji reactions, reply-to, image upload, link previews,
|
||||||
|
soft-delete, typing indicator, unread badges, page title (N) count
|
||||||
|
- User groups (team roster groupings) with colour coding
|
||||||
|
- Schedule: events, event types, availability tracking, recurring events
|
||||||
|
- Font scale: --font-scale CSS var on <html>. Message fonts scale with it. MessageInput
|
||||||
|
font is FIXED (no --font-scale). Slider in ProfileModal = saved setting (localStorage).
|
||||||
|
Pinch zoom = session only (touchend must NOT write to localStorage).
|
||||||
|
- FCM push: fired from messages.js REST route (not socket handler). sendPushToUser helper.
|
||||||
|
Stale token cleanup on FCM error codes.
|
||||||
|
- Avatar colour: AVATAR_COLORS array, charCodeAt(0) % length. Must be identical in
|
||||||
|
Avatar.jsx, Sidebar.jsx, ChatWindow.jsx.
|
||||||
|
- User deletion: email scrubbed, messages nulled, DMs set readonly, sessions purged.
|
||||||
|
- Web Share API for mobile file downloads; a.click() fallback for desktop.
|
||||||
|
|
||||||
|
GOTCHAS:
|
||||||
|
- DB_PASSWORD must not contain '!' (shell interpolation in docker-compose)
|
||||||
|
- dark mode --text-tertiary must be exactly 6 hex digits: #606080
|
||||||
|
- help.md at backend/src/data/help.md (NOT /app/data — volume-mounted, shadows files)
|
||||||
|
- Session displaced: socket receives 'session:displaced' when new login takes device slot
|
||||||
|
- iOS keyboard: use --visual-viewport-height CSS var (not 100vh) for chat layout height
|
||||||
|
- Routes that emit socket events receive io as first argument, not default export
|
||||||
|
```
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.42",
|
"version": "0.13.1",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -249,7 +249,21 @@ async function seedUserGroups(schema) {
|
|||||||
const existing = await queryOne(schema,
|
const existing = await queryOne(schema,
|
||||||
'SELECT id FROM user_groups WHERE name = $1', [name]
|
'SELECT id FROM user_groups WHERE name = $1', [name]
|
||||||
);
|
);
|
||||||
if (existing) continue;
|
if (existing) {
|
||||||
|
// Auto-configure feature settings if not already set
|
||||||
|
if (name === 'Players') {
|
||||||
|
await exec(schema,
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('feature_players_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||||
|
[existing.id.toString()]
|
||||||
|
);
|
||||||
|
} else if (name === 'Parents') {
|
||||||
|
await exec(schema,
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('feature_guardians_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||||
|
[existing.id.toString()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Create the managed DM chat group first
|
// Create the managed DM chat group first
|
||||||
const gr = await queryResult(schema,
|
const gr = await queryResult(schema,
|
||||||
@@ -259,17 +273,31 @@ async function seedUserGroups(schema) {
|
|||||||
const dmGroupId = gr.rows[0].id;
|
const dmGroupId = gr.rows[0].id;
|
||||||
|
|
||||||
// Create the user group linked to the DM group
|
// Create the user group linked to the DM group
|
||||||
await exec(schema,
|
const ugr = await queryResult(schema,
|
||||||
'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING',
|
'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING id',
|
||||||
[name, dmGroupId]
|
[name, dmGroupId]
|
||||||
);
|
);
|
||||||
|
const ugId = ugr.rows[0]?.id;
|
||||||
console.log(`[DB:${schema}] Default user group created: ${name}`);
|
console.log(`[DB:${schema}] Default user group created: ${name}`);
|
||||||
|
|
||||||
|
// Auto-configure feature settings for players/parents groups
|
||||||
|
if (ugId && name === 'Players') {
|
||||||
|
await exec(schema,
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('feature_players_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||||
|
[ugId.toString()]
|
||||||
|
);
|
||||||
|
} else if (ugId && name === 'Parents') {
|
||||||
|
await exec(schema,
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('feature_guardians_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||||
|
[ugId.toString()]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedAdmin(schema) {
|
async function seedAdmin(schema) {
|
||||||
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
||||||
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local';
|
const adminEmail = (strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local').toLowerCase();
|
||||||
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
|
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
|
||||||
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
||||||
const pwReset = process.env.ADMPW_RESET === 'true';
|
const pwReset = process.env.ADMPW_RESET === 'true';
|
||||||
|
|||||||
41
backend/src/models/migrations/015_minor_age_protection.sql
Normal file
41
backend/src/models/migrations/015_minor_age_protection.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- 015_minor_age_protection.sql
|
||||||
|
-- Adds tables and columns for Guardian Only and Mixed Age login type modes.
|
||||||
|
|
||||||
|
-- 1. guardian_approval_required on users (Mixed Age: minor needs approval before unsuspend)
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_approval_required BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- 2. guardian_aliases — children as name aliases under a guardian (Guardian Only mode)
|
||||||
|
CREATE TABLE IF NOT EXISTS guardian_aliases (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
guardian_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
date_of_birth DATE,
|
||||||
|
avatar TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guardian_aliases_guardian ON guardian_aliases(guardian_id);
|
||||||
|
|
||||||
|
-- 3. alias_group_members — links guardian aliases to user groups (e.g. players group)
|
||||||
|
CREATE TABLE IF NOT EXISTS alias_group_members (
|
||||||
|
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_group_id, alias_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. event_alias_availability — availability responses for guardian aliases
|
||||||
|
CREATE TABLE IF NOT EXISTS event_alias_availability (
|
||||||
|
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
|
||||||
|
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
|
||||||
|
note VARCHAR(20),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (event_id, alias_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_alias_availability_event ON event_alias_availability(event_id);
|
||||||
16
backend/src/models/migrations/016_guardian_partners.sql
Normal file
16
backend/src/models/migrations/016_guardian_partners.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- 016_guardian_partners.sql
|
||||||
|
-- Partner/spouse relationship between guardians.
|
||||||
|
-- Partners share the same child alias list (both can manage it) and can
|
||||||
|
-- respond to events on behalf of each other within shared user groups.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS guardian_partners (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id_1 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
user_id_2 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id_1, user_id_2),
|
||||||
|
CHECK (user_id_1 < user_id_2)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user1 ON guardian_partners(user_id_1);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user2 ON guardian_partners(user_id_2);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 017_partner_respond_separately.sql
|
||||||
|
-- Adds respond_separately flag to guardian_partners.
|
||||||
|
-- When true, linked partners can each respond to events on behalf of children
|
||||||
|
-- in the shared alias list, but cannot respond on behalf of each other.
|
||||||
|
|
||||||
|
ALTER TABLE guardian_partners ADD COLUMN IF NOT EXISTS respond_separately BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -12,7 +12,7 @@ module.exports = function(io) {
|
|||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
const { email, password, rememberMe } = req.body;
|
const { email, password, rememberMe } = req.body;
|
||||||
try {
|
try {
|
||||||
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE email = $1', [email]);
|
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE LOWER(email) = LOWER($1)', [email]);
|
||||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
if (user.status === 'suspended') {
|
if (user.status === 'suspended') {
|
||||||
@@ -62,6 +62,7 @@ module.exports = function(io) {
|
|||||||
router.post('/logout', authMiddleware, async (req, res) => {
|
router.post('/logout', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await clearActiveSession(req.schema, req.user.id, req.device);
|
await clearActiveSession(req.schema, req.user.id, req.device);
|
||||||
|
await exec(req.schema, 'DELETE FROM push_subscriptions WHERE user_id=$1 AND device=$2', [req.user.id, req.device]);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ const router = express.Router();
|
|||||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
|
async function getLoginType(schema) {
|
||||||
|
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||||
|
return row?.value || 'all_ages';
|
||||||
|
}
|
||||||
|
|
||||||
function deleteImageFile(imageUrl) {
|
function deleteImageFile(imageUrl) {
|
||||||
if (!imageUrl) return;
|
if (!imageUrl) return;
|
||||||
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
|
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
|
||||||
@@ -67,7 +72,7 @@ router.get('/', authMiddleware, async (req, res) => {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const privateGroupsRaw = await query(req.schema, `
|
const privateGroupsRaw = await query(req.schema, `
|
||||||
SELECT g.*, u.name AS owner_name,
|
SELECT g.*, u.name AS owner_name, ug.id AS source_user_group_id,
|
||||||
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
|
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
|
||||||
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||||
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
|
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
|
||||||
@@ -80,7 +85,9 @@ router.get('/', authMiddleware, async (req, res) => {
|
|||||||
ORDER BY u2.name LIMIT 4
|
ORDER BY u2.name LIMIT 4
|
||||||
) t) AS member_previews
|
) t) AS member_previews
|
||||||
FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1
|
FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1
|
||||||
LEFT JOIN users u ON g.owner_id=u.id WHERE g.type='private'
|
LEFT JOIN users u ON g.owner_id=u.id
|
||||||
|
LEFT JOIN user_groups ug ON ug.dm_group_id=g.id AND g.is_managed=TRUE AND g.is_multi_group IS NOT TRUE
|
||||||
|
WHERE g.type='private'
|
||||||
ORDER BY last_message_at DESC NULLS LAST
|
ORDER BY last_message_at DESC NULLS LAST
|
||||||
`, [userId]);
|
`, [userId]);
|
||||||
|
|
||||||
@@ -182,8 +189,30 @@ router.post('/', authMiddleware, async (req, res) => {
|
|||||||
const groupId = r.rows[0].id;
|
const groupId = r.rows[0].id;
|
||||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]);
|
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]);
|
||||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]);
|
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]);
|
||||||
|
|
||||||
|
// Mixed Age: if initiator is not a minor and the other user is a minor, auto-add their guardian
|
||||||
|
let guardianAdded = false, guardianName = null;
|
||||||
|
const loginType = await getLoginType(req.schema);
|
||||||
|
if (loginType === 'mixed_age' && !req.user.is_minor) {
|
||||||
|
const otherUserFull = await queryOne(req.schema,
|
||||||
|
'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [otherUserId]);
|
||||||
|
if (otherUserFull?.is_minor && otherUserFull.guardian_user_id) {
|
||||||
|
const guardianId = otherUserFull.guardian_user_id;
|
||||||
|
if (guardianId !== userId) {
|
||||||
|
await exec(req.schema,
|
||||||
|
'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||||
|
[groupId, guardianId]);
|
||||||
|
const guardian = await queryOne(req.schema,
|
||||||
|
'SELECT name, display_name FROM users WHERE id=$1', [guardianId]);
|
||||||
|
guardianAdded = true;
|
||||||
|
guardianName = guardian?.display_name || guardian?.name || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await emitGroupNew(req.schema, io, groupId);
|
await emitGroupNew(req.schema, io, groupId);
|
||||||
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
|
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||||
|
return res.json({ group, guardianAdded, guardianName });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate private group
|
// Check for duplicate private group
|
||||||
@@ -205,6 +234,7 @@ router.post('/', authMiddleware, async (req, res) => {
|
|||||||
[name, type||'private', req.user.id, !!isReadonly]
|
[name, type||'private', req.user.id, !!isReadonly]
|
||||||
);
|
);
|
||||||
const groupId = r.rows[0].id;
|
const groupId = r.rows[0].id;
|
||||||
|
const groupGuardianNames = [];
|
||||||
if (type === 'public') {
|
if (type === 'public') {
|
||||||
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
||||||
for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]);
|
for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]);
|
||||||
@@ -222,9 +252,30 @@ router.post('/', authMiddleware, async (req, res) => {
|
|||||||
if (parseInt(totalCount.cnt) >= 3) {
|
if (parseInt(totalCount.cnt) >= 3) {
|
||||||
await computeAndStoreComposite(req.schema, groupId);
|
await computeAndStoreComposite(req.schema, groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mixed Age: auto-add guardians for any minor members (non-minor initiators only)
|
||||||
|
const groupLoginType = await getLoginType(req.schema);
|
||||||
|
if (groupLoginType === 'mixed_age' && !req.user.is_minor && memberIds?.length > 0) {
|
||||||
|
for (const uid of memberIds) {
|
||||||
|
const memberInfo = await queryOne(req.schema,
|
||||||
|
'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [uid]);
|
||||||
|
if (memberInfo?.is_minor && memberInfo.guardian_user_id && memberInfo.guardian_user_id !== req.user.id) {
|
||||||
|
await exec(req.schema,
|
||||||
|
'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||||
|
[groupId, memberInfo.guardian_user_id]);
|
||||||
|
const g = await queryOne(req.schema,
|
||||||
|
'SELECT name,display_name FROM users WHERE id=$1', [memberInfo.guardian_user_id]);
|
||||||
|
const gName = g?.display_name || g?.name;
|
||||||
|
if (gName && !groupGuardianNames.includes(gName)) groupGuardianNames.push(gName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await emitGroupNew(req.schema, io, groupId);
|
await emitGroupNew(req.schema, io, groupId);
|
||||||
res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
|
res.json({
|
||||||
|
group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]),
|
||||||
|
...(groupGuardianNames.length ? { guardianAdded: true, guardianName: groupGuardianNames.join(', ') } : {}),
|
||||||
|
});
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const {
|
const {
|
||||||
query, queryOne, queryResult, exec,
|
query, queryOne, queryResult, exec,
|
||||||
@@ -186,7 +187,7 @@ router.post('/tenants', async (req, res) => {
|
|||||||
// Supports updating: name, plan, customDomain, status
|
// Supports updating: name, plan, customDomain, status
|
||||||
|
|
||||||
router.patch('/tenants/:slug', async (req, res) => {
|
router.patch('/tenants/:slug', async (req, res) => {
|
||||||
const { name, plan, customDomain, status } = req.body;
|
const { name, plan, customDomain, status, adminPassword } = req.body;
|
||||||
try {
|
try {
|
||||||
const tenant = await queryOne('public',
|
const tenant = await queryOne('public',
|
||||||
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
||||||
@@ -224,6 +225,15 @@ router.patch('/tenants/:slug', async (req, res) => {
|
|||||||
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset tenant admin password if provided
|
||||||
|
if (adminPassword && adminPassword.length >= 6) {
|
||||||
|
const hash = bcrypt.hashSync(adminPassword, 10);
|
||||||
|
await exec(tenant.schema_name,
|
||||||
|
"UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE is_default_admin=TRUE",
|
||||||
|
[hash]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await reloadTenantCache();
|
await reloadTenantCache();
|
||||||
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
||||||
res.json({ tenant: updated });
|
res.json({ tenant: updated });
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ async function postEventNotification(schema, eventId, actorId) {
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getPartnerId(schema, userId) {
|
||||||
|
const row = await queryOne(schema,
|
||||||
|
'SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END AS partner_id FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return row?.partner_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
async function isToolManagerFn(schema, user) {
|
async function isToolManagerFn(schema, user) {
|
||||||
if (user.role === 'admin' || user.role === 'manager') return true;
|
if (user.role === 'admin' || user.role === 'manager') return true;
|
||||||
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
|
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
|
||||||
@@ -65,7 +73,33 @@ async function canViewEvent(schema, event, userId, isToolManager) {
|
|||||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
`, [event.id, userId]);
|
`, [event.id, userId]);
|
||||||
return !!assigned;
|
if (assigned) return true;
|
||||||
|
// Also allow if user has an alias in one of the event's user groups (Guardian Only mode)
|
||||||
|
const aliasAssigned = await queryOne(schema, `
|
||||||
|
SELECT 1 FROM event_user_groups eug
|
||||||
|
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||||
|
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||||
|
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||||
|
`, [event.id, userId]);
|
||||||
|
if (aliasAssigned) return true;
|
||||||
|
// Allow if partner is assigned to the event (directly or via alias)
|
||||||
|
const partnerId = await getPartnerId(schema, userId);
|
||||||
|
if (partnerId) {
|
||||||
|
const partnerAssigned = await queryOne(schema, `
|
||||||
|
SELECT 1 FROM event_user_groups eug
|
||||||
|
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
|
`, [event.id, partnerId]);
|
||||||
|
if (partnerAssigned) return true;
|
||||||
|
const partnerAliasAssigned = await queryOne(schema, `
|
||||||
|
SELECT 1 FROM event_user_groups eug
|
||||||
|
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||||
|
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||||
|
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||||
|
`, [event.id, partnerId]);
|
||||||
|
if (partnerAliasAssigned) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichEvent(schema, event) {
|
async function enrichEvent(schema, event) {
|
||||||
@@ -235,16 +269,69 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
|||||||
const itm = await isToolManagerFn(req.schema, req.user);
|
const itm = await isToolManagerFn(req.schema, req.user);
|
||||||
if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
|
if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
|
||||||
await enrichEvent(req.schema, event);
|
await enrichEvent(req.schema, event);
|
||||||
const isMember = !itm && !!(await queryOne(req.schema, `
|
const partnerId = await getPartnerId(req.schema, req.user.id);
|
||||||
SELECT 1 FROM event_user_groups eug
|
const isMember = !itm && !!(
|
||||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
(await queryOne(req.schema, `
|
||||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
SELECT 1 FROM event_user_groups eug
|
||||||
`, [event.id, req.user.id]));
|
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
|
`, [event.id, req.user.id]))
|
||||||
|
||
|
||||||
|
// Guardian Only: user has an alias in one of the event's user groups
|
||||||
|
(await queryOne(req.schema, `
|
||||||
|
SELECT 1 FROM event_user_groups eug
|
||||||
|
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||||
|
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||||
|
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||||
|
`, [event.id, req.user.id]))
|
||||||
|
||
|
||||||
|
// Partner is assigned to this event (user group or alias)
|
||||||
|
(partnerId && !!(
|
||||||
|
(await queryOne(req.schema, `
|
||||||
|
SELECT 1 FROM event_user_groups eug
|
||||||
|
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
|
`, [event.id, partnerId]))
|
||||||
|
||
|
||||||
|
(await queryOne(req.schema, `
|
||||||
|
SELECT 1 FROM event_user_groups eug
|
||||||
|
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||||
|
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||||
|
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||||
|
`, [event.id, partnerId]))
|
||||||
|
))
|
||||||
|
);
|
||||||
if (event.track_availability && (itm || isMember)) {
|
if (event.track_availability && (itm || isMember)) {
|
||||||
event.availability = await query(req.schema, `
|
// User responses
|
||||||
SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name, u.avatar
|
const userAvail = await query(req.schema, `
|
||||||
|
SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name, u.avatar, FALSE AS is_alias
|
||||||
FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
|
FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
|
||||||
`, [req.params.id]);
|
`, [req.params.id]);
|
||||||
|
// Alias responses (Guardian Only mode)
|
||||||
|
const aliasAvail = await query(req.schema, `
|
||||||
|
SELECT eaa.response, eaa.note, eaa.updated_at, ga.id AS alias_id, ga.first_name, ga.last_name, ga.avatar, ga.guardian_id, TRUE AS is_alias
|
||||||
|
FROM event_alias_availability eaa JOIN guardian_aliases ga ON ga.id=eaa.alias_id WHERE eaa.event_id=$1
|
||||||
|
`, [req.params.id]);
|
||||||
|
event.availability = [...userAvail, ...aliasAvail];
|
||||||
|
|
||||||
|
// For non-tool-managers: mask notes on entries that don't belong to them or their aliases
|
||||||
|
if (!itm) {
|
||||||
|
const myAliasIds = new Set(
|
||||||
|
(await query(req.schema,
|
||||||
|
`SELECT id FROM guardian_aliases WHERE guardian_id=$1
|
||||||
|
OR guardian_id IN (
|
||||||
|
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||||
|
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||||
|
)`,
|
||||||
|
[req.user.id])).map(r => r.id)
|
||||||
|
);
|
||||||
|
event.availability = event.availability.map(r => {
|
||||||
|
const isOwn = !r.is_alias && r.user_id === req.user.id;
|
||||||
|
const isOwnAlias = r.is_alias && myAliasIds.has(r.alias_id);
|
||||||
|
return (isOwn || isOwnAlias) ? r : { ...r, note: null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (itm) {
|
if (itm) {
|
||||||
const assignedRows = await query(req.schema, `
|
const assignedRows = await query(req.schema, `
|
||||||
SELECT DISTINCT u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name
|
SELECT DISTINCT u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name
|
||||||
@@ -253,11 +340,69 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
|||||||
JOIN users u ON u.id=ugm.user_id
|
JOIN users u ON u.id=ugm.user_id
|
||||||
WHERE eug.event_id=$1
|
WHERE eug.event_id=$1
|
||||||
`, [req.params.id]);
|
`, [req.params.id]);
|
||||||
const respondedIds = new Set(event.availability.map(r => r.user_id));
|
// Also include alias members
|
||||||
const noResponseRows = assignedRows.filter(r => !respondedIds.has(r.user_id));
|
const assignedAliases = await query(req.schema, `
|
||||||
|
SELECT DISTINCT ga.id AS alias_id, ga.first_name, ga.last_name
|
||||||
|
FROM event_user_groups eug
|
||||||
|
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||||
|
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||||
|
WHERE eug.event_id=$1
|
||||||
|
`, [req.params.id]);
|
||||||
|
const respondedUserIds = new Set(userAvail.map(r => r.user_id));
|
||||||
|
const respondedAliasIds = new Set(aliasAvail.map(r => r.alias_id));
|
||||||
|
const noResponseRows = [
|
||||||
|
...assignedRows.filter(r => !respondedUserIds.has(r.user_id)),
|
||||||
|
...assignedAliases.filter(r => !respondedAliasIds.has(r.alias_id)).map(r => ({ ...r, is_alias: true })),
|
||||||
|
];
|
||||||
event.no_response_count = noResponseRows.length;
|
event.no_response_count = noResponseRows.length;
|
||||||
event.no_response_users = noResponseRows;
|
event.no_response_users = noResponseRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect if event targets the players group (for responder select dropdown)
|
||||||
|
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||||
|
const playersGroupId = parseInt(playersRow?.value);
|
||||||
|
event.has_players_group = !!(playersGroupId && event.user_groups?.some(g => g.id === playersGroupId));
|
||||||
|
|
||||||
|
// Detect if event targets the guardians group (so guardian shows own name in select)
|
||||||
|
const guardiansRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_guardians_group_id'");
|
||||||
|
const guardiansGroupId = parseInt(guardiansRow?.value);
|
||||||
|
event.in_guardians_group = !!(guardiansGroupId && event.user_groups?.some(g => g.id === guardiansGroupId) &&
|
||||||
|
(
|
||||||
|
(await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, req.user.id]))
|
||||||
|
||
|
||||||
|
(partnerId && await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, partnerId]))
|
||||||
|
));
|
||||||
|
|
||||||
|
// Return current user's aliases (and partner's) for the responder dropdown (Guardian Only)
|
||||||
|
if (event.has_players_group) {
|
||||||
|
event.my_aliases = await query(req.schema,
|
||||||
|
`SELECT id,first_name,last_name,avatar FROM guardian_aliases
|
||||||
|
WHERE guardian_id=$1
|
||||||
|
OR guardian_id IN (
|
||||||
|
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||||
|
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||||
|
)
|
||||||
|
ORDER BY first_name,last_name`,
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return partner user info if they are in one of this event's user groups
|
||||||
|
if (partnerId) {
|
||||||
|
const partnerInGroup = await queryOne(req.schema, `
|
||||||
|
SELECT 1 FROM event_user_groups eug
|
||||||
|
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
|
`, [event.id, partnerId]);
|
||||||
|
if (partnerInGroup) {
|
||||||
|
const pUser = await queryOne(req.schema, 'SELECT id,name,display_name,avatar FROM users WHERE id=$1', [partnerId]);
|
||||||
|
const pGp = await queryOne(req.schema,
|
||||||
|
'SELECT respond_separately FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||||
|
[Math.min(req.user.id, partnerId), Math.max(req.user.id, partnerId)]
|
||||||
|
);
|
||||||
|
event.my_partner = pUser ? { ...pUser, respond_separately: pGp?.respond_separately || false } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const mine = await queryOne(req.schema, 'SELECT response, note FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
const mine = await queryOne(req.schema, 'SELECT response, note FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||||
event.my_response = mine?.response || null;
|
event.my_response = mine?.response || null;
|
||||||
@@ -564,19 +709,57 @@ router.put('/:id/availability', authMiddleware, async (req, res) => {
|
|||||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||||
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
|
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
|
||||||
const { response, note } = req.body;
|
const { response, note, aliasId, forPartnerId } = req.body;
|
||||||
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
|
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
|
||||||
const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
|
const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
|
||||||
const itm = await isToolManagerFn(req.schema, req.user);
|
|
||||||
const inGroup = await queryOne(req.schema, `
|
if (forPartnerId) {
|
||||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
// Respond on behalf of partner — verify partnership and partner's group membership
|
||||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
const isPartner = await queryOne(req.schema,
|
||||||
`, [event.id, req.user.id]);
|
'SELECT 1 FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||||
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
[req.user.id, forPartnerId]);
|
||||||
await exec(req.schema, `
|
if (!isPartner) return res.status(403).json({ error: 'Not your partner' });
|
||||||
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
const partnerInGroup = await queryOne(req.schema, `
|
||||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
`, [event.id, req.user.id, response, trimmedNote]);
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
|
`, [event.id, forPartnerId]);
|
||||||
|
if (!partnerInGroup) return res.status(403).json({ error: 'Partner is not assigned to this event' });
|
||||||
|
await exec(req.schema, `
|
||||||
|
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||||
|
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||||
|
`, [event.id, forPartnerId, response, trimmedNote]);
|
||||||
|
return res.json({ success: true, response, note: trimmedNote });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aliasId) {
|
||||||
|
// Alias response (Guardian Only mode) — verify alias belongs to current user or their partner
|
||||||
|
const alias = await queryOne(req.schema,
|
||||||
|
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||||
|
guardian_id=$2 OR guardian_id IN (
|
||||||
|
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||||
|
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
[aliasId, req.user.id]);
|
||||||
|
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
|
||||||
|
await exec(req.schema, `
|
||||||
|
INSERT INTO event_alias_availability (event_id,alias_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||||
|
ON CONFLICT (event_id,alias_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||||
|
`, [event.id, aliasId, response, trimmedNote]);
|
||||||
|
} else {
|
||||||
|
// Regular user response — also allowed if partner is in the event's group
|
||||||
|
const itm = await isToolManagerFn(req.schema, req.user);
|
||||||
|
const avPartner = await getPartnerId(req.schema, req.user.id);
|
||||||
|
const inGroup = await queryOne(req.schema, `
|
||||||
|
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
|
WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
|
||||||
|
`, [event.id, req.user.id, avPartner || -1]);
|
||||||
|
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
||||||
|
await exec(req.schema, `
|
||||||
|
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||||
|
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||||
|
`, [event.id, req.user.id, response, trimmedNote]);
|
||||||
|
}
|
||||||
res.json({ success: true, response, note: trimmedNote });
|
res.json({ success: true, response, note: trimmedNote });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
@@ -593,7 +776,27 @@ router.patch('/:id/availability/note', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
router.delete('/:id/availability', authMiddleware, async (req, res) => {
|
router.delete('/:id/availability', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
const { aliasId, forPartnerId } = req.query;
|
||||||
|
if (forPartnerId) {
|
||||||
|
const isPartner = await queryOne(req.schema,
|
||||||
|
'SELECT 1 FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||||
|
[req.user.id, forPartnerId]);
|
||||||
|
if (!isPartner) return res.status(403).json({ error: 'Not your partner' });
|
||||||
|
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, forPartnerId]);
|
||||||
|
} else if (aliasId) {
|
||||||
|
const alias = await queryOne(req.schema,
|
||||||
|
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||||
|
guardian_id=$2 OR guardian_id IN (
|
||||||
|
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||||
|
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
[aliasId, req.user.id]);
|
||||||
|
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
|
||||||
|
await exec(req.schema, 'DELETE FROM event_alias_availability WHERE event_id=$1 AND alias_id=$2', [req.params.id, aliasId]);
|
||||||
|
} else {
|
||||||
|
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
@@ -604,14 +807,15 @@ router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
let saved = 0;
|
let saved = 0;
|
||||||
const itm = await isToolManagerFn(req.schema, req.user);
|
const itm = await isToolManagerFn(req.schema, req.user);
|
||||||
|
const bulkPartnerId = await getPartnerId(req.schema, req.user.id);
|
||||||
for (const { eventId, response } of responses) {
|
for (const { eventId, response } of responses) {
|
||||||
if (!['going','maybe','not_going'].includes(response)) continue;
|
if (!['going','maybe','not_going'].includes(response)) continue;
|
||||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||||
if (!event || !event.track_availability) continue;
|
if (!event || !event.track_availability) continue;
|
||||||
const inGroup = await queryOne(req.schema, `
|
const inGroup = await queryOne(req.schema, `
|
||||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
|
||||||
`, [eventId, req.user.id]);
|
`, [eventId, req.user.id, bulkPartnerId || -1]);
|
||||||
if (!inGroup && !itm) continue;
|
if (!inGroup && !itm) continue;
|
||||||
await exec(req.schema, `
|
await exec(req.schema, `
|
||||||
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||||
|
|||||||
@@ -152,6 +152,26 @@ router.patch('/messages', authMiddleware, adminMiddleware, async (req, res) => {
|
|||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const VALID_LOGIN_TYPES = ['all_ages', 'guardian_only', 'mixed_age'];
|
||||||
|
|
||||||
|
router.patch('/login-type', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
const { loginType, playersGroupId, guardiansGroupId } = req.body;
|
||||||
|
if (!VALID_LOGIN_TYPES.includes(loginType)) return res.status(400).json({ error: 'Invalid login type' });
|
||||||
|
try {
|
||||||
|
// Enforce: can only change when no non-admin users exist, UNLESS staying on same value
|
||||||
|
const existing = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||||
|
const current = existing?.value || 'all_ages';
|
||||||
|
if (loginType !== current) {
|
||||||
|
const { count } = await queryOne(req.schema, "SELECT COUNT(*)::int AS count FROM users WHERE role != 'admin' AND status != 'deleted'");
|
||||||
|
if (count > 0) return res.status(400).json({ error: 'Login Type can only be changed when no non-admin users exist.' });
|
||||||
|
}
|
||||||
|
await setSetting(req.schema, 'feature_login_type', loginType);
|
||||||
|
await setSetting(req.schema, 'feature_players_group_id', playersGroupId != null ? String(playersGroupId) : '');
|
||||||
|
await setSetting(req.schema, 'feature_guardians_group_id', guardiansGroupId != null ? String(guardiansGroupId) : '');
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
|
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
const { toolManagers } = req.body;
|
const { toolManagers } = req.body;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -211,7 +211,16 @@ router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
|||||||
FROM user_group_members ugm JOIN users u ON u.id=ugm.user_id
|
FROM user_group_members ugm JOIN users u ON u.id=ugm.user_id
|
||||||
WHERE ugm.user_group_id=$1 ORDER BY u.name ASC
|
WHERE ugm.user_group_id=$1 ORDER BY u.name ASC
|
||||||
`, [req.params.id]);
|
`, [req.params.id]);
|
||||||
res.json({ group, members });
|
const aliasMembers = await query(req.schema, `
|
||||||
|
SELECT ga.id, ga.first_name, ga.last_name,
|
||||||
|
ga.first_name || ' ' || ga.last_name AS name,
|
||||||
|
ga.guardian_id, ga.avatar, ga.date_of_birth
|
||||||
|
FROM alias_group_members agm
|
||||||
|
JOIN guardian_aliases ga ON ga.id = agm.alias_id
|
||||||
|
WHERE agm.user_group_id=$1
|
||||||
|
ORDER BY ga.first_name, ga.last_name ASC
|
||||||
|
`, [req.params.id]);
|
||||||
|
res.json({ group, members, aliasMembers });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +259,7 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
// PATCH /:id
|
// PATCH /:id
|
||||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const { name, memberIds, createDm = false } = req.body;
|
const { name, memberIds, createDm = false, aliasMemberIds } = req.body;
|
||||||
try {
|
try {
|
||||||
let ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
let ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||||
@@ -356,6 +365,24 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alias member management (Guardian Only mode — players group)
|
||||||
|
if (Array.isArray(aliasMemberIds)) {
|
||||||
|
const newAliasIds = new Set(aliasMemberIds.map(Number).filter(Boolean));
|
||||||
|
const currentAliasSet = new Set(
|
||||||
|
(await query(req.schema, 'SELECT alias_id FROM alias_group_members WHERE user_group_id=$1', [ug.id])).map(r => r.alias_id)
|
||||||
|
);
|
||||||
|
for (const aid of newAliasIds) {
|
||||||
|
if (!currentAliasSet.has(aid)) {
|
||||||
|
await exec(req.schema, 'INSERT INTO alias_group_members (user_group_id,alias_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, aid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const aid of currentAliasSet) {
|
||||||
|
if (!newAliasIds.has(aid)) {
|
||||||
|
await exec(req.schema, 'DELETE FROM alias_group_members WHERE user_group_id=$1 AND alias_id=$2', [ug.id, aid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||||
res.json({ group: updated });
|
res.json({ group: updated });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ const uploadAvatar = multer({
|
|||||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Alias avatar upload (separate from user avatar so filename doesn't collide)
|
||||||
|
const aliasAvatarStorage = multer.diskStorage({
|
||||||
|
destination: '/app/uploads/avatars',
|
||||||
|
filename: (req, file, cb) => cb(null, `alias_${req.params.aliasId}_${Date.now()}${path.extname(file.originalname)}`),
|
||||||
|
});
|
||||||
|
const uploadAliasAvatar = multer({
|
||||||
|
storage: aliasAvatarStorage,
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||||
|
});
|
||||||
|
|
||||||
async function resolveUniqueName(schema, baseName, excludeId = null) {
|
async function resolveUniqueName(schema, baseName, excludeId = null) {
|
||||||
const existing = await query(schema,
|
const existing = await query(schema,
|
||||||
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
|
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
|
||||||
@@ -29,11 +40,28 @@ async function resolveUniqueName(schema, baseName, excludeId = null) {
|
|||||||
|
|
||||||
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
||||||
|
|
||||||
|
// Returns true if the given date-of-birth string corresponds to age <= 15
|
||||||
|
function isMinorFromDOB(dob) {
|
||||||
|
if (!dob) return false;
|
||||||
|
const birth = new Date(dob);
|
||||||
|
if (isNaN(birth)) return false;
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - birth.getFullYear();
|
||||||
|
const m = today.getMonth() - birth.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||||
|
return age <= 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLoginType(schema) {
|
||||||
|
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||||
|
return row?.value || 'all_ages';
|
||||||
|
}
|
||||||
|
|
||||||
// List users
|
// List users
|
||||||
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = await query(req.schema,
|
const users = await query(req.schema,
|
||||||
"SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
|
"SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
|
||||||
);
|
);
|
||||||
res.json({ users });
|
res.json({ users });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
@@ -52,18 +80,18 @@ router.get('/search', authMiddleware, async (req, res) => {
|
|||||||
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
|
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
|
||||||
if (group && (group.type === 'private' || group.is_direct)) {
|
if (group && (group.type === 'private' || group.is_direct)) {
|
||||||
users = await query(req.schema,
|
users = await query(req.schema,
|
||||||
`SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
`SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm,u.is_minor,u.is_default_admin FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||||
[parseInt(groupId), req.user.id, `%${q}%`]
|
[parseInt(groupId), req.user.id, `%${q}%`]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
users = await query(req.schema,
|
users = await query(req.schema,
|
||||||
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor,is_default_admin FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||||
[req.user.id, `%${q}%`]
|
[req.user.id, `%${q}%`]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
users = await query(req.schema,
|
users = await query(req.schema,
|
||||||
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor,is_default_admin FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||||
[`%${q}%`]
|
[`%${q}%`]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,7 +114,7 @@ router.get('/check-display-name', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const { firstName, lastName, email, password, role, phone, isMinor } = req.body;
|
const { firstName, lastName, email, password, role, phone, dateOfBirth } = req.body;
|
||||||
if (!firstName?.trim() || !lastName?.trim() || !email)
|
if (!firstName?.trim() || !lastName?.trim() || !email)
|
||||||
return res.status(400).json({ error: 'First name, last name and email required' });
|
return res.status(400).json({ error: 'First name, last name and email required' });
|
||||||
if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' });
|
if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' });
|
||||||
@@ -94,31 +122,40 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
|||||||
const assignedRole = validRoles.includes(role) ? role : 'member';
|
const assignedRole = validRoles.includes(role) ? role : 'member';
|
||||||
const name = `${firstName.trim()} ${lastName.trim()}`;
|
const name = `${firstName.trim()} ${lastName.trim()}`;
|
||||||
try {
|
try {
|
||||||
|
const loginType = await getLoginType(req.schema);
|
||||||
|
const dob = dateOfBirth || null;
|
||||||
|
const isMinor = isMinorFromDOB(dob);
|
||||||
|
// In mixed_age mode, minors start suspended and need guardian approval
|
||||||
|
const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active';
|
||||||
|
|
||||||
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND status != 'deleted'", [email.trim()]);
|
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND status != 'deleted'", [email.trim()]);
|
||||||
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
||||||
const resolvedName = await resolveUniqueName(req.schema, name);
|
const resolvedName = await resolveUniqueName(req.schema, name);
|
||||||
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
|
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
|
||||||
const hash = bcrypt.hashSync(pw, 10);
|
const hash = bcrypt.hashSync(pw, 10);
|
||||||
const r = await queryResult(req.schema,
|
const r = await queryResult(req.schema,
|
||||||
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active',TRUE) RETURNING id",
|
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,date_of_birth,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE) RETURNING id",
|
||||||
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, !!isMinor]
|
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, isMinor, dob, initStatus]
|
||||||
);
|
);
|
||||||
const userId = r.rows[0].id;
|
const userId = r.rows[0].id;
|
||||||
await addUserToPublicGroups(req.schema, userId);
|
if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
|
||||||
if (assignedRole === 'admin') {
|
if (assignedRole === 'admin') {
|
||||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
||||||
}
|
}
|
||||||
const user = await queryOne(req.schema, 'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
|
const user = await queryOne(req.schema,
|
||||||
res.json({ user });
|
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,created_at FROM users WHERE id=$1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
res.json({ user, pendingApproval: initStatus === 'suspended' });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user (general — name components, phone, is_minor, role, optional password reset)
|
// Update user (general — name components, phone, DOB, is_minor, role, optional password reset)
|
||||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' });
|
if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' });
|
||||||
const { firstName, lastName, phone, isMinor, role, password } = req.body;
|
const { firstName, lastName, phone, role, password, dateOfBirth, guardianUserId } = req.body;
|
||||||
if (!firstName?.trim() || !lastName?.trim())
|
if (!firstName?.trim() || !lastName?.trim())
|
||||||
return res.status(400).json({ error: 'First and last name required' });
|
return res.status(400).json({ error: 'First and last name required' });
|
||||||
const validRoles = ['member', 'admin', 'manager'];
|
const validRoles = ['member', 'admin', 'manager'];
|
||||||
@@ -128,11 +165,24 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
|||||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||||
if (target.is_default_admin && role !== 'admin')
|
if (target.is_default_admin && role !== 'admin')
|
||||||
return res.status(403).json({ error: 'Cannot change default admin role' });
|
return res.status(403).json({ error: 'Cannot change default admin role' });
|
||||||
const name = `${firstName.trim()} ${lastName.trim()}`;
|
|
||||||
|
const dob = dateOfBirth || null;
|
||||||
|
const isMinor = isMinorFromDOB(dob);
|
||||||
|
const name = `${firstName.trim()} ${lastName.trim()}`;
|
||||||
const resolvedName = await resolveUniqueName(req.schema, name, id);
|
const resolvedName = await resolveUniqueName(req.schema, name, id);
|
||||||
|
|
||||||
|
// Validate guardian if provided
|
||||||
|
let guardianId = null;
|
||||||
|
if (guardianUserId) {
|
||||||
|
const gUser = await queryOne(req.schema, 'SELECT id,is_minor FROM users WHERE id=$1 AND status=$2', [parseInt(guardianUserId), 'active']);
|
||||||
|
if (!gUser) return res.status(400).json({ error: 'Guardian user not found or inactive' });
|
||||||
|
if (gUser.is_minor) return res.status(400).json({ error: 'A minor cannot be a guardian' });
|
||||||
|
guardianId = gUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
await exec(req.schema,
|
await exec(req.schema,
|
||||||
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,role=$6,updated_at=NOW() WHERE id=$7',
|
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,date_of_birth=$6,guardian_user_id=$7,role=$8,updated_at=NOW() WHERE id=$9',
|
||||||
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, !!isMinor, role, id]
|
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, isMinor, dob, guardianId, role, id]
|
||||||
);
|
);
|
||||||
if (password && password.length >= 6) {
|
if (password && password.length >= 6) {
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
@@ -142,8 +192,20 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
|||||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, id]);
|
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, id]);
|
||||||
}
|
}
|
||||||
|
// Auto-unsuspend minor in players group if both guardian and DOB are now set
|
||||||
|
if (isMinor && guardianId && dob && target.status === 'suspended') {
|
||||||
|
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||||
|
const playersGroupId = parseInt(playersRow?.value);
|
||||||
|
if (playersGroupId) {
|
||||||
|
const inPlayers = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [id, playersGroupId]);
|
||||||
|
if (inPlayers) {
|
||||||
|
await exec(req.schema, "UPDATE users SET status='active',updated_at=NOW() WHERE id=$1", [id]);
|
||||||
|
await addUserToPublicGroups(req.schema, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const user = await queryOne(req.schema,
|
const user = await queryOne(req.schema,
|
||||||
'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
|
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
res.json({ user });
|
res.json({ user });
|
||||||
@@ -178,12 +240,16 @@ router.post('/bulk', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
|||||||
const newRole = validRoles.includes(u.role) ? u.role : 'member';
|
const newRole = validRoles.includes(u.role) ? u.role : 'member';
|
||||||
const fn = firstName || name.split(' ')[0] || '';
|
const fn = firstName || name.split(' ')[0] || '';
|
||||||
const ln = lastName || name.split(' ').slice(1).join(' ') || '';
|
const ln = lastName || name.split(' ').slice(1).join(' ') || '';
|
||||||
|
const dob = (u.dateOfBirth || u.dob || '').trim() || null;
|
||||||
|
const isMinor = isMinorFromDOB(dob);
|
||||||
|
const loginType = await getLoginType(req.schema);
|
||||||
|
const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active';
|
||||||
const r = await queryResult(req.schema,
|
const r = await queryResult(req.schema,
|
||||||
"INSERT INTO users (name,first_name,last_name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,'active',TRUE) RETURNING id",
|
"INSERT INTO users (name,first_name,last_name,email,password,role,date_of_birth,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE) RETURNING id",
|
||||||
[resolvedName, fn, ln, email, hash, newRole]
|
[resolvedName, fn, ln, email, hash, newRole, dob, isMinor, initStatus]
|
||||||
);
|
);
|
||||||
const userId = r.rows[0].id;
|
const userId = r.rows[0].id;
|
||||||
await addUserToPublicGroups(req.schema, userId);
|
if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
|
||||||
if (newRole === 'admin') {
|
if (newRole === 'admin') {
|
||||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
||||||
@@ -324,7 +390,7 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req,
|
|||||||
|
|
||||||
// Update own profile
|
// Update own profile
|
||||||
router.patch('/me/profile', authMiddleware, async (req, res) => {
|
router.patch('/me/profile', authMiddleware, async (req, res) => {
|
||||||
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
|
const { displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone } = req.body;
|
||||||
try {
|
try {
|
||||||
if (displayName) {
|
if (displayName) {
|
||||||
const conflict = await queryOne(req.schema,
|
const conflict = await queryOne(req.schema,
|
||||||
@@ -333,12 +399,14 @@ router.patch('/me/profile', authMiddleware, async (req, res) => {
|
|||||||
);
|
);
|
||||||
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
|
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
|
||||||
}
|
}
|
||||||
|
const dob = dateOfBirth || null;
|
||||||
|
const isMinor = isMinorFromDOB(dob);
|
||||||
await exec(req.schema,
|
await exec(req.schema,
|
||||||
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5',
|
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, date_of_birth=$5, is_minor=$6, phone=$7, updated_at=NOW() WHERE id=$8',
|
||||||
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, req.user.id]
|
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, dob, isMinor, phone?.trim() || null, req.user.id]
|
||||||
);
|
);
|
||||||
const user = await queryOne(req.schema,
|
const user = await queryOne(req.schema,
|
||||||
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm FROM users WHERE id=$1',
|
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm,date_of_birth,phone FROM users WHERE id=$1',
|
||||||
[req.user.id]
|
[req.user.id]
|
||||||
);
|
);
|
||||||
res.json({ user });
|
res.json({ user });
|
||||||
@@ -376,4 +444,345 @@ router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Guardian alias routes (Guardian Only mode) ──────────────────────────────
|
||||||
|
|
||||||
|
// List ALL aliases — admin/manager only (for Group Manager alias management)
|
||||||
|
router.get('/aliases-all', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const aliases = await query(req.schema,
|
||||||
|
`SELECT ga.id, ga.first_name, ga.last_name, ga.guardian_id, ga.avatar, ga.date_of_birth,
|
||||||
|
u.name AS guardian_name, u.display_name AS guardian_display_name
|
||||||
|
FROM guardian_aliases ga
|
||||||
|
JOIN users u ON u.id = ga.guardian_id
|
||||||
|
ORDER BY ga.first_name, ga.last_name`,
|
||||||
|
);
|
||||||
|
res.json({ aliases });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user's partner (spouse/partner relationship)
|
||||||
|
router.get('/me/partner', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const partner = await queryOne(req.schema,
|
||||||
|
`SELECT u.id, u.name, u.display_name, u.avatar, gp.respond_separately
|
||||||
|
FROM guardian_partners gp
|
||||||
|
JOIN users u ON u.id = CASE WHEN gp.user_id_1=$1 THEN gp.user_id_2 ELSE gp.user_id_1 END
|
||||||
|
WHERE gp.user_id_1=$1 OR gp.user_id_2=$1`,
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ partner: partner || null });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set partner (replaces any existing partnership for this user)
|
||||||
|
// If the partner is changing to a different person, the user's child aliases are also removed.
|
||||||
|
router.post('/me/partner', authMiddleware, async (req, res) => {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const partnerId = parseInt(req.body.partnerId);
|
||||||
|
const respondSeparately = !!req.body.respondSeparately;
|
||||||
|
if (!partnerId || partnerId === userId) return res.status(400).json({ error: 'Invalid partner' });
|
||||||
|
const uid1 = Math.min(userId, partnerId);
|
||||||
|
const uid2 = Math.max(userId, partnerId);
|
||||||
|
try {
|
||||||
|
// Check current partner before replacing
|
||||||
|
const currentRow = await queryOne(req.schema,
|
||||||
|
`SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END AS partner_id
|
||||||
|
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const currentPartnerId = currentRow?.partner_id ? parseInt(currentRow.partner_id) : null;
|
||||||
|
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [userId]);
|
||||||
|
// If switching to a different partner, remove user's own child aliases
|
||||||
|
if (currentPartnerId && currentPartnerId !== partnerId) {
|
||||||
|
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE guardian_id=$1', [userId]);
|
||||||
|
}
|
||||||
|
await exec(req.schema, 'INSERT INTO guardian_partners (user_id_1,user_id_2,respond_separately) VALUES ($1,$2,$3)', [uid1, uid2, respondSeparately]);
|
||||||
|
const partner = await queryOne(req.schema,
|
||||||
|
'SELECT id,name,display_name,avatar FROM users WHERE id=$1',
|
||||||
|
[partnerId]
|
||||||
|
);
|
||||||
|
res.json({ partner: { ...partner, respond_separately: respondSeparately } });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update respond_separately on existing partnership
|
||||||
|
router.patch('/me/partner', authMiddleware, async (req, res) => {
|
||||||
|
const respondSeparately = !!req.body.respondSeparately;
|
||||||
|
try {
|
||||||
|
await exec(req.schema,
|
||||||
|
'UPDATE guardian_partners SET respond_separately=$1 WHERE user_id_1=$2 OR user_id_2=$2',
|
||||||
|
[respondSeparately, req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove partner — also removes the requesting user's child aliases
|
||||||
|
router.delete('/me/partner', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE guardian_id=$1', [req.user.id]);
|
||||||
|
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [req.user.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// List current user's aliases (includes partner's aliases)
|
||||||
|
router.get('/me/aliases', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const aliases = await query(req.schema,
|
||||||
|
`SELECT id,first_name,last_name,email,date_of_birth,avatar,phone
|
||||||
|
FROM guardian_aliases
|
||||||
|
WHERE guardian_id=$1
|
||||||
|
OR guardian_id IN (
|
||||||
|
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||||
|
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||||
|
)
|
||||||
|
ORDER BY first_name,last_name`,
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ aliases });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create alias
|
||||||
|
router.post('/me/aliases', authMiddleware, async (req, res) => {
|
||||||
|
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
|
||||||
|
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
|
||||||
|
try {
|
||||||
|
const r = await queryResult(req.schema,
|
||||||
|
'INSERT INTO guardian_aliases (guardian_id,first_name,last_name,email,date_of_birth,phone) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id',
|
||||||
|
[req.user.id, firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null]
|
||||||
|
);
|
||||||
|
const aliasId = r.rows[0].id;
|
||||||
|
|
||||||
|
// Auto-add alias to players group if designated
|
||||||
|
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||||
|
const playersGroupId = parseInt(playersRow?.value);
|
||||||
|
if (playersGroupId) {
|
||||||
|
await exec(req.schema,
|
||||||
|
'INSERT INTO alias_group_members (user_group_id,alias_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||||
|
[playersGroupId, aliasId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const alias = await queryOne(req.schema,
|
||||||
|
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
|
||||||
|
[aliasId]
|
||||||
|
);
|
||||||
|
res.json({ alias });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update alias
|
||||||
|
router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
|
||||||
|
const aliasId = parseInt(req.params.aliasId);
|
||||||
|
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
|
||||||
|
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
|
||||||
|
try {
|
||||||
|
const existing = await queryOne(req.schema,
|
||||||
|
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||||
|
guardian_id=$2 OR guardian_id IN (
|
||||||
|
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||||
|
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
[aliasId, req.user.id]);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||||
|
await exec(req.schema,
|
||||||
|
'UPDATE guardian_aliases SET first_name=$1,last_name=$2,email=$3,date_of_birth=$4,phone=$5,updated_at=NOW() WHERE id=$6',
|
||||||
|
[firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null, aliasId]
|
||||||
|
);
|
||||||
|
const alias = await queryOne(req.schema,
|
||||||
|
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
|
||||||
|
[aliasId]
|
||||||
|
);
|
||||||
|
res.json({ alias });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete alias
|
||||||
|
router.delete('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
|
||||||
|
const aliasId = parseInt(req.params.aliasId);
|
||||||
|
try {
|
||||||
|
const existing = await queryOne(req.schema,
|
||||||
|
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||||
|
guardian_id=$2 OR guardian_id IN (
|
||||||
|
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||||
|
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
[aliasId, req.user.id]);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||||
|
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE id=$1', [aliasId]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload alias avatar
|
||||||
|
router.post('/me/aliases/:aliasId/avatar', authMiddleware, uploadAliasAvatar.single('avatar'), async (req, res) => {
|
||||||
|
const aliasId = parseInt(req.params.aliasId);
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
try {
|
||||||
|
const existing = await queryOne(req.schema,
|
||||||
|
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||||
|
guardian_id=$2 OR guardian_id IN (
|
||||||
|
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||||
|
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
[aliasId, req.user.id]);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const filePath = req.file.path;
|
||||||
|
const MAX_DIM = 256;
|
||||||
|
const image = sharp(filePath);
|
||||||
|
const meta = await image.metadata();
|
||||||
|
const needsResize = meta.width > MAX_DIM || meta.height > MAX_DIM;
|
||||||
|
let avatarUrl;
|
||||||
|
if (req.file.size >= 500 * 1024 || needsResize) {
|
||||||
|
const outPath = filePath.replace(/\.[^.]+$/, '.webp');
|
||||||
|
await sharp(filePath).resize(MAX_DIM,MAX_DIM,{fit:'cover',withoutEnlargement:true}).webp({quality:82}).toFile(outPath);
|
||||||
|
require('fs').unlinkSync(filePath);
|
||||||
|
avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
|
||||||
|
} else {
|
||||||
|
avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||||
|
}
|
||||||
|
await exec(req.schema, 'UPDATE guardian_aliases SET avatar=$1,updated_at=NOW() WHERE id=$2', [avatarUrl, aliasId]);
|
||||||
|
res.json({ avatarUrl });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search minor users (Mixed Age — for Add Child in profile)
|
||||||
|
router.get('/search-minors', authMiddleware, async (req, res) => {
|
||||||
|
const { q } = req.query;
|
||||||
|
try {
|
||||||
|
const users = await query(req.schema,
|
||||||
|
`SELECT id,name,first_name,last_name,date_of_birth,avatar,phone FROM users
|
||||||
|
WHERE is_minor=TRUE AND status='suspended' AND guardian_user_id IS NULL AND status!='deleted'
|
||||||
|
AND (name ILIKE $1 OR first_name ILIKE $1 OR last_name ILIKE $1)
|
||||||
|
ORDER BY name ASC LIMIT 20`,
|
||||||
|
[`%${q || ''}%`]
|
||||||
|
);
|
||||||
|
res.json({ users });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Approve guardian link (Mixed Age — manager+ sets guardian, clears approval flag, unsuspends)
|
||||||
|
router.patch('/:id/approve-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
try {
|
||||||
|
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
|
||||||
|
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||||
|
if (!minor.guardian_approval_required) return res.status(400).json({ error: 'No pending approval' });
|
||||||
|
await exec(req.schema,
|
||||||
|
"UPDATE users SET guardian_approval_required=FALSE,status='active',updated_at=NOW() WHERE id=$1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
await addUserToPublicGroups(req.schema, id);
|
||||||
|
const user = await queryOne(req.schema,
|
||||||
|
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json({ user });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deny guardian link (Mixed Age — clears guardian, keeps suspended)
|
||||||
|
router.patch('/:id/deny-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
try {
|
||||||
|
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
|
||||||
|
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||||
|
await exec(req.schema,
|
||||||
|
'UPDATE users SET guardian_approval_required=FALSE,guardian_user_id=NULL,updated_at=NOW() WHERE id=$1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const user = await queryOne(req.schema,
|
||||||
|
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json({ user });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// List minor players available for this guardian to claim (Mixed Age — Family Manager)
|
||||||
|
// Returns minors in the players group who either have no guardian yet or are already linked to me.
|
||||||
|
router.get('/minor-players', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||||
|
const playersGroupId = parseInt(playersRow?.value);
|
||||||
|
if (!playersGroupId) return res.json({ users: [] });
|
||||||
|
const users = await query(req.schema,
|
||||||
|
`SELECT u.id,u.name,u.first_name,u.last_name,u.date_of_birth,u.avatar,u.status,u.guardian_user_id
|
||||||
|
FROM users u
|
||||||
|
JOIN user_group_members ugm ON ugm.user_id=u.id AND ugm.user_group_id=$1
|
||||||
|
WHERE u.is_minor=TRUE AND u.status!='deleted'
|
||||||
|
AND (u.guardian_user_id IS NULL OR u.guardian_user_id=$2)
|
||||||
|
ORDER BY u.first_name,u.last_name`,
|
||||||
|
[playersGroupId, req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ users });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claim minor as guardian (Mixed Age — Family Manager direct link, no approval needed)
|
||||||
|
// dateOfBirth is required to activate the minor — without it the guardian is saved but the account stays suspended.
|
||||||
|
router.post('/me/guardian-children/:minorId', authMiddleware, async (req, res) => {
|
||||||
|
const minorId = parseInt(req.params.minorId);
|
||||||
|
const { dateOfBirth } = req.body;
|
||||||
|
try {
|
||||||
|
const minor = await queryOne(req.schema, "SELECT * FROM users WHERE id=$1 AND status!='deleted'", [minorId]);
|
||||||
|
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||||
|
if (!minor.is_minor) return res.status(400).json({ error: 'User is not a minor' });
|
||||||
|
if (minor.guardian_user_id && minor.guardian_user_id !== req.user.id)
|
||||||
|
return res.status(409).json({ error: 'This minor already has a guardian' });
|
||||||
|
const dob = dateOfBirth || minor.date_of_birth || null;
|
||||||
|
const isMinor = dob ? isMinorFromDOB(dob) : minor.is_minor;
|
||||||
|
const shouldActivate = !!dob;
|
||||||
|
const newStatus = shouldActivate ? 'active' : 'suspended';
|
||||||
|
await exec(req.schema,
|
||||||
|
'UPDATE users SET guardian_user_id=$1,guardian_approval_required=FALSE,date_of_birth=$2,is_minor=$3,status=$4,updated_at=NOW() WHERE id=$5',
|
||||||
|
[req.user.id, dob, isMinor, newStatus, minorId]
|
||||||
|
);
|
||||||
|
if (shouldActivate) await addUserToPublicGroups(req.schema, minorId);
|
||||||
|
const user = await queryOne(req.schema,
|
||||||
|
'SELECT id,name,first_name,last_name,date_of_birth,avatar,status,guardian_user_id FROM users WHERE id=$1',
|
||||||
|
[minorId]
|
||||||
|
);
|
||||||
|
res.json({ user });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove minor from guardian's list (Mixed Age — re-suspends the minor)
|
||||||
|
router.delete('/me/guardian-children/:minorId', authMiddleware, async (req, res) => {
|
||||||
|
const minorId = parseInt(req.params.minorId);
|
||||||
|
try {
|
||||||
|
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]);
|
||||||
|
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||||
|
if (minor.guardian_user_id !== req.user.id)
|
||||||
|
return res.status(403).json({ error: 'You are not the guardian of this user' });
|
||||||
|
await exec(req.schema,
|
||||||
|
"UPDATE users SET guardian_user_id=NULL,status='suspended',updated_at=NOW() WHERE id=$1",
|
||||||
|
[minorId]
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Guardian self-link (Mixed Age — user links themselves as guardian of a minor, triggers approval)
|
||||||
|
router.patch('/me/link-minor/:minorId', authMiddleware, async (req, res) => {
|
||||||
|
const minorId = parseInt(req.params.minorId);
|
||||||
|
try {
|
||||||
|
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]);
|
||||||
|
if (!minor) return res.status(404).json({ error: 'Minor user not found' });
|
||||||
|
if (!minor.is_minor) return res.status(400).json({ error: 'User is not flagged as a minor' });
|
||||||
|
if (minor.guardian_user_id && !minor.guardian_approval_required)
|
||||||
|
return res.status(400).json({ error: 'This minor already has an approved guardian' });
|
||||||
|
await exec(req.schema,
|
||||||
|
'UPDATE users SET guardian_user_id=$1,guardian_approval_required=TRUE,updated_at=NOW() WHERE id=$2',
|
||||||
|
[req.user.id, minorId]
|
||||||
|
);
|
||||||
|
res.json({ success: true, pendingApproval: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.42}"
|
VERSION="${1:-0.13.1}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="rosterchirp"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.12.42",
|
"version": "0.13.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
434
frontend/src/components/AddChildAliasModal.jsx
Normal file
434
frontend/src/components/AddChildAliasModal.jsx
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
|
||||||
|
export default function AddChildAliasModal({ features = {}, onClose }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const loginType = features.loginType || 'guardian_only';
|
||||||
|
const isMixedAge = loginType === 'mixed_age';
|
||||||
|
|
||||||
|
// ── Guardian-only state (alias form) ──────────────────────────────────────
|
||||||
|
const [aliases, setAliases] = useState([]);
|
||||||
|
const [editingAlias, setEditingAlias] = useState(null);
|
||||||
|
const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||||
|
const [avatarFile, setAvatarFile] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// ── Mixed-age state (real minor users) ────────────────────────────────────
|
||||||
|
const [minorPlayers, setMinorPlayers] = useState([]); // available + already-mine
|
||||||
|
const [selectedMinorId, setSelectedMinorId] = useState('');
|
||||||
|
const [childDob, setChildDob] = useState('');
|
||||||
|
const [addingMinor, setAddingMinor] = useState(false);
|
||||||
|
|
||||||
|
// ── Partner state (shared) ────────────────────────────────────────────────
|
||||||
|
const [partner, setPartner] = useState(null);
|
||||||
|
const [selectedPartnerId, setSelectedPartnerId] = useState('');
|
||||||
|
const [respondSeparately, setRespondSeparately] = useState(false);
|
||||||
|
const [allUsers, setAllUsers] = useState([]);
|
||||||
|
const [savingPartner, setSavingPartner] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loads = [api.getPartner(), api.searchUsers('')];
|
||||||
|
if (isMixedAge) {
|
||||||
|
loads.push(api.getMinorPlayers());
|
||||||
|
} else {
|
||||||
|
loads.push(api.getAliases());
|
||||||
|
}
|
||||||
|
Promise.all(loads).then(([partnerRes, usersRes, thirdRes]) => {
|
||||||
|
const p = partnerRes.partner || null;
|
||||||
|
setPartner(p);
|
||||||
|
setSelectedPartnerId(p?.id?.toString() || '');
|
||||||
|
setRespondSeparately(p?.respond_separately || false);
|
||||||
|
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id && !u.is_default_admin));
|
||||||
|
if (isMixedAge) {
|
||||||
|
setMinorPlayers(thirdRes.users || []);
|
||||||
|
} else {
|
||||||
|
setAliases(thirdRes.aliases || []);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [isMixedAge]);
|
||||||
|
|
||||||
|
// Pre-populate DOB when a minor is selected from the dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMinorId) { setChildDob(''); return; }
|
||||||
|
const minor = availableMinors.find(u => u.id === parseInt(selectedMinorId));
|
||||||
|
setChildDob(minor?.date_of_birth ? minor.date_of_birth.slice(0, 10) : '');
|
||||||
|
}, [selectedMinorId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEditingAlias(null);
|
||||||
|
setForm({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||||
|
setAvatarFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lbl = (text, required) => (
|
||||||
|
<label className="text-sm" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
|
||||||
|
{text}{required && <span style={{ color: 'var(--error)', marginLeft: 2 }}>*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Partner handlers ──────────────────────────────────────────────────────
|
||||||
|
const handleSavePartner = async () => {
|
||||||
|
setSavingPartner(true);
|
||||||
|
try {
|
||||||
|
if (!selectedPartnerId) {
|
||||||
|
await api.removePartner();
|
||||||
|
setPartner(null);
|
||||||
|
setRespondSeparately(false);
|
||||||
|
if (!isMixedAge) {
|
||||||
|
const { aliases: fresh } = await api.getAliases();
|
||||||
|
setAliases(fresh || []);
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
const { users: fresh } = await api.getMinorPlayers();
|
||||||
|
setMinorPlayers(fresh || []);
|
||||||
|
}
|
||||||
|
toast('Spouse/Partner/Co-Parent removed', 'success');
|
||||||
|
} else {
|
||||||
|
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately);
|
||||||
|
setPartner(p);
|
||||||
|
setRespondSeparately(p?.respond_separately || false);
|
||||||
|
if (!isMixedAge) {
|
||||||
|
const { aliases: fresh } = await api.getAliases();
|
||||||
|
setAliases(fresh || []);
|
||||||
|
}
|
||||||
|
toast('Spouse/Partner/Co-Parent saved', 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setSavingPartner(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Guardian-only alias handlers ──────────────────────────────────────────
|
||||||
|
const handleSelectAlias = (a) => {
|
||||||
|
if (editingAlias?.id === a.id) { resetForm(); return; }
|
||||||
|
setEditingAlias(a);
|
||||||
|
setForm({
|
||||||
|
firstName: a.first_name || '',
|
||||||
|
lastName: a.last_name || '',
|
||||||
|
dob: a.date_of_birth ? a.date_of_birth.slice(0, 10) : '',
|
||||||
|
phone: a.phone || '',
|
||||||
|
email: a.email || '',
|
||||||
|
});
|
||||||
|
setAvatarFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAlias = async () => {
|
||||||
|
if (!form.firstName.trim() || !form.lastName.trim())
|
||||||
|
return toast('First and last name required', 'error');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingAlias) {
|
||||||
|
await api.updateAlias(editingAlias.id, {
|
||||||
|
firstName: form.firstName.trim(),
|
||||||
|
lastName: form.lastName.trim(),
|
||||||
|
dateOfBirth: form.dob || null,
|
||||||
|
phone: form.phone || null,
|
||||||
|
email: form.email || null,
|
||||||
|
});
|
||||||
|
if (avatarFile) await api.uploadAliasAvatar(editingAlias.id, avatarFile);
|
||||||
|
toast('Child alias updated', 'success');
|
||||||
|
} else {
|
||||||
|
const { alias } = await api.createAlias({
|
||||||
|
firstName: form.firstName.trim(),
|
||||||
|
lastName: form.lastName.trim(),
|
||||||
|
dateOfBirth: form.dob || null,
|
||||||
|
phone: form.phone || null,
|
||||||
|
email: form.email || null,
|
||||||
|
});
|
||||||
|
if (avatarFile) await api.uploadAliasAvatar(alias.id, avatarFile);
|
||||||
|
toast('Child alias added', 'success');
|
||||||
|
}
|
||||||
|
const { aliases: fresh } = await api.getAliases();
|
||||||
|
setAliases(fresh || []);
|
||||||
|
resetForm();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAlias = async (e, aliasId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.deleteAlias(aliasId);
|
||||||
|
setAliases(prev => prev.filter(a => a.id !== aliasId));
|
||||||
|
if (editingAlias?.id === aliasId) resetForm();
|
||||||
|
toast('Child alias removed', 'success');
|
||||||
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Mixed-age minor handlers ──────────────────────────────────────────────
|
||||||
|
const myMinors = minorPlayers.filter(u => u.guardian_user_id === currentUser?.id);
|
||||||
|
const availableMinors = minorPlayers.filter(u => !u.guardian_user_id);
|
||||||
|
|
||||||
|
const handleAddMinor = async () => {
|
||||||
|
if (!selectedMinorId) return;
|
||||||
|
if (!childDob.trim()) return toast('Date of Birth is required', 'error');
|
||||||
|
setAddingMinor(true);
|
||||||
|
try {
|
||||||
|
await api.addGuardianChild(parseInt(selectedMinorId), childDob.trim());
|
||||||
|
const { users: fresh } = await api.getMinorPlayers();
|
||||||
|
setMinorPlayers(fresh || []);
|
||||||
|
setSelectedMinorId('');
|
||||||
|
setChildDob('');
|
||||||
|
toast('Child added and account activated', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setAddingMinor(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMinor = async (e, minorId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.removeGuardianChild(minorId);
|
||||||
|
const { users: fresh } = await api.getMinorPlayers();
|
||||||
|
setMinorPlayers(fresh || []);
|
||||||
|
toast('Child removed', 'success');
|
||||||
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>Family Manager</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose} aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spouse/Partner/Co-Parent section */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
{lbl('Spouse/Partner/Co-Parent')}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={selectedPartnerId}
|
||||||
|
onChange={e => setSelectedPartnerId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{allUsers.map(u => (
|
||||||
|
<option key={u.id} value={u.id}>{u.display_name || u.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSavePartner}
|
||||||
|
disabled={savingPartner}
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{savingPartner ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, cursor: 'pointer', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={respondSeparately}
|
||||||
|
onChange={e => setRespondSeparately(e.target.checked)}
|
||||||
|
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: 'var(--primary)' }}
|
||||||
|
/>
|
||||||
|
Respond separately to events
|
||||||
|
</label>
|
||||||
|
{partner && (
|
||||||
|
<div className="text-sm" style={{ color: 'var(--text-secondary)', marginTop: 6 }}>
|
||||||
|
Linked with {partner.display_name || partner.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Mixed Age: link real minor users ── */}
|
||||||
|
{isMixedAge && (
|
||||||
|
<>
|
||||||
|
{/* Current children list */}
|
||||||
|
{myMinors.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
|
Your Children
|
||||||
|
</div>
|
||||||
|
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||||
|
{myMinors.map((u, i) => (
|
||||||
|
<div
|
||||||
|
key={u.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '9px 12px',
|
||||||
|
borderBottom: i < myMinors.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1, fontSize: 14 }}>{u.first_name} {u.last_name}</span>
|
||||||
|
{u.date_of_birth && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||||
|
{u.date_of_birth.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={e => handleRemoveMinor(e, u.id)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
|
||||||
|
aria-label="Remove"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add minor from players group */}
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
|
Add Child
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
value={selectedMinorId}
|
||||||
|
onChange={e => setSelectedMinorId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Select a player —</option>
|
||||||
|
{availableMinors.map(u => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.first_name} {u.last_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
{lbl('Date of Birth', true)}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
value={childDob}
|
||||||
|
onChange={e => setChildDob(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
style={childDob === '' && selectedMinorId ? { borderColor: 'var(--error)' } : {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleAddMinor}
|
||||||
|
disabled={addingMinor || !selectedMinorId || !childDob.trim()}
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{addingMinor ? 'Adding…' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{availableMinors.length === 0 && myMinors.length === 0 && (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||||
|
No minor players available to link.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Guardian Only: alias form ── */}
|
||||||
|
{!isMixedAge && (
|
||||||
|
<>
|
||||||
|
{/* Existing aliases list */}
|
||||||
|
{aliases.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
|
Your Children — click to edit
|
||||||
|
</div>
|
||||||
|
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||||
|
{aliases.map((a, i) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => handleSelectAlias(a)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '9px 12px', cursor: 'pointer',
|
||||||
|
borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
background: editingAlias?.id === a.id ? 'var(--primary-light)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1, fontSize: 14, fontWeight: editingAlias?.id === a.id ? 600 : 400 }}>
|
||||||
|
{a.first_name} {a.last_name}
|
||||||
|
</span>
|
||||||
|
{a.date_of_birth && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||||
|
{a.date_of_birth.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={e => handleDeleteAlias(e, a.id)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
|
||||||
|
aria-label="Remove"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form section label */}
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>
|
||||||
|
{editingAlias
|
||||||
|
? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}`
|
||||||
|
: 'Add Child'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
|
<div>
|
||||||
|
{lbl('First Name', true)}
|
||||||
|
<input className="input" value={form.firstName} onChange={set('firstName')}
|
||||||
|
autoComplete="off" autoCapitalize="words" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{lbl('Last Name', true)}
|
||||||
|
<input className="input" value={form.lastName} onChange={set('lastName')}
|
||||||
|
autoComplete="off" autoCapitalize="words" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{lbl('Date of Birth')}
|
||||||
|
<input className="input" placeholder="YYYY-MM-DD" value={form.dob} onChange={set('dob')}
|
||||||
|
autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{lbl('Phone')}
|
||||||
|
<input className="input" type="tel" value={form.phone} onChange={set('phone')}
|
||||||
|
autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{lbl('Email (optional)')}
|
||||||
|
<input className="input" type="email" value={form.email} onChange={set('email')}
|
||||||
|
autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{lbl('Avatar (optional)')}
|
||||||
|
<input type="file" accept="image/*"
|
||||||
|
onChange={e => setAvatarFile(e.target.files?.[0] || null)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||||
|
{editingAlias && (
|
||||||
|
<button className="btn btn-secondary" onClick={resetForm}>Cancel Edit</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" onClick={handleSaveAlias} disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : editingAlias ? 'Update Alias' : 'Add Alias'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -164,21 +164,28 @@ function ProvisionModal({ api, baseDomain, onClose, onDone, toast }) {
|
|||||||
|
|
||||||
function EditModal({ api, tenant, onClose, onDone }) {
|
function EditModal({ api, tenant, onClose, onDone }) {
|
||||||
const [form, setForm] = useState({ name: tenant.name, plan: tenant.plan, customDomain: tenant.custom_domain || '' });
|
const [form, setForm] = useState({ name: tenant.name, plan: tenant.plan, customDomain: tenant.custom_domain || '' });
|
||||||
|
const [adminPassword, setAdminPassword] = useState('');
|
||||||
|
const [showAdminPass, setShowAdminPass] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const set = k => v => setForm(f => ({ ...f, [k]: v }));
|
const set = k => v => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
const handle = async () => {
|
const handle = async () => {
|
||||||
|
if (adminPassword && adminPassword.length < 6)
|
||||||
|
return setError('Admin password must be at least 6 characters');
|
||||||
setSaving(true); setError('');
|
setSaving(true); setError('');
|
||||||
try {
|
try {
|
||||||
const { tenant: updated } = await api.updateTenant(tenant.slug, {
|
const { tenant: updated } = await api.updateTenant(tenant.slug, {
|
||||||
name: form.name || undefined, plan: form.plan, customDomain: form.customDomain || null,
|
name: form.name || undefined, plan: form.plan, customDomain: form.customDomain || null,
|
||||||
|
...(adminPassword ? { adminPassword } : {}),
|
||||||
});
|
});
|
||||||
onDone(updated);
|
onDone(updated);
|
||||||
} catch (e) { setError(e.message); }
|
} catch (e) { setError(e.message); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const adminEmail = tenant.admin_email || '(uses system default from .env)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
@@ -191,6 +198,41 @@ function EditModal({ api, tenant, onClose, onDone }) {
|
|||||||
<Field label="Display Name" value={form.name} onChange={set('name')} />
|
<Field label="Display Name" value={form.name} onChange={set('name')} />
|
||||||
<FieldSelect label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} />
|
<FieldSelect label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} />
|
||||||
<Field label="Custom Domain" value={form.customDomain} onChange={set('customDomain')} placeholder="chat.example.com" hint="Leave blank to remove" />
|
<Field label="Custom Domain" value={form.customDomain} onChange={set('customDomain')} placeholder="chat.example.com" hint="Leave blank to remove" />
|
||||||
|
<div style={{ borderTop:'1px solid var(--border)', paddingTop:12 }}>
|
||||||
|
<div style={{ fontSize:11, fontWeight:700, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px', marginBottom:10 }}>Admin Account</div>
|
||||||
|
<FieldGroup label="Login Email (read-only)">
|
||||||
|
<input type="text" value={adminEmail} readOnly
|
||||||
|
className="input" style={{ fontSize:13, opacity:0.7, cursor:'default' }} />
|
||||||
|
</FieldGroup>
|
||||||
|
<div style={{ marginTop:10 }}>
|
||||||
|
<FieldGroup label="Reset Admin Password" >
|
||||||
|
<div style={{ position:'relative' }}>
|
||||||
|
<input
|
||||||
|
type={showAdminPass ? 'text' : 'password'}
|
||||||
|
value={adminPassword}
|
||||||
|
onChange={e => setAdminPassword(e.target.value)}
|
||||||
|
placeholder="Leave blank to keep current password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="input"
|
||||||
|
style={{ fontSize:13, paddingRight:40 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdminPass(v => !v)}
|
||||||
|
style={{ position:'absolute', right:10, top:'50%', transform:'translateY(-50%)', background:'none', border:'none', cursor:'pointer', color:'var(--text-tertiary)', padding:0, display:'flex', alignItems:'center' }}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showAdminPass ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize:11, color:'var(--text-tertiary)' }}>Admin will be required to change password on next login</span>
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ display:'flex', justifyContent:'flex-end', gap:8 }}>
|
<div style={{ display:'flex', justifyContent:'flex-end', gap:8 }}>
|
||||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
<button className="btn btn-primary" onClick={handle} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
|
<button className="btn btn-primary" onClick={handle} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: calc(0.875rem * var(--font-scale));
|
font-size: 0.875rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ const NAV_ICON = {
|
|||||||
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
|
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, features = {}, currentPage = 'chat', isMobile = false, unreadMessages = false, unreadGroupMessages = false }) {
|
export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, onAddChild, features = {}, currentPage = 'chat', isMobile = false, unreadMessages = false, unreadGroupMessages = false }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const drawerRef = useRef(null);
|
const drawerRef = useRef(null);
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
const userGroupIds = features.userGroupMemberships || [];
|
const userGroupIds = features.userGroupMemberships || [];
|
||||||
const canAccessTools = isAdmin || user?.role === 'manager' || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid));
|
const canAccessTools = isAdmin || user?.role === 'manager' || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid));
|
||||||
const hasUserGroups = userGroupIds.length > 0;
|
const hasUserGroups = userGroupIds.length > 0;
|
||||||
|
const showAddChild = (features.loginType === 'guardian_only' || features.loginType === 'mixed_age') && features.inGuardiansGroup;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -80,11 +81,16 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tools section */}
|
{/* Tools section */}
|
||||||
{canAccessTools && (
|
{(canAccessTools || showAddChild) && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-drawer-section-label admin">Tools</div>
|
<div className="nav-drawer-section-label admin">Tools</div>
|
||||||
{item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })}
|
{canAccessTools && item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })}
|
||||||
{features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })}
|
{canAccessTools && features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })}
|
||||||
|
{showAddChild && onAddChild && item(
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>,
|
||||||
|
'Family Manager',
|
||||||
|
onAddChild
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
const msgPublic = features.msgPublic ?? true;
|
const msgPublic = features.msgPublic ?? true;
|
||||||
const msgU2U = features.msgU2U ?? true;
|
const msgU2U = features.msgU2U ?? true;
|
||||||
const msgPrivateGroup = features.msgPrivateGroup ?? true;
|
const msgPrivateGroup = features.msgPrivateGroup ?? true;
|
||||||
|
const loginType = features.loginType || 'all_ages';
|
||||||
|
|
||||||
// Default to private if available, otherwise public
|
// Default to private if available, otherwise public
|
||||||
const defaultTab = (msgU2U || msgPrivateGroup) ? 'private' : 'public';
|
const defaultTab = (msgU2U || msgPrivateGroup) ? 'private' : 'public';
|
||||||
@@ -21,6 +22,8 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
// Pre-confirmation for minor members (shown before creating the chat)
|
||||||
|
const [minorConfirm, setMinorConfirm] = useState(null); // { minorNames: [] } — pending create
|
||||||
|
|
||||||
// True when exactly 1 user selected on private tab AND U2U messages are enabled
|
// True when exactly 1 user selected on private tab AND U2U messages are enabled
|
||||||
const isDirect = tab === 'private' && selected.length === 1 && msgU2U;
|
const isDirect = tab === 'private' && selected.length === 1 && msgU2U;
|
||||||
@@ -45,16 +48,11 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const doCreate = async () => {
|
||||||
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
|
||||||
if (tab === 'private' && !isDirect && !name.trim()) return toast('Name required', 'error');
|
|
||||||
if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let payload;
|
let payload;
|
||||||
if (isDirect) {
|
if (isDirect) {
|
||||||
// Direct message: no name, isDirect flag
|
|
||||||
payload = {
|
payload = {
|
||||||
type: 'private',
|
type: 'private',
|
||||||
memberIds: selected.map(u => u.id),
|
memberIds: selected.map(u => u.id),
|
||||||
@@ -69,11 +67,14 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { group, duplicate } = await api.createGroup(payload);
|
const { group, duplicate, guardianAdded } = await api.createGroup(payload);
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
toast('A group with these members already exists — opening it now.', 'info');
|
toast('A group with these members already exists — opening it now.', 'info');
|
||||||
} else {
|
} else {
|
||||||
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
|
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
|
||||||
|
if (guardianAdded) {
|
||||||
|
toast('A guardian has been added to this conversation.', 'info');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onCreated(group);
|
onCreated(group);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -83,6 +84,23 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
||||||
|
if (tab === 'private' && !isDirect && !name.trim()) return toast('Name required', 'error');
|
||||||
|
if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
|
||||||
|
|
||||||
|
// Mixed Age: warn if any selected member is a minor (and initiator is not a minor)
|
||||||
|
if (loginType === 'mixed_age' && !user.is_minor) {
|
||||||
|
const minors = selected.filter(u => u.is_minor);
|
||||||
|
if (minors.length > 0) {
|
||||||
|
setMinorConfirm({ minorNames: minors.map(u => u.display_name || u.name) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await doCreate();
|
||||||
|
};
|
||||||
|
|
||||||
// Placeholder for the name field
|
// Placeholder for the name field
|
||||||
const namePlaceholder = isDirect
|
const namePlaceholder = isDirect
|
||||||
? selected[0]?.name || ''
|
? selected[0]?.name || ''
|
||||||
@@ -172,6 +190,30 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pre-confirmation modal: minor member warning */}
|
||||||
|
{minorConfirm && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal" style={{ maxWidth: 380 }}>
|
||||||
|
<h2 className="modal-title" style={{ marginBottom: 12 }}>Guardian Notice</h2>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
|
The following member{minorConfirm.minorNames.length > 1 ? 's are' : ' is'} a minor:
|
||||||
|
</p>
|
||||||
|
<ul style={{ marginBottom: 16, paddingLeft: 20 }}>
|
||||||
|
{minorConfirm.minorNames.map(n => (
|
||||||
|
<li key={n} className="text-sm" style={{ color: 'var(--text-primary)' }}>{n}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 20 }}>
|
||||||
|
Their designated guardian(s) will be automatically added to this conversation. Do you want to proceed?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setMinorConfirm(null)}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => { setMinorConfirm(null); doCreate(); }}>Proceed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export default function ProfileModal({ onClose }) {
|
|||||||
const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
|
const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
|
||||||
const [displayNameWarning, setDisplayNameWarning] = useState('');
|
const [displayNameWarning, setDisplayNameWarning] = useState('');
|
||||||
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
|
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
|
||||||
|
const [dob, setDob] = useState(user?.date_of_birth ? user.date_of_birth.slice(0, 10) : '');
|
||||||
|
const [phone, setPhone] = useState(user?.phone || '');
|
||||||
const [currentPw, setCurrentPw] = useState('');
|
const [currentPw, setCurrentPw] = useState('');
|
||||||
const [newPw, setNewPw] = useState('');
|
const [newPw, setNewPw] = useState('');
|
||||||
const [confirmPw, setConfirmPw] = useState('');
|
const [confirmPw, setConfirmPw] = useState('');
|
||||||
@@ -28,10 +30,17 @@ export default function ProfileModal({ onClose }) {
|
|||||||
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
|
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
|
||||||
);
|
);
|
||||||
const isIOS = /iphone|ipad/i.test(navigator.userAgent);
|
const isIOS = /iphone|ipad/i.test(navigator.userAgent);
|
||||||
|
const isAndroid = /android/i.test(navigator.userAgent);
|
||||||
|
const isDesktop = !isIOS && !isAndroid;
|
||||||
const isStandalone = window.navigator.standalone === true;
|
const isStandalone = window.navigator.standalone === true;
|
||||||
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
||||||
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
|
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
|
||||||
|
|
||||||
|
// Minor age protection — DOB/phone display + mixed_age forced-DOB gate
|
||||||
|
const [loginType, setLoginType] = useState('all_ages');
|
||||||
|
// True when mixed_age mode and the user still has no DOB on record
|
||||||
|
const needsDob = loginType === 'mixed_age' && !user?.date_of_birth;
|
||||||
|
|
||||||
const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY));
|
const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY));
|
||||||
const [fontScale, setFontScale] = useState(
|
const [fontScale, setFontScale] = useState(
|
||||||
(savedScale >= MIN_SCALE && savedScale <= MAX_SCALE) ? savedScale : 1.0
|
(savedScale >= MIN_SCALE && savedScale <= MAX_SCALE) ? savedScale : 1.0
|
||||||
@@ -43,6 +52,13 @@ export default function ProfileModal({ onClose }) {
|
|||||||
return () => window.removeEventListener('resize', onResize);
|
return () => window.removeEventListener('resize', onResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load login type for DOB/phone field visibility
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSettings().then(({ settings: s }) => {
|
||||||
|
setLoginType(s.feature_login_type || 'all_ages');
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const applyFontScale = (val) => {
|
const applyFontScale = (val) => {
|
||||||
setFontScale(val);
|
setFontScale(val);
|
||||||
document.documentElement.style.setProperty('--font-scale', val);
|
document.documentElement.style.setProperty('--font-scale', val);
|
||||||
@@ -53,7 +69,7 @@ export default function ProfileModal({ onClose }) {
|
|||||||
if (displayNameWarning) return toast('Display name is already in use', 'error');
|
if (displayNameWarning) return toast('Display name is already in use', 'error');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm });
|
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth: dob || null, phone: phone || null });
|
||||||
updateUser(updated);
|
updateUser(updated);
|
||||||
setSavedDisplayName(displayName);
|
setSavedDisplayName(displayName);
|
||||||
toast('Profile updated', 'success');
|
toast('Profile updated', 'success');
|
||||||
@@ -91,6 +107,55 @@ export default function ProfileModal({ onClose }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Forced DOB gate for mixed_age users ───────────────────────────────────
|
||||||
|
if (needsDob) {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal" style={{ maxWidth: 380 }}>
|
||||||
|
<h2 className="modal-title" style={{ marginBottom: 8 }}>Date of Birth Required</h2>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 16, lineHeight: 1.5 }}>
|
||||||
|
Your organisation requires a date of birth on file. Please enter yours to continue.
|
||||||
|
</p>
|
||||||
|
<div className="flex-col gap-1" style={{ marginBottom: 16 }}>
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Date of Birth <span style={{ color: 'var(--error)' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
value={dob}
|
||||||
|
onChange={e => setDob(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ borderColor: dob ? undefined : 'var(--error)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={loading || !dob.trim()}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!dob.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth: dob.trim(), phone: phone || null });
|
||||||
|
updateUser(updated);
|
||||||
|
toast('Profile updated', 'success');
|
||||||
|
// needsDob will re-evaluate to false now that user.date_of_birth is set
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Saving…' : 'Save & Continue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
@@ -126,27 +191,16 @@ export default function ProfileModal({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs — select on mobile, buttons on desktop */}
|
{/* Tab navigation — unified select list on all screen sizes */}
|
||||||
{isMobile ? (
|
<div style={{ marginBottom: 20 }}>
|
||||||
<select
|
<label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
|
||||||
className="input"
|
<select className="input" value={tab} onChange={e => { setTab(e.target.value); setPushResult(null); }}>
|
||||||
value={tab}
|
|
||||||
onChange={e => { setTab(e.target.value); setPushResult(null); }}
|
|
||||||
style={{ marginBottom: 20 }}
|
|
||||||
>
|
|
||||||
<option value="profile">Profile</option>
|
<option value="profile">Profile</option>
|
||||||
<option value="password">Change Password</option>
|
<option value="password">Change Password</option>
|
||||||
<option value="notifications">Notifications</option>
|
<option value="notifications">Notifications</option>
|
||||||
<option value="appearance">Appearance</option>
|
<option value="appearance">Appearance</option>
|
||||||
</select>
|
</select>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
|
||||||
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button>
|
|
||||||
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button>
|
|
||||||
<button className={`btn btn-sm ${tab === 'notifications' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setTab('notifications'); setPushResult(null); }}>Notifications</button>
|
|
||||||
<button className={`btn btn-sm ${tab === 'appearance' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('appearance')}>Appearance</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'profile' && (
|
{tab === 'profile' && (
|
||||||
<div className="flex-col gap-3">
|
<div className="flex-col gap-3">
|
||||||
@@ -206,6 +260,19 @@ export default function ProfileModal({ onClose }) {
|
|||||||
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} />
|
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} />
|
||||||
Allow others to send me direct messages
|
Allow others to send me direct messages
|
||||||
</label>
|
</label>
|
||||||
|
{/* Date of Birth + Phone — visible in Guardian Only / Mixed Age modes */}
|
||||||
|
{loginType !== 'all_ages' && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Date of Birth</label>
|
||||||
|
<input className="input" type="text" placeholder="YYYY-MM-DD" value={dob} onChange={e => setDob(e.target.value)} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Phone</label>
|
||||||
|
<input className="input" type="tel" placeholder="+1 555 000 0000" value={phone} onChange={e => setPhone(e.target.value)} autoComplete="tel" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
|
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
|
||||||
{loading ? 'Saving...' : 'Save Changes'}
|
{loading ? 'Saving...' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
@@ -214,7 +281,11 @@ export default function ProfileModal({ onClose }) {
|
|||||||
|
|
||||||
{tab === 'notifications' && (
|
{tab === 'notifications' && (
|
||||||
<div className="flex-col gap-3">
|
<div className="flex-col gap-3">
|
||||||
{isIOS && !isStandalone ? (
|
{isDesktop ? (
|
||||||
|
<div style={{ padding: '12px 14px', borderRadius: 8, background: 'var(--surface-variant)', border: '1px solid var(--border)', fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||||
|
In-app notifications are active on this device. Unread message counts and browser tab indicators update in real time — no additional setup needed.
|
||||||
|
</div>
|
||||||
|
) : isIOS && !isStandalone ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', borderRadius: 8, background: 'var(--surface-variant)', border: '1px solid var(--border)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', borderRadius: 8, background: 'var(--surface-variant)', border: '1px solid var(--border)' }}>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>Home Screen required for notifications</div>
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>Home Screen required for notifications</div>
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||||
@@ -376,7 +447,7 @@ export default function ProfileModal({ onClose }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
Pinch to zoom in the chat window also adjusts this setting.
|
Pinch to zoom adjusts font size for this session only.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -789,6 +789,22 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
const [noteSaving,setNoteSaving]=useState(false);
|
const [noteSaving,setNoteSaving]=useState(false);
|
||||||
const [avail,setAvail]=useState(event.availability||[]);
|
const [avail,setAvail]=useState(event.availability||[]);
|
||||||
const [expandedNotes,setExpandedNotes]=useState(new Set());
|
const [expandedNotes,setExpandedNotes]=useState(new Set());
|
||||||
|
const [responsesExpanded,setResponsesExpanded]=useState(false);
|
||||||
|
// Guardian Only: responder select ('all' | 'self' | 'alias:<id>' | 'partner:<id>')
|
||||||
|
const myAliases = event.my_aliases || [];
|
||||||
|
const myPartner = event.my_partner || null;
|
||||||
|
const showResponderSelect = !!(event.has_players_group && (myAliases.length > 0 || myPartner)) || !!(myPartner && event.in_guardians_group);
|
||||||
|
const [responder, setResponder] = useState(event.in_guardians_group ? 'self' : 'all');
|
||||||
|
|
||||||
|
// Response that should be highlighted for the currently selected responder
|
||||||
|
const activeResp = !showResponderSelect || responder === 'all'
|
||||||
|
? myResp
|
||||||
|
: responder === 'self'
|
||||||
|
? myResp
|
||||||
|
: responder.startsWith('alias:')
|
||||||
|
? (avail.find(r => r.is_alias && r.alias_id === parseInt(responder.replace('alias:','')))?.response || null)
|
||||||
|
: (avail.find(r => !r.is_alias && r.user_id === parseInt(responder.replace('partner:','')))?.response || null);
|
||||||
|
|
||||||
// Sync when parent reloads event after availability change
|
// Sync when parent reloads event after availability change
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
setMyResp(event.my_response);
|
setMyResp(event.my_response);
|
||||||
@@ -802,6 +818,49 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
const noteChanged = noteInput.trim() !== myNote.trim();
|
const noteChanged = noteInput.trim() !== myNote.trim();
|
||||||
|
|
||||||
const handleResp=async resp=>{
|
const handleResp=async resp=>{
|
||||||
|
// Guardian Only multi-responder logic
|
||||||
|
if (showResponderSelect) {
|
||||||
|
const note = noteInput.trim() || null;
|
||||||
|
// Build list of responders for this action
|
||||||
|
const targets = responder === 'all'
|
||||||
|
? [
|
||||||
|
...(event.in_guardians_group ? [{ type:'self' }] : []),
|
||||||
|
...myAliases.map(a => ({ type:'alias', aliasId:a.id })),
|
||||||
|
...(myPartner && !myPartner.respond_separately ? [{ type:'partner', userId:myPartner.id }] : []),
|
||||||
|
]
|
||||||
|
: responder === 'self'
|
||||||
|
? [{ type:'self' }]
|
||||||
|
: responder.startsWith('alias:')
|
||||||
|
? [{ type:'alias', aliasId:parseInt(responder.replace('alias:','')) }]
|
||||||
|
: [{ type:'partner', userId:parseInt(responder.replace('partner:','')) }];
|
||||||
|
|
||||||
|
const getCurrentResp = (t) =>
|
||||||
|
t.type === 'self' ? myResp
|
||||||
|
: t.type === 'alias' ? (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null)
|
||||||
|
: (avail.find(r => !r.is_alias && r.user_id === t.userId)?.response || null);
|
||||||
|
|
||||||
|
// For "All": toggle all off only when every target already has this response;
|
||||||
|
// otherwise set all to this response (avoids partial-toggle confusion)
|
||||||
|
const allHaveResp = responder === 'all' && targets.every(t => getCurrentResp(t) === resp);
|
||||||
|
try {
|
||||||
|
for (const t of targets) {
|
||||||
|
const prevResp = getCurrentResp(t);
|
||||||
|
const shouldDelete = responder === 'all' ? allHaveResp : prevResp === resp;
|
||||||
|
if (shouldDelete) {
|
||||||
|
await api.deleteAvailability(event.id, t.type === 'alias' ? t.aliasId : undefined, t.type === 'partner' ? t.userId : undefined);
|
||||||
|
} else {
|
||||||
|
await api.setAvailability(event.id, resp, note, t.type === 'alias' ? t.aliasId : undefined, t.type === 'partner' ? t.userId : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targets.some(t => t.type === 'self')) {
|
||||||
|
setMyResp(responder === 'all' ? (allHaveResp ? null : resp) : (myResp === resp ? null : resp));
|
||||||
|
}
|
||||||
|
onAvailabilityChange?.(resp);
|
||||||
|
} catch(e) { toast(e.message,'error'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal (non-Guardian-Only) path
|
||||||
const prev=myResp;
|
const prev=myResp;
|
||||||
const next=myResp===resp?null:resp;
|
const next=myResp===resp?null:resp;
|
||||||
setMyResp(next); // optimistic update
|
setMyResp(next); // optimistic update
|
||||||
@@ -826,6 +885,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
const handleDownloadAvailability = () => {
|
const handleDownloadAvailability = () => {
|
||||||
// Format as "Lastname, Firstname" using first_name/last_name fields when available
|
// Format as "Lastname, Firstname" using first_name/last_name fields when available
|
||||||
const fmtName = u => {
|
const fmtName = u => {
|
||||||
|
// Alias entries have first_name/last_name directly
|
||||||
const last = (u.last_name || '').trim();
|
const last = (u.last_name || '').trim();
|
||||||
const first = (u.first_name || '').trim();
|
const first = (u.first_name || '').trim();
|
||||||
if (last && first) return `${last}, ${first}`;
|
if (last && first) return `${last}, ${first}`;
|
||||||
@@ -864,14 +924,23 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
lines.push('');
|
lines.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
const safeName = (event.title || 'event').replace(/[^a-z0-9]+/gi, '_').toLowerCase();
|
const safeName = (event.title || 'event').replace(/[^a-z0-9]+/gi, '_').toLowerCase();
|
||||||
a.href = url;
|
const fileName = `availability_${safeName}.txt`;
|
||||||
a.download = `availability_${safeName}.txt`;
|
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
// On mobile use the native share sheet (lets the user choose Save to Files, etc.)
|
||||||
|
// On desktop fall back to a standard download link.
|
||||||
|
const file = new File([blob], fileName, { type: 'text/plain' });
|
||||||
|
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||||
|
navigator.share({ files: [file], title: fileName }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
@@ -932,11 +1001,24 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
<>
|
<>
|
||||||
<div style={{display:'flex',gap:8,marginBottom:12}}>
|
<div style={{display:'flex',gap:8,marginBottom:12}}>
|
||||||
{Object.entries(RESP_LABEL).map(([key,label])=>(
|
{Object.entries(RESP_LABEL).map(([key,label])=>(
|
||||||
<button key={key} onClick={()=>handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:myResp===key?RESP_COLOR[key]:'transparent',color:myResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
|
<button key={key} onClick={()=>handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:activeResp===key?RESP_COLOR[key]:'transparent',color:activeResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
|
||||||
{myResp===key?'✓ ':''}{label}
|
{activeResp===key?'✓ ':''}{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Guardian Only: responder select — shown when event targets the players group and user has aliases */}
|
||||||
|
{showResponderSelect && (
|
||||||
|
<div style={{marginBottom:10}}>
|
||||||
|
<label style={{fontSize:11,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px',display:'block',marginBottom:4}}>Responding for</label>
|
||||||
|
<select value={responder} onChange={e=>setResponder(e.target.value)}
|
||||||
|
style={{width:'100%',padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13}}>
|
||||||
|
{event.in_guardians_group && <option value="self">Myself</option>}
|
||||||
|
<option value="all">Entire Family</option>
|
||||||
|
{myPartner && !myPartner.respond_separately && <option value={`partner:${myPartner.id}`}>{myPartner.display_name || myPartner.name}</option>}
|
||||||
|
{myAliases.map(a=><option key={a.id} value={`alias:${a.id}`}>{a.first_name} {a.last_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{display:'flex',gap:8,alignItems:'center',marginBottom:16}}>
|
<div style={{display:'flex',gap:8,alignItems:'center',marginBottom:16}}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -957,39 +1039,60 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
)}
|
)}
|
||||||
{(isToolManager||avail.length>0)&&(
|
{(isToolManager||avail.length>0)&&(
|
||||||
<>
|
<>
|
||||||
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:8}}>Responses</div>
|
<div
|
||||||
<div style={{display:'flex',gap:20,marginBottom:10,fontSize:13}}>
|
onClick={()=>setResponsesExpanded(e=>!e)}
|
||||||
{Object.entries(counts).map(([k,n])=><span key={k}><span style={{color:RESP_COLOR[k],fontWeight:700}}>{n}</span> {RESP_LABEL[k]}</span>)}
|
style={{display:'flex',alignItems:'center',justifyContent:'space-between',cursor:'pointer',userSelect:'none',marginBottom:responsesExpanded?8:0}}
|
||||||
{isToolManager&&<span><span style={{fontWeight:700}}>{event.no_response_count||0}</span> No response</span>}
|
>
|
||||||
</div>
|
<span style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px'}}>Responses</span>
|
||||||
{avail.length>0&&(
|
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||||
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden'}}>
|
<div style={{display:'flex',gap:12,fontSize:12}}>
|
||||||
{avail.map(r=>{
|
{Object.entries(counts).map(([k,n])=><span key={k}><span style={{color:RESP_COLOR[k],fontWeight:700}}>{n}</span> {RESP_LABEL[k]}</span>)}
|
||||||
const hasNote=!!(r.note&&r.note.trim());
|
{isToolManager&&<span><span style={{fontWeight:700}}>{event.no_response_count||0}</span> No response</span>}
|
||||||
const expanded=expandedNotes.has(r.user_id);
|
</div>
|
||||||
return(
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:responsesExpanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
<div key={r.user_id} style={{borderBottom:'1px solid var(--border)'}}>
|
|
||||||
<div
|
|
||||||
style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',fontSize:13,cursor:hasNote?'pointer':'default'}}
|
|
||||||
onClick={hasNote?()=>toggleNote(r.user_id):undefined}
|
|
||||||
>
|
|
||||||
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
|
|
||||||
<span style={{flex:1}}>{r.display_name||r.name}</span>
|
|
||||||
{hasNote&&(
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:expanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
|
|
||||||
)}
|
|
||||||
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span>
|
|
||||||
</div>
|
|
||||||
{hasNote&&expanded&&(
|
|
||||||
<div style={{padding:'0 12px 10px 31px',fontSize:12,color:'var(--text-secondary)',fontStyle:'italic'}}>
|
|
||||||
{r.note}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
{responsesExpanded&&avail.length>0&&(()=>{
|
||||||
|
const RESP_ORDER={going:0,maybe:1,not_going:2};
|
||||||
|
const sortedAvail=[...avail].sort((a,b)=>{
|
||||||
|
const od=(RESP_ORDER[a.response]??99)-(RESP_ORDER[b.response]??99);
|
||||||
|
if(od!==0)return od;
|
||||||
|
const na=a.is_alias?`${a.first_name} ${a.last_name}`:(a.display_name||a.name||'');
|
||||||
|
const nb=b.is_alias?`${b.first_name} ${b.last_name}`:(b.display_name||b.name||'');
|
||||||
|
return na.localeCompare(nb);
|
||||||
|
});
|
||||||
|
return(
|
||||||
|
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden',maxHeight:avail.length>4?'140px':undefined,overflowY:avail.length>4?'auto':undefined}}>
|
||||||
|
{sortedAvail.map(r=>{
|
||||||
|
const rowKey=r.is_alias?`alias:${r.alias_id}`:`user:${r.user_id}`;
|
||||||
|
const displayName=r.is_alias?`${r.first_name} ${r.last_name}`:(r.display_name||r.name);
|
||||||
|
const hasNote=!!(r.note&&r.note.trim());
|
||||||
|
const expanded=expandedNotes.has(rowKey);
|
||||||
|
return(
|
||||||
|
<div key={rowKey} style={{borderBottom:'1px solid var(--border)'}}>
|
||||||
|
<div
|
||||||
|
style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',fontSize:13,cursor:hasNote?'pointer':'default'}}
|
||||||
|
onClick={hasNote?()=>toggleNote(rowKey):undefined}
|
||||||
|
>
|
||||||
|
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
|
||||||
|
<span style={{flex:1}}>{displayName}</span>
|
||||||
|
{r.is_alias&&<span style={{fontSize:11,color:'var(--text-tertiary)',fontStyle:'italic'}}>child</span>}
|
||||||
|
{hasNote&&(
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:expanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
)}
|
||||||
|
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span>
|
||||||
|
</div>
|
||||||
|
{hasNote&&expanded&&(
|
||||||
|
<div style={{padding:'0 12px 10px 31px',fontSize:12,color:'var(--text-secondary)',fontStyle:'italic'}}>
|
||||||
|
{r.note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function MessagesTab() {
|
|||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
{ key: 'msgPublic', label: 'Public Messages', desc: 'Public group channels visible to all members.' },
|
{ key: 'msgPublic', label: 'Public Messages', desc: 'Public group channels visible to all members.' },
|
||||||
{ key: 'msgGroup', label: 'Group Messages', desc: 'Private group messages managed by User Groups.' },
|
{ key: 'msgGroup', label: 'User Group Messages', desc: 'Private group messages managed by User Groups.' },
|
||||||
{ key: 'msgPrivateGroup', label: 'Private Group Messages', desc: 'Private multi-member group conversations.' },
|
{ key: 'msgPrivateGroup', label: 'Private Group Messages', desc: 'Private multi-member group conversations.' },
|
||||||
{ key: 'msgU2U', label: 'Private Messages (U2U)', desc: 'One-on-one direct messages between users.' },
|
{ key: 'msgU2U', label: 'Private Messages (U2U)', desc: 'One-on-one direct messages between users.' },
|
||||||
];
|
];
|
||||||
@@ -158,6 +158,124 @@ function TeamManagementTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Login Type Tab ────────────────────────────────────────────────────────────
|
||||||
|
const LOGIN_TYPE_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: 'all_ages',
|
||||||
|
label: 'Unrestricted (default)',
|
||||||
|
desc: 'No age restrictions. All users interact normally.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'guardian_only',
|
||||||
|
label: 'Guardian Only',
|
||||||
|
desc: "Parents/Guardians login one. Parents/Guardians are required to add their child's details in the \"Family Manager\". They will also respond on behalf of the child for events with availability tracking.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mixed_age',
|
||||||
|
label: 'Restricted',
|
||||||
|
desc: "No age restriction for login. Date of Birth is a required field. Parents/Guardians must select their child in the Family Manager to allow them to login. Any private message initiated by any adult to a minor aged user will include the child's designated guardian.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function LoginTypeTab() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [loginType, setLoginType] = useState('all_ages');
|
||||||
|
const [playersGroupId, setPlayersGroupId] = useState('');
|
||||||
|
const [guardiansGroupId,setGuardiansGroupId] = useState('');
|
||||||
|
const [userGroups, setUserGroups] = useState([]);
|
||||||
|
const [canChange, setCanChange] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([api.getSettings(), api.getUserGroups()]).then(([{ settings: s }, { groups }]) => {
|
||||||
|
setLoginType(s.feature_login_type || 'all_ages');
|
||||||
|
setPlayersGroupId(s.feature_players_group_id || '');
|
||||||
|
setGuardiansGroupId(s.feature_guardians_group_id || '');
|
||||||
|
setUserGroups([...(groups || [])].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
}).catch(() => {});
|
||||||
|
// Determine if the user table is empty enough to allow changes
|
||||||
|
api.getUsers().then(({ users }) => {
|
||||||
|
const nonAdmins = (users || []).filter(u => u.role !== 'admin');
|
||||||
|
setCanChange(nonAdmins.length === 0);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.updateLoginType({
|
||||||
|
loginType,
|
||||||
|
playersGroupId: playersGroupId ? parseInt(playersGroupId) : null,
|
||||||
|
guardiansGroupId: guardiansGroupId ? parseInt(guardiansGroupId) : null,
|
||||||
|
});
|
||||||
|
toast('Login Type settings saved', 'success');
|
||||||
|
window.dispatchEvent(new Event('rosterchirp:settings-changed'));
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsGroups = loginType !== 'all_ages';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="settings-section-label">Login Type</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 14px', marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 16, lineHeight: 1 }}>⚠️</span>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0, lineHeight: 1.5 }}>
|
||||||
|
This setting can only be set or changed when the user table is empty (no non-admin users exist).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 16 }}>
|
||||||
|
{LOGIN_TYPE_OPTIONS.map((opt, i) => (
|
||||||
|
<label key={opt.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 14px', borderBottom: i < LOGIN_TYPE_OPTIONS.length - 1 ? '1px solid var(--border)' : 'none', cursor: canChange ? 'pointer' : 'not-allowed', opacity: canChange ? 1 : 0.6 }}>
|
||||||
|
<input type="radio" name="loginType" value={opt.id} checked={loginType === opt.id} disabled={!canChange}
|
||||||
|
onChange={() => setLoginType(opt.id)} style={{ marginTop: 3, accentColor: 'var(--primary)' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500 }}>{opt.label}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>{opt.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group selectors — only shown for Guardian Only / Mixed Age */}
|
||||||
|
{needsGroups && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>Players Group</label>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 6 }}>Select a group that minor aged users will be put in by default. *</p>
|
||||||
|
<select className="input" value={playersGroupId} disabled={!canChange}
|
||||||
|
onChange={e => setPlayersGroupId(e.target.value)}>
|
||||||
|
<option value="">— Select group —</option>
|
||||||
|
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>Guardians Group</label>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 6 }}>Members of the selected group will have access to Family Manager. *</p>
|
||||||
|
<select className="input" value={guardiansGroupId} disabled={!canChange}
|
||||||
|
onChange={e => setGuardiansGroupId(e.target.value)}>
|
||||||
|
<option value="">— Select group —</option>
|
||||||
|
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||||||
|
* Open Group Manager to create a different group, if none are suitable in these lists.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !canChange}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Registration Tab ──────────────────────────────────────────────────────────
|
// ── Registration Tab ──────────────────────────────────────────────────────────
|
||||||
function RegistrationTab({ onFeaturesChanged }) {
|
function RegistrationTab({ onFeaturesChanged }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -281,7 +399,7 @@ function RegistrationTab({ onFeaturesChanged }) {
|
|||||||
|
|
||||||
// ── Main modal ────────────────────────────────────────────────────────────────
|
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||||
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
||||||
const [tab, setTab] = useState('messages');
|
const [tab, setTab] = useState('login-type');
|
||||||
const [appType, setAppType] = useState('RosterChirp-Chat');
|
const [appType, setAppType] = useState('RosterChirp-Chat');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -295,12 +413,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
|||||||
|
|
||||||
const isTeam = appType === 'RosterChirp-Team';
|
const isTeam = appType === 'RosterChirp-Team';
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'messages', label: 'Messages' },
|
|
||||||
isTeam && { id: 'team', label: 'Tools' },
|
|
||||||
{ id: 'registration', label: 'Registration' },
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="modal" style={{ maxWidth: 520 }}>
|
<div className="modal" style={{ maxWidth: 520 }}>
|
||||||
@@ -311,17 +423,20 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab buttons */}
|
{/* Select navigation */}
|
||||||
<div className="flex gap-2" style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
{tabs.map(t => (
|
<label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
|
||||||
<button key={t.id} className={`btn btn-sm ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t.id)}>
|
<select className="input" value={tab} onChange={e => setTab(e.target.value)}>
|
||||||
{t.label}
|
<option value="login-type">Login Type</option>
|
||||||
</button>
|
<option value="messages">Messages</option>
|
||||||
))}
|
{isTeam && <option value="team">Tools</option>}
|
||||||
|
<option value="registration">Registration</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'messages' && <MessagesTab />}
|
{tab === 'messages' && <MessagesTab />}
|
||||||
{tab === 'team' && <TeamManagementTab />}
|
{tab === 'team' && <TeamManagementTab />}
|
||||||
|
{tab === 'login-type' && <LoginTypeTab />}
|
||||||
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
|
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
const msgPublic = features.msgPublic ?? true;
|
const msgPublic = features.msgPublic ?? true;
|
||||||
const msgU2U = features.msgU2U ?? true;
|
const msgU2U = features.msgU2U ?? true;
|
||||||
const msgPrivateGroup = features.msgPrivateGroup ?? true;
|
const msgPrivateGroup = features.msgPrivateGroup ?? true;
|
||||||
|
const loginType = features.loginType || 'all_ages';
|
||||||
|
const playersGroupId = features.playersGroupId ?? null;
|
||||||
|
|
||||||
const allGroups = [
|
const allGroups = [
|
||||||
...(groups.publicGroups || []),
|
...(groups.publicGroups || []),
|
||||||
@@ -143,6 +145,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
if (g.is_managed) return false;
|
if (g.is_managed) return false;
|
||||||
if (g.is_direct && !msgU2U) return false;
|
if (g.is_direct && !msgU2U) return false;
|
||||||
if (!g.is_direct && !msgPrivateGroup) return false;
|
if (!g.is_direct && !msgPrivateGroup) return false;
|
||||||
|
// Guardian Only: hide the managed DM channel for the designated players group
|
||||||
|
if (loginType === 'guardian_only' && g.is_managed && playersGroupId && g.source_user_group_id === playersGroupId) return false;
|
||||||
return true;
|
return true;
|
||||||
})].sort((a, b) => {
|
})].sort((a, b) => {
|
||||||
if (!a.last_message_at && !b.last_message_at) return 0;
|
if (!a.last_message_at && !b.last_message_at) return 0;
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ if ('serviceWorker' in navigator) {
|
|||||||
document.addEventListener('touchend', function (e) {
|
document.addEventListener('touchend', function (e) {
|
||||||
if (e.touches.length < 2 && pinchStartDist !== null) {
|
if (e.touches.length < 2 && pinchStartDist !== null) {
|
||||||
pinchStartDist = null;
|
pinchStartDist = null;
|
||||||
localStorage.setItem(LS_KEY, currentScale);
|
// Pinch zoom is session-only — do NOT persist to localStorage.
|
||||||
|
// The saved (slider) scale is only written by ProfileModal.
|
||||||
}
|
}
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import GlobalBar from '../components/GlobalBar.jsx';
|
|||||||
import AboutModal from '../components/AboutModal.jsx';
|
import AboutModal from '../components/AboutModal.jsx';
|
||||||
import HelpModal from '../components/HelpModal.jsx';
|
import HelpModal from '../components/HelpModal.jsx';
|
||||||
import NavDrawer from '../components/NavDrawer.jsx';
|
import NavDrawer from '../components/NavDrawer.jsx';
|
||||||
|
import AddChildAliasModal from '../components/AddChildAliasModal.jsx';
|
||||||
import SchedulePage from '../components/SchedulePage.jsx';
|
import SchedulePage from '../components/SchedulePage.jsx';
|
||||||
import MobileGroupManager from '../components/MobileGroupManager.jsx';
|
import MobileGroupManager from '../components/MobileGroupManager.jsx';
|
||||||
import './Chat.css';
|
import './Chat.css';
|
||||||
@@ -48,6 +49,9 @@ export default function Chat() {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false, msgPublic: true, msgGroup: true, msgPrivateGroup: true, msgU2U: true });
|
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false, msgPublic: true, msgGroup: true, msgPrivateGroup: true, msgU2U: true });
|
||||||
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
||||||
|
const [addChildPending, setAddChildPending] = useState(false); // defer add-child popup until help closes
|
||||||
|
const addChildCheckedRef = useRef(false); // only auto-check aliases once per session
|
||||||
|
const modalRef = useRef(null); // always reflects current modal value in async callbacks
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const [showSidebar, setShowSidebar] = useState(true);
|
const [showSidebar, setShowSidebar] = useState(true);
|
||||||
|
|
||||||
@@ -80,26 +84,31 @@ export default function Chat() {
|
|||||||
// Keep groupsRef in sync so visibility/reconnect handlers can read current groups
|
// Keep groupsRef in sync so visibility/reconnect handlers can read current groups
|
||||||
useEffect(() => { groupsRef.current = groups; }, [groups]);
|
useEffect(() => { groupsRef.current = groups; }, [groups]);
|
||||||
|
|
||||||
// Load feature flags + current user's group memberships on mount
|
// Load feature flags + current user's group memberships on mount (combined for consistent inGuardiansGroup)
|
||||||
const loadFeatures = useCallback(() => {
|
const loadFeatures = useCallback(() => {
|
||||||
api.getSettings().then(({ settings }) => {
|
Promise.all([api.getSettings(), api.getMyUserGroups()])
|
||||||
setFeatures(prev => ({
|
.then(([{ settings: s }, { userGroups }]) => {
|
||||||
...prev,
|
const memberships = (userGroups || []).map(g => g.id);
|
||||||
branding: settings.feature_branding === 'true',
|
const guardiansGroupId = s.feature_guardians_group_id ? parseInt(s.feature_guardians_group_id) : null;
|
||||||
groupManager: settings.feature_group_manager === 'true',
|
setFeatures(prev => ({
|
||||||
scheduleManager: settings.feature_schedule_manager === 'true',
|
...prev,
|
||||||
appType: settings.app_type || 'RosterChirp-Chat',
|
branding: s.feature_branding === 'true',
|
||||||
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
|
groupManager: s.feature_group_manager === 'true',
|
||||||
isHostDomain: settings.is_host_domain === 'true',
|
scheduleManager: s.feature_schedule_manager === 'true',
|
||||||
msgPublic: settings.feature_msg_public !== 'false',
|
appType: s.app_type || 'RosterChirp-Chat',
|
||||||
msgGroup: settings.feature_msg_group !== 'false',
|
teamToolManagers: JSON.parse(s.team_tool_managers || s.team_group_managers || '[]'),
|
||||||
msgPrivateGroup: settings.feature_msg_private_group !== 'false',
|
isHostDomain: s.is_host_domain === 'true',
|
||||||
msgU2U: settings.feature_msg_u2u !== 'false',
|
msgPublic: s.feature_msg_public !== 'false',
|
||||||
}));
|
msgGroup: s.feature_msg_group !== 'false',
|
||||||
}).catch(() => {});
|
msgPrivateGroup: s.feature_msg_private_group !== 'false',
|
||||||
api.getMyUserGroups().then(({ userGroups }) => {
|
msgU2U: s.feature_msg_u2u !== 'false',
|
||||||
setFeatures(prev => ({ ...prev, userGroupMemberships: (userGroups || []).map(g => g.id) }));
|
loginType: s.feature_login_type || 'all_ages',
|
||||||
}).catch(() => {});
|
playersGroupId: s.feature_players_group_id ? parseInt(s.feature_players_group_id) : null,
|
||||||
|
guardiansGroupId,
|
||||||
|
userGroupMemberships: memberships,
|
||||||
|
inGuardiansGroup: guardiansGroupId ? memberships.includes(guardiansGroupId) : false,
|
||||||
|
}));
|
||||||
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,6 +117,39 @@ export default function Chat() {
|
|||||||
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
|
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
|
||||||
}, [loadFeatures]);
|
}, [loadFeatures]);
|
||||||
|
|
||||||
|
// Keep modalRef in sync so async callbacks can read current modal without stale closure
|
||||||
|
useEffect(() => { modalRef.current = modal; }, [modal]);
|
||||||
|
|
||||||
|
// Auto-popup Add Child Alias modal when guardian user has no children yet
|
||||||
|
useEffect(() => {
|
||||||
|
if (addChildCheckedRef.current) return;
|
||||||
|
if (!features.inGuardiansGroup) return;
|
||||||
|
if (features.loginType !== 'guardian_only' && features.loginType !== 'mixed_age') return;
|
||||||
|
addChildCheckedRef.current = true;
|
||||||
|
api.getAliases().then(({ aliases }) => {
|
||||||
|
if (!(aliases || []).length) {
|
||||||
|
if (modalRef.current === 'help') {
|
||||||
|
setAddChildPending(true); // defer until help closes
|
||||||
|
} else if (!modalRef.current) {
|
||||||
|
setModal('addchild');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [features.loginType, features.inGuardiansGroup]);
|
||||||
|
|
||||||
|
// Close help — open deferred add-child popup if pending, or settings for first-time default admin
|
||||||
|
const handleHelpClose = useCallback(() => {
|
||||||
|
if (addChildPending) {
|
||||||
|
setAddChildPending(false);
|
||||||
|
setModal('addchild');
|
||||||
|
} else if (!helpDismissed && user?.is_default_admin && !localStorage.getItem('rosterchirp_admin_setup_shown')) {
|
||||||
|
localStorage.setItem('rosterchirp_admin_setup_shown', '1');
|
||||||
|
setModal('settings');
|
||||||
|
} else {
|
||||||
|
setModal(null);
|
||||||
|
}
|
||||||
|
}, [addChildPending, helpDismissed, user]);
|
||||||
|
|
||||||
// Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS
|
// Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!('serviceWorker' in navigator)) return;
|
if (!('serviceWorker' in navigator)) return;
|
||||||
@@ -599,12 +641,14 @@ export default function Chat() {
|
|||||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||||
|
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||||
features={features} currentPage={page} isMobile={isMobile}
|
features={features} currentPage={page} isMobile={isMobile}
|
||||||
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
|
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
|
||||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -628,12 +672,14 @@ export default function Chat() {
|
|||||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||||
|
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||||
features={features} currentPage={page} isMobile={isMobile}
|
features={features} currentPage={page} isMobile={isMobile}
|
||||||
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
|
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
|
||||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -687,12 +733,14 @@ export default function Chat() {
|
|||||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||||
|
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||||
features={features} currentPage={page} isMobile={isMobile}
|
features={features} currentPage={page} isMobile={isMobile}
|
||||||
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
|
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
|
||||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
|
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
|
||||||
|
|
||||||
@@ -719,6 +767,7 @@ export default function Chat() {
|
|||||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||||
|
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||||
features={features}
|
features={features}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -726,7 +775,8 @@ export default function Chat() {
|
|||||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -758,6 +808,7 @@ export default function Chat() {
|
|||||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||||
|
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||||
features={features}
|
features={features}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -772,7 +823,8 @@ export default function Chat() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
|
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -830,6 +882,7 @@ export default function Chat() {
|
|||||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||||
|
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||||
features={features}
|
features={features}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -840,7 +893,8 @@ export default function Chat() {
|
|||||||
|
|
||||||
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,29 @@ function UserCheckList({ allUsers, selectedIds, onChange, onIF, onIB }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AliasCheckList({ allAliases, selectedIds, onChange, onIF, onIB }) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const filtered = allAliases
|
||||||
|
.filter(a => `${a.first_name} ${a.last_name}`.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
.sort((a, b) => `${a.first_name} ${a.last_name}`.localeCompare(`${b.first_name} ${b.last_name}`));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input className="input" placeholder="Search aliases…" value={search} onChange={e => setSearch(e.target.value)} autoComplete="off" style={{ marginBottom:8 }} onFocus={onIF} onBlur={onIB} />
|
||||||
|
<div style={{ maxHeight:220, overflowY:'auto', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}>
|
||||||
|
{filtered.map(a => (
|
||||||
|
<label key={a.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}>
|
||||||
|
<input type="checkbox" checked={selectedIds.has(a.id)} onChange={() => { const n=new Set(selectedIds); n.has(a.id)?n.delete(a.id):n.add(a.id); onChange(n); }}
|
||||||
|
style={{ accentColor:'var(--primary)', width:15, height:15 }} />
|
||||||
|
<span className="flex-1 text-sm">{a.first_name} {a.last_name}</span>
|
||||||
|
<span className="text-xs" style={{ color:'var(--text-tertiary)' }}>{a.guardian_display_name || a.guardian_name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && <div style={{ padding:16, textAlign:'center', color:'var(--text-tertiary)', fontSize:13 }}>No aliases found</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GroupCheckList({ allGroups, selectedIds, onChange }) {
|
function GroupCheckList({ allGroups, selectedIds, onChange }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:220, overflowY:'auto' }}>
|
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:220, overflowY:'auto' }}>
|
||||||
@@ -60,13 +83,16 @@ function GroupCheckList({ allGroups, selectedIds, onChange }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── All Groups tab ────────────────────────────────────────────────────────────
|
// ── All Groups tab ────────────────────────────────────────────────────────────
|
||||||
function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB, playersGroupId }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [savedMembers, setSavedMembers] = useState(new Set());
|
const [savedMembers, setSavedMembers] = useState(new Set());
|
||||||
const [members, setMembers] = useState(new Set());
|
const [members, setMembers] = useState(new Set());
|
||||||
const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted
|
const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted
|
||||||
|
const [aliasMembers, setAliasMembers] = useState([]); // child aliases in this group
|
||||||
|
const [allAliases, setAllAliases] = useState([]); // all aliases for players group management
|
||||||
|
const [aliasSelection, setAliasSelection] = useState(new Set()); // selected alias ids for players group
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [noDm, setNoDm] = useState(false);
|
const [noDm, setNoDm] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -81,18 +107,32 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
|||||||
const selectGroup = async (g) => {
|
const selectGroup = async (g) => {
|
||||||
setShowDelete(false);
|
setShowDelete(false);
|
||||||
setAccordionOpen(false);
|
setAccordionOpen(false);
|
||||||
const { members: mems } = await api.getUserGroup(g.id);
|
const { members: mems, aliasMembers: aliases } = await api.getUserGroup(g.id);
|
||||||
const ids = new Set(mems.map(m => m.id));
|
const ids = new Set(mems.map(m => m.id));
|
||||||
setSelected(g); setEditName(g.name); setMembers(ids); setSavedMembers(ids);
|
setSelected(g); setEditName(g.name); setMembers(ids); setSavedMembers(ids);
|
||||||
setFullMembers(mems);
|
setFullMembers(mems);
|
||||||
|
setAliasMembers(aliases || []);
|
||||||
// No DM → checkbox enabled+checked; has DM → checkbox disabled+unchecked
|
// No DM → checkbox enabled+checked; has DM → checkbox disabled+unchecked
|
||||||
setNoDm(!g.dm_group_id);
|
setNoDm(!g.dm_group_id);
|
||||||
|
// Players group: load all aliases for alias-based membership management
|
||||||
|
if (playersGroupId && g.id === playersGroupId) {
|
||||||
|
api.getAllAliases().then(({ aliases: all }) => {
|
||||||
|
setAllAliases(all || []);
|
||||||
|
setAliasSelection(new Set((aliases || []).map(a => a.id)));
|
||||||
|
}).catch(() => {});
|
||||||
|
} else {
|
||||||
|
setAllAliases([]);
|
||||||
|
setAliasSelection(new Set());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set());
|
setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set());
|
||||||
setShowDelete(false); setFullMembers([]); setNoDm(false);
|
setShowDelete(false); setFullMembers([]); setAliasMembers([]); setNoDm(false);
|
||||||
|
setAllAliases([]); setAliasSelection(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPlayersGroup = !!(playersGroupId && selected?.id === playersGroupId);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editName.trim()) return toast('Name required', 'error');
|
if (!editName.trim()) return toast('Name required', 'error');
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -100,11 +140,18 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
|||||||
if (selected) {
|
if (selected) {
|
||||||
// createDm=true when the group has no DM and the user unchecked "Do not create Group DM"
|
// createDm=true when the group has no DM and the user unchecked "Do not create Group DM"
|
||||||
const createDm = !selected.dm_group_id && !noDm;
|
const createDm = !selected.dm_group_id && !noDm;
|
||||||
const { group: updated } = await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members], createDm });
|
const body = isPlayersGroup
|
||||||
|
? { name: editName.trim(), memberIds: [], aliasMemberIds: [...aliasSelection], createDm }
|
||||||
|
: { name: editName.trim(), memberIds: [...members], createDm };
|
||||||
|
const { group: updated } = await api.updateUserGroup(selected.id, body);
|
||||||
toast('Group updated', 'success');
|
toast('Group updated', 'success');
|
||||||
const { members: fresh } = await api.getUserGroup(selected.id);
|
const { members: fresh, aliasMembers: freshAliases } = await api.getUserGroup(selected.id);
|
||||||
const freshIds = new Set(fresh.map(m => m.id));
|
const freshIds = new Set(fresh.map(m => m.id));
|
||||||
setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh);
|
setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh); setAliasMembers(freshAliases || []);
|
||||||
|
if (isPlayersGroup) {
|
||||||
|
setAliasSelection(new Set((freshAliases || []).map(a => a.id)));
|
||||||
|
setAllAliases(prev => prev); // keep existing list
|
||||||
|
}
|
||||||
// Reflect new dm_group_id if a DM was just created
|
// Reflect new dm_group_id if a DM was just created
|
||||||
setSelected(prev => ({ ...prev, name: editName.trim(), dm_group_id: updated?.dm_group_id ?? prev.dm_group_id }));
|
setSelected(prev => ({ ...prev, name: editName.trim(), dm_group_id: updated?.dm_group_id ?? prev.dm_group_id }));
|
||||||
if (createDm) setNoDm(false);
|
if (createDm) setNoDm(false);
|
||||||
@@ -216,10 +263,32 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
|||||||
{selected && selected.dm_group_id && <p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:4 }}>Group DM already exists — cannot be removed.</p>}
|
{selected && selected.dm_group_id && <p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:4 }}>Group DM already exists — cannot be removed.</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="settings-section-label">Members</label>
|
<label className="settings-section-label">{isPlayersGroup ? 'Child Aliases' : 'Members'}</label>
|
||||||
<div style={{ marginTop:6 }}><UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} onIF={onIF} onIB={onIB} /></div>
|
{isPlayersGroup ? (
|
||||||
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{members.size} selected</p>
|
<div style={{ marginTop:6 }}>
|
||||||
|
<AliasCheckList allAliases={allAliases} selectedIds={aliasSelection} onChange={setAliasSelection} onIF={onIF} onIB={onIB} />
|
||||||
|
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{aliasSelection.size} selected</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ marginTop:6 }}><UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} onIF={onIF} onIB={onIB} /></div>
|
||||||
|
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{members.size} selected</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!isPlayersGroup && aliasMembers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="settings-section-label">Child Aliases</label>
|
||||||
|
<div style={{ marginTop:6, border:'1px solid var(--border)', borderRadius:'var(--radius)', overflow:'hidden' }}>
|
||||||
|
{aliasMembers.map((a, i) => (
|
||||||
|
<div key={a.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', borderBottom: i < aliasMembers.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||||
|
<span style={{ flex:1, fontSize:13 }}>{a.name}</span>
|
||||||
|
{a.date_of_birth && <span style={{ fontSize:11, color:'var(--text-tertiary)' }}>{a.date_of_birth.slice(0,10)}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{deletedMembers.length > 0 && (
|
{deletedMembers.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="settings-section-label" style={{ color:'var(--error)' }}>
|
<label className="settings-section-label" style={{ color:'var(--error)' }}>
|
||||||
@@ -684,13 +753,18 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
|
|||||||
const [allUserGroups, setAllUserGroups] = useState([]);
|
const [allUserGroups, setAllUserGroups] = useState([]);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [inputFocused, setInputFocused] = useState(false);
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
const [playersGroupId, setPlayersGroupId] = useState(null);
|
||||||
const onIF = () => setInputFocused(true);
|
const onIF = () => setInputFocused(true);
|
||||||
const onIB = () => setInputFocused(false);
|
const onIB = () => setInputFocused(false);
|
||||||
const onRefresh = () => setRefreshKey(k => k+1);
|
const onRefresh = () => setRefreshKey(k => k+1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active').sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {});
|
api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active' && !u.is_default_admin).sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {});
|
||||||
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {});
|
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {});
|
||||||
|
api.getSettings().then(({ settings }) => {
|
||||||
|
const pgid = (settings || []).find(s => s.key === 'feature_players_group_id')?.value;
|
||||||
|
setPlayersGroupId(pgid ? parseInt(pgid) : null);
|
||||||
|
}).catch(() => {});
|
||||||
}, [refreshKey]);
|
}, [refreshKey]);
|
||||||
|
|
||||||
// Nav item helper — matches Schedule page style
|
// Nav item helper — matches Schedule page style
|
||||||
@@ -743,7 +817,7 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 0 }}>
|
<div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 0 }}>
|
||||||
{tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} isMobile={isMobile} onIF={onIF} onIB={onIB} />}
|
{tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} isMobile={isMobile} onIF={onIF} onIB={onIB} playersGroupId={playersGroupId} />}
|
||||||
{tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} isMobile={isMobile} onIF={onIF} onIB={onIB} />}
|
{tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} isMobile={isMobile} onIF={onIF} onIB={onIB} />}
|
||||||
{tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} onIF={onIF} onIB={onIB} />}
|
{tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} onIF={onIF} onIB={onIB} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ function isValidPhone(p) {
|
|||||||
return /^\d{7,15}$/.test(digits);
|
return /^\d{7,15}$/.test(digits);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format: email,firstname,lastname,password,role,usergroup (exactly 5 commas / 6 fields)
|
// Format: email,firstname,lastname,dob,password,role,usergroup (exactly 6 commas / 7 fields)
|
||||||
function parseCSV(text, ignoreFirstRow, allUserGroups) {
|
function parseCSV(text, ignoreFirstRow, allUserGroups, loginType) {
|
||||||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||||
const rows = [], invalid = [];
|
const rows = [], invalid = [];
|
||||||
const groupMap = new Map((allUserGroups || []).map(g => [g.name.toLowerCase(), g]));
|
const groupMap = new Map((allUserGroups || []).map(g => [g.name.toLowerCase(), g]));
|
||||||
const validRoles = ['member', 'manager', 'admin'];
|
const validRoles = ['member', 'manager', 'admin'];
|
||||||
|
const requireDob = loginType === 'mixed_age';
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
@@ -27,12 +28,13 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) {
|
|||||||
if (i === 0 && (ignoreFirstRow || /^e-?mail$/i.test(line.split(',')[0].trim()))) continue;
|
if (i === 0 && (ignoreFirstRow || /^e-?mail$/i.test(line.split(',')[0].trim()))) continue;
|
||||||
|
|
||||||
const parts = line.split(',');
|
const parts = line.split(',');
|
||||||
if (parts.length !== 6) { invalid.push({ line, reason: `Must have exactly 5 commas (has ${parts.length - 1})` }); continue; }
|
if (parts.length !== 7) { invalid.push({ line, reason: `Must have exactly 6 commas (has ${parts.length - 1})` }); continue; }
|
||||||
const [email, firstName, lastName, password, roleRaw, usergroupRaw] = parts.map(p => p.trim());
|
const [email, firstName, lastName, dobRaw, password, roleRaw, usergroupRaw] = parts.map(p => p.trim());
|
||||||
|
|
||||||
if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email || '(blank)'}"` }); continue; }
|
if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email || '(blank)'}"` }); continue; }
|
||||||
if (!firstName) { invalid.push({ line, reason: 'First name required' }); continue; }
|
if (!firstName) { invalid.push({ line, reason: 'First name required' }); continue; }
|
||||||
if (!lastName) { invalid.push({ line, reason: 'Last name required' }); continue; }
|
if (!lastName) { invalid.push({ line, reason: 'Last name required' }); continue; }
|
||||||
|
if (requireDob && !dobRaw) { invalid.push({ line, reason: 'Date of birth required in Restricted login type' }); continue; }
|
||||||
|
|
||||||
const role = validRoles.includes(roleRaw.toLowerCase()) ? roleRaw.toLowerCase() : 'member';
|
const role = validRoles.includes(roleRaw.toLowerCase()) ? roleRaw.toLowerCase() : 'member';
|
||||||
const matchedGroup = usergroupRaw ? groupMap.get(usergroupRaw.toLowerCase()) : null;
|
const matchedGroup = usergroupRaw ? groupMap.get(usergroupRaw.toLowerCase()) : null;
|
||||||
@@ -42,6 +44,7 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) {
|
|||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
password,
|
password,
|
||||||
|
dateOfBirth: dobRaw || null,
|
||||||
role,
|
role,
|
||||||
userGroupId: matchedGroup?.id || null,
|
userGroupId: matchedGroup?.id || null,
|
||||||
userGroupName: usergroupRaw || null,
|
userGroupName: usergroupRaw || null,
|
||||||
@@ -89,10 +92,11 @@ function UserRow({ u, onUpdated, onEdit }) {
|
|||||||
<Avatar user={u} size="sm" />
|
<Avatar user={u} size="sm" />
|
||||||
<div style={{ flex:1, minWidth:0 }}>
|
<div style={{ flex:1, minWidth:0 }}>
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
|
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
|
||||||
<span style={{ fontWeight:600, fontSize:14 }}>{u.display_name || u.name}</span>
|
<span style={{ fontWeight:600, fontSize:14, color: u.guardian_approval_required ? 'var(--error)' : 'var(--text-primary)' }}>{u.display_name || u.name}</span>
|
||||||
{u.display_name && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>({u.name})</span>}
|
{u.display_name && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>({u.name})</span>}
|
||||||
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
||||||
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
|
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
|
||||||
|
{!!u.guardian_approval_required && <span className="role-badge" style={{ background:'var(--error)', color:'white' }}>Pending Guardian Approval</span>}
|
||||||
{!!u.is_default_admin && <span className="text-xs" style={{ color:'var(--text-tertiary)' }}>Default Admin</span>}
|
{!!u.is_default_admin && <span className="text-xs" style={{ color:'var(--text-tertiary)' }}>Default Admin</span>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize:12, color:'var(--text-secondary)', marginTop:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{u.email}</div>
|
<div style={{ fontSize:12, color:'var(--text-secondary)', marginTop:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{u.email}</div>
|
||||||
@@ -129,7 +133,7 @@ function UserRow({ u, onUpdated, onEdit }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── User Form (create / edit) ─────────────────────────────────────────────────
|
// ── User Form (create / edit) ─────────────────────────────────────────────────
|
||||||
function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, onIF, onIB }) {
|
function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onDone, onCancel, isMobile, onIF, onIB }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const isEdit = !!user;
|
const isEdit = !!user;
|
||||||
|
|
||||||
@@ -138,7 +142,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
const [email, setEmail] = useState(user?.email || '');
|
const [email, setEmail] = useState(user?.email || '');
|
||||||
const [phone, setPhone] = useState(user?.phone || '');
|
const [phone, setPhone] = useState(user?.phone || '');
|
||||||
const [role, setRole] = useState(user?.role || 'member');
|
const [role, setRole] = useState(user?.role || 'member');
|
||||||
const [dob, setDob] = useState(user?.date_of_birth || '');
|
const [dob, setDob] = useState(user?.date_of_birth?.slice(0, 10) || '');
|
||||||
const [guardianId, setGuardianId] = useState(user?.guardian_user_id || '');
|
const [guardianId, setGuardianId] = useState(user?.guardian_user_id || '');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [pwEnabled, setPwEnabled] = useState(!isEdit);
|
const [pwEnabled, setPwEnabled] = useState(!isEdit);
|
||||||
@@ -171,10 +175,12 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await api.updateUser(user.id, {
|
await api.updateUser(user.id, {
|
||||||
firstName: firstName.trim(),
|
firstName: firstName.trim(),
|
||||||
lastName: lastName.trim(),
|
lastName: lastName.trim(),
|
||||||
phone: phone.trim(),
|
phone: phone.trim(),
|
||||||
role,
|
role,
|
||||||
|
dateOfBirth: dob || undefined,
|
||||||
|
guardianUserId: guardianId || undefined,
|
||||||
...(pwEnabled && password ? { password } : {}),
|
...(pwEnabled && password ? { password } : {}),
|
||||||
});
|
});
|
||||||
// Sync group memberships: add newly selected, remove deselected
|
// Sync group memberships: add newly selected, remove deselected
|
||||||
@@ -187,11 +193,12 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
toast('User updated', 'success');
|
toast('User updated', 'success');
|
||||||
} else {
|
} else {
|
||||||
const { user: newUser } = await api.createUser({
|
const { user: newUser } = await api.createUser({
|
||||||
firstName: firstName.trim(),
|
firstName: firstName.trim(),
|
||||||
lastName: lastName.trim(),
|
lastName: lastName.trim(),
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
phone: phone.trim(),
|
phone: phone.trim(),
|
||||||
role,
|
role,
|
||||||
|
dateOfBirth: dob || undefined,
|
||||||
...(password ? { password } : {}),
|
...(password ? { password } : {}),
|
||||||
});
|
});
|
||||||
// Add to selected groups
|
// Add to selected groups
|
||||||
@@ -284,17 +291,34 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
{lbl('Date of Birth', false, '(optional)')}
|
{lbl('Date of Birth', false, '(optional)')}
|
||||||
<input className="input" type="text" placeholder="YYYY-MM-DD"
|
<input className="input" type="text" placeholder="YYYY-MM-DD"
|
||||||
value={dob} onChange={e => setDob(e.target.value)}
|
value={dob} onChange={e => setDob(e.target.value)}
|
||||||
disabled
|
autoComplete="off" onFocus={onIF} onBlur={onIB} />
|
||||||
style={{ opacity:0.5, cursor:'not-allowed' }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{lbl('Guardian', false, '(optional)')}
|
|
||||||
<select className="input" value={guardianId} onChange={e => setGuardianId(e.target.value)}
|
|
||||||
disabled
|
|
||||||
style={{ opacity:0.5, cursor:'not-allowed' }}>
|
|
||||||
<option value="">— Select guardian —</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Guardian field — shown for all login types except guardian_only (children are aliases there, not users) */}
|
||||||
|
{loginType !== 'guardian_only' && (
|
||||||
|
<div>
|
||||||
|
{lbl('Guardian', false, '(optional)')}
|
||||||
|
<div style={{ position:'relative' }}>
|
||||||
|
<select className="input" value={guardianId} onChange={e => setGuardianId(e.target.value)}
|
||||||
|
style={ user?.guardian_approval_required ? { borderColor:'var(--error)' } : {} }>
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{(nonMinorUsers || []).map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{isEdit && user?.guardian_approval_required && (
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:8, marginTop:6 }}>
|
||||||
|
<span style={{ fontSize:12, color:'var(--error)', fontWeight:600 }}>Pending approval</span>
|
||||||
|
<button className="btn btn-sm" style={{ fontSize:12, color:'var(--success)', background:'none', border:'1px solid var(--success)', padding:'2px 8px', cursor:'pointer' }}
|
||||||
|
onClick={async () => { try { await api.approveGuardian(user.id); toast('Approved', 'success'); onDone(); } catch(e) { toast(e.message,'error'); } }}>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" style={{ fontSize:12, color:'var(--error)', background:'none', border:'1px solid var(--error)', padding:'2px 8px', cursor:'pointer' }}
|
||||||
|
onClick={async () => { try { await api.denyGuardian(user.id); toast('Denied', 'success'); onDone(); } catch(e) { toast(e.message,'error'); } }}>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 4b: User Groups */}
|
{/* Row 4b: User Groups */}
|
||||||
@@ -366,7 +390,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk Import Form ──────────────────────────────────────────────────────────
|
// ── Bulk Import Form ──────────────────────────────────────────────────────────
|
||||||
function BulkImportForm({ userPass, allUserGroups, onCreated }) {
|
function BulkImportForm({ userPass, allUserGroups, loginType, onCreated }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
const [csvFile, setCsvFile] = useState(null);
|
const [csvFile, setCsvFile] = useState(null);
|
||||||
@@ -381,9 +405,9 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
|
|||||||
// Re-parse whenever raw text or options change
|
// Re-parse whenever raw text or options change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rawText) return;
|
if (!rawText) return;
|
||||||
const { rows, invalid } = parseCSV(rawText, ignoreFirst, allUserGroups);
|
const { rows, invalid } = parseCSV(rawText, ignoreFirst, allUserGroups, loginType);
|
||||||
setCsvRows(rows); setCsvInvalid(invalid);
|
setCsvRows(rows); setCsvInvalid(invalid);
|
||||||
}, [rawText, ignoreFirst, allUserGroups]);
|
}, [rawText, ignoreFirst, allUserGroups, loginType]);
|
||||||
|
|
||||||
const handleFile = e => {
|
const handleFile = e => {
|
||||||
const file = e.target.files?.[0]; if (!file) return;
|
const file = e.target.files?.[0]; if (!file) return;
|
||||||
@@ -413,11 +437,11 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
|
|||||||
{/* Format info box */}
|
{/* Format info box */}
|
||||||
<div style={{ background:'var(--background)', border:'1px dashed var(--border)', borderRadius:'var(--radius)', padding:'12px 14px' }}>
|
<div style={{ background:'var(--background)', border:'1px dashed var(--border)', borderRadius:'var(--radius)', padding:'12px 14px' }}>
|
||||||
<p style={{ fontSize:13, fontWeight:600, marginBottom:8 }}>CSV Format</p>
|
<p style={{ fontSize:13, fontWeight:600, marginBottom:8 }}>CSV Format</p>
|
||||||
<code style={codeStyle}>{'FULL: email,firstname,lastname,password,role,usergroup'}</code>
|
<code style={codeStyle}>{'FULL: email,firstname,lastname,dob,password,role,usergroup'}</code>
|
||||||
<code style={codeStyle}>{'MINIMUM: email,firstname,lastname,,,'}</code>
|
<code style={codeStyle}>{'MINIMUM: email,firstname,lastname,,,,'}</code>
|
||||||
<p style={{ fontSize:12, color:'var(--text-tertiary)', margin:'8px 0 6px' }}>Examples:</p>
|
<p style={{ fontSize:12, color:'var(--text-tertiary)', margin:'8px 0 6px' }}>Examples:</p>
|
||||||
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,,member,parents'}</code>
|
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,1970-11-21,,member,parents'}</code>
|
||||||
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players'}</code>
|
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,2013-06-11,Ori0n2026!,member,players'}</code>
|
||||||
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:8 }}>
|
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:8 }}>
|
||||||
Blank password defaults to <strong>{userPass}</strong>. Blank role defaults to member. We recommend using a spreadsheet editor and saving as CSV.
|
Blank password defaults to <strong>{userPass}</strong>. Blank role defaults to member. We recommend using a spreadsheet editor and saving as CSV.
|
||||||
</p>
|
</p>
|
||||||
@@ -433,8 +457,8 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
|
|||||||
<div>
|
<div>
|
||||||
<p style={{ fontWeight:600, marginBottom:4 }}>CSV Requirements</p>
|
<p style={{ fontWeight:600, marginBottom:4 }}>CSV Requirements</p>
|
||||||
<ul style={{ paddingLeft:16, margin:0, lineHeight:1.8 }}>
|
<ul style={{ paddingLeft:16, margin:0, lineHeight:1.8 }}>
|
||||||
<li>Exactly 5 commas per row (rows with more or less will be skipped)</li>
|
<li>Exactly six (6) commas per row (rows with more or less will be skipped)</li>
|
||||||
<li><code>email</code>, <code>firstname</code>, <code>lastname</code> are required</li>
|
<li><code>email</code>, <code>firstname</code>, <code>lastname</code> are required fields{loginType === 'mixed_age' ? <> (DOB field required for <strong>Restricted</strong> login type)</> : ''}.</li>
|
||||||
<li>A user can only be added to one group during bulk import</li>
|
<li>A user can only be added to one group during bulk import</li>
|
||||||
<li>Optional fields left blank will use system defaults</li>
|
<li>Optional fields left blank will use system defaults</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -543,6 +567,8 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
const [editUser, setEditUser] = useState(null);
|
const [editUser, setEditUser] = useState(null);
|
||||||
const [userPass, setUserPass] = useState('user@1234');
|
const [userPass, setUserPass] = useState('user@1234');
|
||||||
const [allUserGroups, setAllUserGroups] = useState([]);
|
const [allUserGroups, setAllUserGroups] = useState([]);
|
||||||
|
const [loginType, setLoginType] = useState('all_ages');
|
||||||
|
const [guardiansGroupUserIds, setGuardiansGroupUserIds] = useState(null); // null = not loaded yet
|
||||||
const [inputFocused, setInputFocused] = useState(false);
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
const onIF = () => setInputFocused(true);
|
const onIF = () => setInputFocused(true);
|
||||||
const onIB = () => setInputFocused(false);
|
const onIB = () => setInputFocused(false);
|
||||||
@@ -556,7 +582,16 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {});
|
api.getSettings().then(({ settings }) => {
|
||||||
|
if (settings.user_pass) setUserPass(settings.user_pass);
|
||||||
|
setLoginType(settings.feature_login_type || 'all_ages');
|
||||||
|
const guardiansGroupId = settings.feature_guardians_group_id ? parseInt(settings.feature_guardians_group_id) : null;
|
||||||
|
if (guardiansGroupId) {
|
||||||
|
api.getUserGroup(guardiansGroupId)
|
||||||
|
.then(({ members }) => setGuardiansGroupUserIds(new Set((members || []).map(m => m.id))))
|
||||||
|
.catch(() => setGuardiansGroupUserIds(null));
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a,b) => a.name.localeCompare(b.name)))).catch(() => {});
|
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a,b) => a.name.localeCompare(b.name)))).catch(() => {});
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
@@ -664,6 +699,8 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
user={view === 'edit' ? editUser : null}
|
user={view === 'edit' ? editUser : null}
|
||||||
userPass={userPass}
|
userPass={userPass}
|
||||||
allUserGroups={allUserGroups}
|
allUserGroups={allUserGroups}
|
||||||
|
nonMinorUsers={users.filter(u => !u.is_minor && u.status === 'active' && (guardiansGroupUserIds === null || guardiansGroupUserIds.has(u.id)))}
|
||||||
|
loginType={loginType}
|
||||||
onDone={() => { load(); goList(); }}
|
onDone={() => { load(); goList(); }}
|
||||||
onCancel={goList}
|
onCancel={goList}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -676,7 +713,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
{/* BULK IMPORT */}
|
{/* BULK IMPORT */}
|
||||||
{view === 'bulk' && (
|
{view === 'bulk' && (
|
||||||
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
|
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
|
||||||
<BulkImportForm userPass={userPass} allUserGroups={allUserGroups} onCreated={load} />
|
<BulkImportForm userPass={userPass} allUserGroups={allUserGroups} loginType={loginType} onCreated={load} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,6 +69,28 @@ export const api = {
|
|||||||
const form = new FormData(); form.append('avatar', file);
|
const form = new FormData(); form.append('avatar', file);
|
||||||
return req('POST', '/users/me/avatar', form);
|
return req('POST', '/users/me/avatar', form);
|
||||||
},
|
},
|
||||||
|
searchMinorUsers: (q) => req('GET', `/users/search-minors?q=${encodeURIComponent(q || '')}`),
|
||||||
|
getMinorPlayers: () => req('GET', '/users/minor-players'),
|
||||||
|
addGuardianChild: (minorId, dateOfBirth) => req('POST', `/users/me/guardian-children/${minorId}`, { dateOfBirth: dateOfBirth || null }),
|
||||||
|
removeGuardianChild: (minorId) => req('DELETE', `/users/me/guardian-children/${minorId}`),
|
||||||
|
approveGuardian: (id) => req('PATCH', `/users/${id}/approve-guardian`),
|
||||||
|
denyGuardian: (id) => req('PATCH', `/users/${id}/deny-guardian`),
|
||||||
|
linkMinor: (minorId) => req('PATCH', `/users/me/link-minor/${minorId}`),
|
||||||
|
// Guardian aliases
|
||||||
|
getAliases: () => req('GET', '/users/me/aliases'),
|
||||||
|
getAllAliases: () => req('GET', '/users/aliases-all'),
|
||||||
|
createAlias: (body) => req('POST', '/users/me/aliases', body),
|
||||||
|
updateAlias: (id, body) => req('PATCH', `/users/me/aliases/${id}`, body),
|
||||||
|
deleteAlias: (id) => req('DELETE', `/users/me/aliases/${id}`),
|
||||||
|
uploadAliasAvatar: (aliasId, file) => {
|
||||||
|
const form = new FormData(); form.append('avatar', file);
|
||||||
|
return req('POST', `/users/me/aliases/${aliasId}/avatar`, form);
|
||||||
|
},
|
||||||
|
// Spouse/Partner
|
||||||
|
getPartner: () => req('GET', '/users/me/partner'),
|
||||||
|
setPartner: (partnerId, respondSeparately = false) => req('POST', '/users/me/partner', { partnerId, respondSeparately }),
|
||||||
|
updatePartnerRespondSeparately: (respondSeparately) => req('PATCH', '/users/me/partner', { respondSeparately }),
|
||||||
|
removePartner: () => req('DELETE', '/users/me/partner'),
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
getGroups: () => req('GET', '/groups'),
|
getGroups: () => req('GET', '/groups'),
|
||||||
@@ -105,6 +127,7 @@ export const api = {
|
|||||||
registerCode: (code) => req('POST', '/settings/register', { code }),
|
registerCode: (code) => req('POST', '/settings/register', { code }),
|
||||||
updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
|
updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
|
||||||
updateMessageSettings: (body) => req('PATCH', '/settings/messages', body),
|
updateMessageSettings: (body) => req('PATCH', '/settings/messages', body),
|
||||||
|
updateLoginType: (body) => req('PATCH', '/settings/login-type', body),
|
||||||
|
|
||||||
// Schedule Manager
|
// Schedule Manager
|
||||||
getMyScheduleGroups: () => req('GET', '/schedule/my-groups'),
|
getMyScheduleGroups: () => req('GET', '/schedule/my-groups'),
|
||||||
@@ -120,9 +143,9 @@ export const api = {
|
|||||||
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
|
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
|
||||||
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
|
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
|
||||||
deleteEvent: (id, scope = 'this', occurrenceStart = null) => req('DELETE', `/schedule/${id}`, { recurringScope: scope, occurrenceStart }),
|
deleteEvent: (id, scope = 'this', occurrenceStart = null) => req('DELETE', `/schedule/${id}`, { recurringScope: scope, occurrenceStart }),
|
||||||
setAvailability: (id, response, note) => req('PUT', `/schedule/${id}/availability`, { response, note }),
|
setAvailability: (id, response, note, aliasId, forPartnerId) => req('PUT', `/schedule/${id}/availability`, { response, note, ...(aliasId ? { aliasId } : {}), ...(forPartnerId ? { forPartnerId } : {}) }),
|
||||||
setAvailabilityNote: (id, note) => req('PATCH', `/schedule/${id}/availability/note`, { note }),
|
setAvailabilityNote: (id, note) => req('PATCH', `/schedule/${id}/availability/note`, { note }),
|
||||||
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`),
|
deleteAvailability: (id, aliasId, forPartnerId) => req('DELETE', `/schedule/${id}/availability${aliasId ? `?aliasId=${aliasId}` : forPartnerId ? `?forPartnerId=${forPartnerId}` : ''}`),
|
||||||
getPendingAvailability: () => req('GET', '/schedule/me/pending'),
|
getPendingAvailability: () => req('GET', '/schedule/me/pending'),
|
||||||
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
|
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
|
||||||
importPreview: (file) => {
|
importPreview: (file) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user