- invitations table (token, task, inviter, expiry 7d) - POST /invitations (create + share links: WhatsApp, Telegram, SMS, Copy) - GET /invite/:token (public landing page) - POST /invite/:token/accept (register + assign + JWT) - Landing page at /invite/[token] with accept flow - CLAUDE.md + Notion key deployed to all servers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
141 lines
5.8 KiB
JavaScript
141 lines
5.8 KiB
JavaScript
// 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;
|