v0.12.49 family rules update

This commit is contained in:
2026-04-01 16:26:58 -04:00
parent 7031979571
commit 3910063ed3
6 changed files with 258 additions and 26 deletions

View 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);

View File

@@ -48,6 +48,14 @@ async function postEventNotification(schema, eventId, actorId) {
// ── 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) {
if (user.role === 'admin' || user.role === 'manager') return true;
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
@@ -73,7 +81,25 @@ async function canViewEvent(schema, event, userId, isToolManager) {
JOIN guardian_aliases ga ON ga.id=agm.alias_id
WHERE eug.event_id=$1 AND ga.guardian_id=$2
`, [event.id, userId]);
return !!aliasAssigned;
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) {
@@ -243,6 +269,7 @@ router.get('/:id', authMiddleware, async (req, res) => {
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' });
await enrichEvent(req.schema, event);
const partnerId = await getPartnerId(req.schema, req.user.id);
const isMember = !itm && !!(
(await queryOne(req.schema, `
SELECT 1 FROM event_user_groups eug
@@ -257,6 +284,22 @@ router.get('/:id', authMiddleware, async (req, res) => {
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)) {
// User responses
@@ -274,7 +317,13 @@ router.get('/:id', authMiddleware, async (req, res) => {
// 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', [req.user.id])).map(r => r.id)
(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;
@@ -318,12 +367,22 @@ router.get('/:id', authMiddleware, async (req, res) => {
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])));
(
(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 for the responder dropdown (Guardian Only)
// 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 ORDER BY first_name,last_name',
`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]
);
}
@@ -638,20 +697,28 @@ router.put('/:id/availability', authMiddleware, async (req, res) => {
const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
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]);
// 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
// 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
`, [event.id, req.user.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())
@@ -676,7 +743,14 @@ router.delete('/:id/availability', authMiddleware, async (req, res) => {
try {
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]);
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 {
@@ -692,14 +766,15 @@ router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
try {
let saved = 0;
const itm = await isToolManagerFn(req.schema, req.user);
const bulkPartnerId = await getPartnerId(req.schema, req.user.id);
for (const { eventId, response } of responses) {
if (!['going','maybe','not_going'].includes(response)) continue;
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
if (!event || !event.track_availability) continue;
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
`, [eventId, req.user.id]);
WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
`, [eventId, req.user.id, bulkPartnerId || -1]);
if (!inGroup && !itm) continue;
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())

View File

@@ -451,11 +451,58 @@ router.get('/aliases-all', authMiddleware, teamManagerMiddleware, async (req, re
} catch (e) { res.status(500).json({ error: e.message }); }
});
// List current user's aliases
// 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
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)
router.post('/me/partner', authMiddleware, async (req, res) => {
const userId = req.user.id;
const partnerId = parseInt(req.body.partnerId);
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 {
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [userId]);
await exec(req.schema, 'INSERT INTO guardian_partners (user_id_1,user_id_2) VALUES ($1,$2)', [uid1, uid2]);
const partner = await queryOne(req.schema,
'SELECT id,name,display_name,avatar FROM users WHERE id=$1',
[partnerId]
);
res.json({ partner });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Remove partner
router.delete('/me/partner', authMiddleware, async (req, res) => {
try {
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 ORDER BY first_name,last_name',
`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 });
@@ -496,7 +543,14 @@ router.patch('/me/aliases/:aliasId', 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 existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
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',
@@ -514,7 +568,14 @@ router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
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]);
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 });
@@ -526,7 +587,14 @@ router.post('/me/aliases/:aliasId/avatar', authMiddleware, uploadAliasAvatar.sin
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]);
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;