Invitation system: DB + API + landing page + share links

- 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>
This commit is contained in:
2026-03-30 00:31:22 +00:00
parent 653805af4c
commit 703541d29a
6 changed files with 571 additions and 1 deletions

View File

@@ -0,0 +1,140 @@
// 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;