From 97f1dace4f69d792fa95fcf9dd9574c771d9ae3f Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Fri, 27 Mar 2026 10:19:52 -0400 Subject: [PATCH] v0.12.31 multiple UI changes --- backend/package.json | 2 +- backend/src/routes/schedule.js | 54 ++++++++-- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/ChatWindow.jsx | 14 ++- frontend/src/components/MessageInput.jsx | 5 +- frontend/src/components/MobileEventForm.jsx | 16 +-- frontend/src/components/SchedulePage.jsx | 108 ++++++++++++-------- frontend/src/pages/Chat.jsx | 39 +++++-- frontend/src/utils/api.js | 1 + 10 files changed, 174 insertions(+), 69 deletions(-) diff --git a/backend/package.json b/backend/package.json index 5614aef..e942422 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.30", + "version": "0.12.31", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index 9148862..4fccf9b 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -177,6 +177,20 @@ router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async ( } catch (e) { res.status(500).json({ error: e.message }); } }); +// ── User's own groups (for regular users creating events) ───────────────────── + +router.get('/my-groups', authMiddleware, async (req, res) => { + try { + const groups = await query(req.schema, ` + SELECT ug.id, ug.name FROM user_groups ug + JOIN user_group_members ugm ON ugm.user_group_id = ug.id + WHERE ugm.user_id = $1 + ORDER BY ug.name ASC + `, [req.user.id]); + res.json({ groups }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + // ── Events ──────────────────────────────────────────────────────────────────── router.get('/', authMiddleware, async (req, res) => { @@ -244,31 +258,55 @@ router.get('/:id', authMiddleware, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); -router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => { +router.post('/', authMiddleware, async (req, res) => { const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = req.body; if (!title?.trim()) return res.status(400).json({ error: 'Title required' }); if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' }); try { + const itm = await isToolManagerFn(req.schema, req.user); + const groupIds = Array.isArray(userGroupIds) ? userGroupIds : []; + if (!itm) { + // Regular users: must select at least one group they belong to; event always private + if (!groupIds.length) return res.status(400).json({ error: 'Select at least one group' }); + for (const ugId of groupIds) { + const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]); + if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' }); + } + } + const effectiveIsPublic = itm ? (isPublic !== false) : false; const r = await queryResult(req.schema, ` INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id `, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null, - isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]); + effectiveIsPublic, !!trackAvailability, recurrenceRule||null, req.user.id]); const eventId = r.rows[0].id; - for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : [])) + for (const ugId of groupIds) await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]); - if (Array.isArray(userGroupIds) && userGroupIds.length > 0) + if (groupIds.length > 0) await postEventNotification(req.schema, eventId, req.user.id, false); const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]); res.json({ event: await enrichEvent(req.schema, event) }); } catch (e) { res.status(500).json({ error: e.message }); } }); -router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { +router.patch('/:id', authMiddleware, async (req, res) => { try { 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' }); - const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body; + const itm = await isToolManagerFn(req.schema, req.user); + if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' }); + let { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body; + if (!itm) { + // Regular users editing their own event: force private, validate group membership + isPublic = false; + if (Array.isArray(userGroupIds)) { + if (!userGroupIds.length) return res.status(400).json({ error: 'Select at least one group' }); + for (const ugId of userGroupIds) { + const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]); + if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' }); + } + } + } const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event }; await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds); @@ -317,10 +355,12 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => } catch (e) { res.status(500).json({ error: e.message }); } }); -router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { +router.delete('/:id', authMiddleware, async (req, res) => { try { 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' }); + const itm = await isToolManagerFn(req.schema, req.user); + if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' }); const { recurringScope } = req.body || {}; if (recurringScope === 'future' && event.recurrence_rule) { // Delete this event and all future occurrences with same creator/title diff --git a/build.sh b/build.sh index db76970..13b39f2 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.30}" +VERSION="${1:-0.12.31}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 78ebc4f..f0a90e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.30", + "version": "0.12.31", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index eb62333..003042a 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -39,6 +39,18 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess return () => window.removeEventListener('resize', onResize); }, []); + // On mobile, when the soft keyboard opens the visual viewport shrinks but the + // messages-container scroll position stays where it was, leaving the latest + // messages hidden behind the keyboard. Scroll to bottom whenever the visual + // viewport resizes (keyboard appear/dismiss) so the last message stays visible. + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return; + const onVVResize = () => scrollToBottom(); + vv.addEventListener('resize', onVVResize); + return () => vv.removeEventListener('resize', onVVResize); + }, [scrollToBottom]); + useEffect(() => { api.getSettings().then(({ settings }) => { setIconGroupInfo(settings.icon_groupinfo || ''); @@ -339,7 +351,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess This channel is read-only ) : ( - setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} /> + setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} /> )} {showInfo && ( diff --git a/frontend/src/components/MessageInput.jsx b/frontend/src/components/MessageInput.jsx index 1df3565..360ed40 100644 --- a/frontend/src/components/MessageInput.jsx +++ b/frontend/src/components/MessageInput.jsx @@ -12,7 +12,7 @@ function isEmojiOnly(str) { return emojiRegex.test(str.trim()); } -export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onlineUserIds = new Set() }) { +export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onInputFocus, onlineUserIds = new Set() }) { const [text, setText] = useState(''); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(null); @@ -380,10 +380,11 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on