diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..e39a0de --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,152 @@ +# RosterChirp — Feature Reference + +> **Current version:** 0.12.42 +> **Application types:** RosterChirp-Chat · RosterChirp-Brand · RosterChirp-Team + +--- + +## All Users + +### Messaging + +- **Public Messages** — Read and post in public group channels open to all members. Channels can be marked read-only by an admin (announcements-style). +- **Private Group Messages** — Participate in named private groups with a specific set of members. +- **Direct Messages (U2U)** — Start a private one-on-one conversation with any user who has not blocked direct messages. +- **Group Messages** — Access managed private group conversations assigned to you through User Groups (requires RosterChirp-Team). +- **Message History** — Scroll back through conversation history with paginated loading (50 messages per page). +- **Message Reactions** — React to any message with an emoji. +- **Image Sharing** — Attach and send images in any conversation. +- **Reply Threading** — Reply to a specific message to preserve context. +- **@Mentions** — Mention users by name; mentioned users receive a notification badge. +- **Link Previews** — URLs pasted into messages automatically generate a title/image preview card. +- **Message Deletion** — Authors can delete their own messages; deleted messages are replaced with a tombstone. + +### Schedule (requires RosterChirp-Team) + +- **Calendar View** — Browse events in a full monthly calendar grid (desktop) or a day-list view (mobile). +- **Event Details** — Tap any event to view its full details: date/time, location, description, event type, assigned user groups, and recurrence pattern. +- **Availability Response** — Respond to events with **Going**, **Maybe**, or **Not Going**, plus an optional short note (up to 20 characters). +- **Bulk Availability** — Respond to multiple pending events at once from a single screen. +- **Response Summary** — See how many group members have responded Going / Maybe / Not Going on any event you are assigned to. +- **Filter & Search** — Filter the calendar by event type, keyword, or your own availability status. Keyword search supports word-boundary matching and exact quoted terms. + +### Profile & Account + +- **Display Name** — Set a public display name shown alongside your username (must be unique). +- **Avatar** — Upload a custom profile photo. A consistent colour avatar is generated automatically from your name if no photo is set. +- **About Me** — Add a short bio visible on your profile. +- **Hide Admin Tag** — Admins can choose to hide the "Admin" role badge on their messages. +- **Block Direct Messages** — Opt out of receiving unsolicited direct messages from other users. +- **Change Password** — Change your own account password at any time. +- **Font Scale** — Adjust the interface text size (80%–200%) stored per-device. + +### Notifications & Presence + +- **Push Notifications** — Receive push notifications for new messages when the app is backgrounded. Supports Android (Firebase Cloud Messaging) and iOS 16.4+ PWA (Web Push / VAPID). +- **Notification Permission** — Grant or revoke push notification permission from the Notifications tab in your profile. +- **Unread Badges** — Conversations with unread messages display a count badge in the sidebar and on the PWA app icon. +- **Online Presence** — A green indicator shows which users are currently active. Last-seen time is displayed for offline users. +- **Browser Tab Badge** — The page title and PWA icon badge update with the total unread count across all conversations. + +### App Experience + +- **Progressive Web App (PWA)** — Install RosterChirp to your home screen on Android, iOS, and desktop for a native app feel. +- **Dark / Light Theme** — The interface respects your operating system's colour scheme preference automatically. +- **Mobile-Optimised Layout** — A dedicated mobile layout with a slide-in sidebar, swipe-back navigation, and mobile-native time/date pickers. +- **Keyboard Shortcuts** — Press Enter to send messages; Escape to dismiss modals. + +--- + +## Managers (Tool Managers) + +Tool Manager access is granted by an admin to members of one or more designated **User Groups**. Managers have access to the following tools in addition to all user features. + +### User Manager + +- **View All Users** — Browse the full user directory including email, role, phone, status, and last seen time. +- **Create Users** — Add individual new user accounts with name, email, role, and phone. +- **Bulk Import** — Import multiple users at once from a structured list (CSV-compatible). +- **Edit Users** — Update names, email addresses, phone numbers, and minor status for any user. +- **Suspend / Activate** — Suspend a user to block login without deleting their account or messages. Reversible at any time. +- **Reset Password** — Set a new temporary password for any user. + +### Group Manager (requires RosterChirp-Team) + +- **Create User Groups** — Create named user groups to organise members into teams or departments. +- **Manage Members** — Add or remove users from any user group. Member changes trigger a system notification in the group's conversation. +- **Multi-Groups** — Create a multi-group conversation that spans multiple user groups simultaneously. +- **Assign Schedule Groups** — Link user groups to schedule events to control who is invited and whose availability is tracked. + +### Schedule Manager (requires RosterChirp-Team) + +- **Create Events** — Create new calendar events with title, type, date/time, location, description, visibility (public/private), and assigned user groups. +- **Edit & Delete Events** — Modify or remove any event. Recurring events support editing/deleting a single occurrence, all future occurrences, or the entire series. +- **Recurring Events** — Schedule repeating events (daily, weekly, bi-weekly, monthly) with optional end date or occurrence count. Supports specific weekday selection for weekly recurrence. +- **Event Types** — Create and manage colour-coded event type categories (e.g. Training, Match, Meeting). +- **Track Availability** — Enable availability tracking on an event to collect Going / Maybe / Not Going responses from assigned group members. +- **View Full Responses** — See the complete list of who has responded and with what answer, including individual notes. The **No Response** count shows how many assigned members have not yet replied. +- **Download Availability List** — Export a formatted `.txt` file of all availability responses for an event, organised by section (Going, Maybe, Not Going, No Response) and sorted alphabetically by last name within each section. +- **Import Schedule** — Upload and preview a schedule import file, then confirm to bulk-create events. +- **Past Event Visibility** — View and manage past events in the calendar; past events are displayed in a greyed style. + +--- + +## Admins + +Admins have full access to all user and manager features plus the following administrative controls. + +### User Manager (extended) + +- **Delete Users** — Permanently scrub a user's account: email and name are anonymised, all their messages are marked deleted, and direct message threads become read-only. Frees the email address for re-registration immediately. +- **Assign Roles** — Promote or demote users between the **User**, **Manager**, and **Admin** roles. + +### Settings + +- **Message Features** — Enable or disable individual message channel types across the entire instance: Public Messages, Group Messages, Private Group Messages, and Private Messages (U2U). Disabled features are hidden from all menus, sidebars, and modals. +- **Registration** — Apply a registration code to unlock the application type (Chat / Brand / Team) and associated features. View the instance serial number and current registration status. + +### Branding (requires RosterChirp-Brand or higher) + +- **App Name** — Set a custom application name that appears in the header, browser tab, and push notifications. +- **Logo / Favicon** — Upload a custom logo used as the app header image and PWA icon (192×512 px generated automatically). +- **Header Colour** — Set custom header bar colours for light mode and dark mode independently. +- **Avatar Colours** — Customise the default avatar colours used for public channel icons and direct message icons. +- **Reset Branding** — Restore all branding settings to the default RosterChirp values in one click. + +### Team Configuration (requires RosterChirp-Team) + +- **Tool Manager Groups** — Designate one or more User Groups whose members are granted Tool Manager access (User Manager, Group Manager, Schedule Manager). Admins always have full access regardless of this setting. + +### Control Panel (Host mode only — admin on the host domain) + +- **Tenant Management** — View, create, suspend, and delete tenant instances from a central dashboard. +- **Assign Plans** — Set the application type (Chat / Brand / Team) for each tenant. +- **Custom Domains** — Assign a custom domain to a tenant in addition to its default subdomain. +- **Tenant Details** — View each tenant's slug, plan, status, custom domain, and creation date. + +--- + +## Hosting & Tenant Privacy + +RosterChirp supports two deployment modes configured via the `APP_TYPE` environment variable. + +### Self-Hosted (Single Tenant) + +`APP_TYPE=selfhost` — The default mode for teams running their own private instance. All data is stored in a single PostgreSQL schema. There are no subdomains or tenant concepts; the application runs at the root of whatever domain or IP the server is deployed on. + +### RosterChirp-Host (Multi-Tenant) + +`APP_TYPE=host` — Enables multi-tenant hosting from a single server. Each tenant is provisioned with: + +- **A unique slug** — for example, the slug `acme` creates a dedicated instance accessible at `acme.yourdomain.com`. The slug is set at provisioning time and forms the permanent subdomain for that tenant. +- **An isolated Postgres schema** — every tenant's data (users, messages, groups, events, settings) lives in its own named schema (`tenant_acme`, etc.) within the same database. No data is shared between tenants. +- **An optional custom domain** — a tenant can be mapped to a fully custom domain (e.g. `chat.acme.com`) in addition to its default subdomain. Custom domain lookups are cached for performance. +- **Plan-level feature control** — each tenant can be assigned a different application type (Chat / Brand / Team), enabling per-tenant feature gating from the host control panel. + +### Privacy & Isolation Guarantees + +- **Schema isolation** — all database queries are scoped to the tenant's schema. A query in one tenant's context cannot read or write another tenant's tables. +- **Socket room isolation** — all real-time socket rooms are prefixed with the tenant schema name (`acme:group:42`). Events emitted in one tenant's rooms cannot reach sockets in another tenant. +- **Online presence isolation** — the online user map is keyed by `schema:userId`, preventing user ID collisions between tenants from leaking presence data. +- **Session isolation** — JWT tokens are validated against the tenant schema. A valid token for one tenant is not accepted by another. +- **Host control plane separation** — the host admin control panel is only accessible on the host's own root domain, protected by a separate `HOST_ADMIN_KEY`, and hidden from all tenant subdomains. diff --git a/Reference/minor-age-protection.txt b/Reference/minor-age-protection.txt new file mode 100644 index 0000000..bbffa7e --- /dev/null +++ b/Reference/minor-age-protection.txt @@ -0,0 +1,63 @@ +RULE: minor age is when DOB is 15 years and under. + +User manager: +Enable "Date of Birth" field (default optional unless "Mixed Age" is selected in the (new) as "Login Type" on Settings modal page) +Enable "Guardian" field as optional + +My Profile modal: +Change: replace tab buttons with a select list labled "SELECT OPTION:" Profile - default selected (on both desktop and mobile) +On the "Profile" tab, add two new text inputs: Date of Birth and Phone (same format field verification as used in the user manager field) - once saved, user manager is updated with data from these two fields for the given user +Add a new select option "Add Child" (only displayed IF the new "Login Type" setting has either "Guardians Only" or "Mixed Age" selected. + +"Add Child" Form: + - (on user's my profile): Firstname, Lastname, Email, DOB, Profile avatar (follow avatar upload rules), number (input box), Add button, Save button (disabled until there is a child added to guardian's child list) + - Clicking the Add button will add child to the guardians child list.) + - Clicking save, will save the the guardian's child list and will each child entry to players user group. + +Settings modal: +Change: replace tab tabs buttons to a select list, labelled "SELECT OPTION" Messages is still the default option (on both desktop and mobile) +Add new selection "Login Type" to the list, form details below: + +An option list with brief description under: +Option: "Guardian Only" +Descriptions: "Parents are required to add their child's details in their profile. They will respond on behalf of the child for events with availability tracking for the "players" group". + + - User manager DOB is optional + - "Players" user group entry will be the child's name as an alias to the guardians account, if more than one child each entry will be treated uniquely for mentions and event availabillty responses. + - "Players" User Group DM will be disabled/hidden and cannot be added to Multi-Group DMs + - The event modal with Availabilty requested for the "players" user group, will have a new drop down select list (under the response buttons, above the note input box - hidden/disabled by default) and will only be unhidden/enable if the event includes the "players" user group, and only displayed for user who have a saved child list of at least one. The select list options will include the guardians child list, AND will also the guardian's name IF multiple user groups are selected for the event, with one be players, and at least one being a user group that the guardian is a member of that is not the players group. There will be a default option of "ALL", if selected, the response and note (if entered) will be the same for all "people" listed in the select list, but responses will be listed indivually in the response list for the event. + + Scenario 1: Event: party, track availability enabled, groups selected: players + parents + - select drop down will display option: "All" default selected, then guadians name on row 2 (parent user group), each child's name on susequent rows (listed in the players user group owned by the guardian) + - selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each + - selecting an idividual name will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list + Scenario 2: Event, track availability enabled, groups selected: players + - select drop down will display "All" on the top row, each child's name (listed in the players user group and owned by the guardian) + - selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each + - selecting an idividual name in the selectlist will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list + Scenario 3: Event, track availability enabled, groups selected: parents + - select drop down is hidden + - availability response and note is for the guardian only + +Option: "Mixed Age" +Descriptions: "Parents, or user managers, are required to add the minor aged child's user account to the guardians user profile. Minor aged users cannot login until this is complete." + - User manager DOB is required for all users + - Minor aged user's account are automatically suspended + on the add child form: + - search user input field, will display only a list of minor aged users. Selecting a user fills out the "Add Child" read-only form + - "Add Child" Form all fields are read-only: Firstname, Lastname, DOB*, user's avatar, number (input box), Add button when form is filled by selecting a user, Save button (disabled until there is a child added to guardian's child list). Requires admin approval. A message is sent to all users with the managers from from the default admin user account indicating "User Manager requires approval". + - The minor user account name that requires gaurdina name approval is bold red in the user manager list. When the "editing" user account, the Gaurdian field will be highlighted in red. Besides the label will be two link options [approve - green] [deny - red] (same size font as the label). Approve will clear the approval required flag and unsuspend the user account, with the guardian name save to the user's profile. + - Saving the form will update the minor aged user account with a guardian name, requires a user with manager role approval, once approved, the minor's user account is unsuspended. + - Events are handled like they are currently + - Guardians are not part of players user group DMs + - Guardians do not respond to availability on the childs behalf (new select list on the event form remains hidden/diabled) + - any private messages initiated by a user 18 years or older to a minor aged will automatically include the guardian user account as well. + -> a modal confirmation will be provide to the 18+ user that they are messaging a user that will also include their parent/guardian. + +Option: All Ages (default selection) + - "Add Child" hidden/disbled Profile select option list. + - Aliase select list is hidden/disabled + - Events are handled like they are currently + +Yellow warning symbol and text below: "This setting can only be set/changed when the user table in the database is empty." (form is read-only unless user manager is empty OR only has users with the admin role). +- if the setting is changed and users exisit with admin role, on each subsequent login the user's profile modal popup until the Date of Birth is entered. diff --git a/backend/package.json b/backend/package.json index a8df2e7..1514373 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.42", + "version": "0.12.43", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/migrations/015_minor_age_protection.sql b/backend/src/models/migrations/015_minor_age_protection.sql new file mode 100644 index 0000000..569fe67 --- /dev/null +++ b/backend/src/models/migrations/015_minor_age_protection.sql @@ -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); diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index cdfd8e5..a913e62 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -4,6 +4,11 @@ const router = express.Router(); const { query, queryOne, queryResult, exec } = require('../models/db'); 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) { if (!imageUrl) return; 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, ` - 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 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, @@ -80,7 +85,9 @@ router.get('/', authMiddleware, async (req, res) => { ORDER BY u2.name LIMIT 4 ) t) AS member_previews 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 `, [userId]); @@ -182,8 +189,30 @@ router.post('/', authMiddleware, async (req, res) => { 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, otherUserId]); + + // Mixed Age: if 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') { + 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); - 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 diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index 88c4f93..b147c54 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -241,10 +241,18 @@ router.get('/:id', authMiddleware, async (req, res) => { WHERE eug.event_id=$1 AND ugm.user_id=$2 `, [event.id, req.user.id])); if (event.track_availability && (itm || isMember)) { - event.availability = 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 + // User responses + 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 `, [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]; + if (itm) { const assignedRows = await query(req.schema, ` SELECT DISTINCT u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name @@ -253,11 +261,42 @@ router.get('/:id', authMiddleware, async (req, res) => { JOIN users u ON u.id=ugm.user_id WHERE eug.event_id=$1 `, [req.params.id]); - const respondedIds = new Set(event.availability.map(r => r.user_id)); - const noResponseRows = assignedRows.filter(r => !respondedIds.has(r.user_id)); + // Also include alias members + 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_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]))); + + // Return current user's aliases 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 ORDER BY first_name,last_name', + [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; @@ -564,19 +603,31 @@ router.put('/:id/availability', authMiddleware, async (req, res) => { 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.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' }); - const { response, note } = req.body; + const { response, note, aliasId } = req.body; 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 itm = await isToolManagerFn(req.schema, req.user); - 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 - `, [event.id, req.user.id]); - 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]); + + if (aliasId) { + // Alias response (Guardian Only mode) — verify alias belongs to current user + const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$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 + const itm = await isToolManagerFn(req.schema, req.user); + 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 + `, [event.id, req.user.id]); + 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 }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -593,7 +644,14 @@ router.patch('/:id/availability/note', authMiddleware, async (req, res) => { router.delete('/:id/availability', authMiddleware, async (req, res) => { 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 } = req.query; + if (aliasId) { + const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$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 }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index e7e9583..68085bf 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -152,6 +152,26 @@ router.patch('/messages', authMiddleware, adminMiddleware, async (req, res) => { } 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) => { const { toolManagers } = req.body; try { diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 3a980ec..c398b33 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -16,6 +16,17 @@ const uploadAvatar = multer({ 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) { const existing = await query(schema, "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); } +// 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 router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => { try { 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 }); } catch (e) { res.status(500).json({ error: e.message }); } @@ -86,7 +114,7 @@ router.get('/check-display-name', authMiddleware, async (req, res) => { // Create user 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) 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' }); @@ -94,45 +122,74 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => { const assignedRole = validRoles.includes(role) ? role : 'member'; const name = `${firstName.trim()} ${lastName.trim()}`; try { + const loginType = await getLoginType(req.schema); + if (loginType === 'mixed_age' && !dateOfBirth) + return res.status(400).json({ error: 'Date of birth is required in Mixed Age mode' }); + + 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()]); if (exists) return res.status(400).json({ error: 'Email already in use' }); const resolvedName = await resolveUniqueName(req.schema, name); const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234'; const hash = bcrypt.hashSync(pw, 10); 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", - [resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, !!isMinor] + "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, dob, initStatus] ); const userId = r.rows[0].id; - await addUserToPublicGroups(req.schema, userId); + if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId); if (assignedRole === 'admin') { 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]); } - 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]); - res.json({ user }); + 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,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 }); } }); -// 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) => { const id = parseInt(req.params.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()) return res.status(400).json({ error: 'First and last name required' }); const validRoles = ['member', 'admin', 'manager']; if (!validRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' }); try { + const loginType = await getLoginType(req.schema); + if (loginType === 'mixed_age' && !dateOfBirth) + return res.status(400).json({ error: 'Date of birth is required in Mixed Age mode' }); + const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.is_default_admin && role !== 'admin') 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); + + // 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, - 'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,role=$6,updated_at=NOW() WHERE id=$7', - [resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, !!isMinor, role, id] + '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, dob, guardianId, role, id] ); if (password && password.length >= 6) { const hash = bcrypt.hashSync(password, 10); @@ -143,7 +200,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, id]); } 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] ); res.json({ user }); @@ -324,7 +381,7 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, // Update own profile 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 { if (displayName) { const conflict = await queryOne(req.schema, @@ -333,12 +390,14 @@ router.patch('/me/profile', authMiddleware, async (req, res) => { ); 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, - 'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5', - [displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, req.user.id] + '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, dob, isMinor, phone?.trim() || null, req.user.id] ); 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] ); res.json({ user }); @@ -376,4 +435,173 @@ router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async ( } }); +// ── Guardian alias routes (Guardian Only mode) ────────────────────────────── + +// List current user'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 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', [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', [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', [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 }); } +}); + +// 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; diff --git a/build.sh b/build.sh index 11d8706..23b5a62 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.42}" +VERSION="${1:-0.12.43}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index a517550..e5ec80d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.42", + "version": "0.12.43", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/NewChatModal.jsx b/frontend/src/components/NewChatModal.jsx index 1983316..69a4da2 100644 --- a/frontend/src/components/NewChatModal.jsx +++ b/frontend/src/components/NewChatModal.jsx @@ -21,6 +21,9 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) { const [users, setUsers] = useState([]); const [selected, setSelected] = useState([]); const [loading, setLoading] = useState(false); + // Mixed Age: guardian confirmation modal + const [guardianConfirm, setGuardianConfirm] = useState(null); // { group, guardianName } + const loginType = features.loginType || 'all_ages'; // True when exactly 1 user selected on private tab AND U2U messages are enabled const isDirect = tab === 'private' && selected.length === 1 && msgU2U; @@ -69,13 +72,18 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) { }; } - const { group, duplicate } = await api.createGroup(payload); + const { group, duplicate, guardianAdded, guardianName } = await api.createGroup(payload); if (duplicate) { toast('A group with these members already exists — opening it now.', 'info'); + onCreated(group); } else { toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success'); + if (guardianAdded && guardianName) { + setGuardianConfirm({ group, guardianName }); + } else { + onCreated(group); + } } - onCreated(group); } catch (e) { toast(e.message, 'error'); } finally { @@ -172,6 +180,20 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) { + + {guardianConfirm && ( +
+ {guardianConfirm.guardianName} has been added to this conversation as the guardian of this minor. +
+