// Task Team — Invitation System — 2026-03-30 const crypto = require('crypto'); async function invitationRoutes(app) { // Create invitation app.post('/invitations', async (req) => { const { task_id, invitee_email, invitee_name, message, inviter_id } = req.body; if (!task_id) throw { statusCode: 400, message: 'task_id is required' }; const token = crypto.randomBytes(32).toString('hex'); const { rows } = await app.db.query( `INSERT INTO invitations (token, task_id, inviter_id, invitee_email, invitee_name, message) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [token, task_id, inviter_id || null, invitee_email || null, invitee_name || '', message || ''] ); const invite = rows[0]; const link = `https://tasks.hasdo.info/invite/${token}`; // Generate share links const taskRes = await app.db.query('SELECT title FROM tasks WHERE id=$1', [task_id]); const taskTitle = taskRes.rows[0]?.title || 'Task'; let inviterName = 'Someone'; if (inviter_id) { const inviterRes = await app.db.query('SELECT name FROM users WHERE id=$1', [inviter_id]); inviterName = inviterRes.rows[0]?.name || 'Someone'; } const shareText = `${inviterName} te pozval do Task Team!\n${taskTitle}\n\n${link}`; const shareLinks = { whatsapp: `https://wa.me/?text=${encodeURIComponent(shareText)}`, telegram: `https://t.me/share/url?url=${encodeURIComponent(link)}&text=${encodeURIComponent(`${inviterName} te pozval: ${taskTitle}`)}`, sms: `sms:${invitee_email || ''}?body=${encodeURIComponent(shareText)}`, copy: link }; return { data: { invitation: invite, link, share: shareLinks } }; }); // Get invitation by token (public - no auth needed) app.get('/invite/:token', async (req) => { const { rows } = await app.db.query( `SELECT i.*, t.title as task_title, t.description as task_description, t.due_at, u.name as inviter_name, u.avatar_url as inviter_avatar FROM invitations i LEFT JOIN tasks t ON i.task_id = t.id LEFT JOIN users u ON i.inviter_id = u.id WHERE i.token = $1`, [req.params.token] ); if (!rows.length) throw { statusCode: 404, message: 'Invitation not found' }; const invite = rows[0]; if (invite.status !== 'pending') throw { statusCode: 410, message: 'Invitation already used' }; if (new Date(invite.expires_at) < new Date()) throw { statusCode: 410, message: 'Invitation expired' }; return { data: invite }; }); // Accept invitation (registers + assigns to task) app.post('/invite/:token/accept', async (req) => { const { name, password, email } = req.body; // Get invitation const { rows: invites } = await app.db.query('SELECT * FROM invitations WHERE token=$1', [req.params.token]); if (!invites.length) throw { statusCode: 404, message: 'Invitation not found' }; const invite = invites[0]; if (invite.status !== 'pending') throw { statusCode: 410, message: 'Already accepted' }; if (new Date(invite.expires_at) < new Date()) throw { statusCode: 410, message: 'Invitation expired' }; const userEmail = email || invite.invitee_email; if (!userEmail) throw { statusCode: 400, message: 'Email is required' }; // Find or create user let { rows: users } = await app.db.query('SELECT * FROM users WHERE email=$1', [userEmail]); let userId; if (users.length) { userId = users[0].id; } else { // Register new user const bcrypt = require('bcryptjs'); const hash = password ? await bcrypt.hash(password, 12) : null; const settings = hash ? { password_hash: hash } : {}; const { rows: newUser } = await app.db.query( 'INSERT INTO users (email, name, auth_provider, settings) VALUES ($1, $2, $3, $4) RETURNING id, email, name', [userEmail, name || invite.invitee_name || 'New User', 'email', JSON.stringify(settings)] ); userId = newUser[0].id; } // Assign to task (avoid duplicates) await app.db.query( `UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid) WHERE id = $2 AND NOT ($1::uuid = ANY(COALESCE(assigned_to, ARRAY[]::uuid[])))`, [userId, invite.task_id] ); // Mark invitation accepted await app.db.query( "UPDATE invitations SET status='accepted', accepted_at=NOW() WHERE id=$1", [invite.id] ); // Generate JWT const token = app.jwt.sign({ id: userId, email: userEmail }, { expiresIn: '7d' }); return { data: { user: { id: userId, email: userEmail, name: name || invite.invitee_name }, token, task_id: invite.task_id } }; }); // List invitations for a task app.get('/tasks/:taskId/invitations', async (req) => { const { rows } = await app.db.query( 'SELECT * FROM invitations WHERE task_id=$1 ORDER BY created_at DESC', [req.params.taskId] ); return { data: rows }; }); // Revoke invitation app.delete('/invitations/:id', async (req) => { const { rows } = await app.db.query( "UPDATE invitations SET status='revoked' WHERE id=$1 AND status='pending' RETURNING *", [req.params.id] ); if (!rows.length) throw { statusCode: 404, message: 'Invitation not found or already used' }; return { data: rows[0] }; }); } module.exports = invitationRoutes;