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:
@@ -114,6 +114,7 @@ const start = async () => {
|
||||
await app.register(require("./routes/collaboration"), { prefix: "/api/v1" });
|
||||
await app.register(require("./routes/email"), { prefix: "/api/v1" });
|
||||
await app.register(require("./routes/errors"), { prefix: "/api/v1" });
|
||||
await app.register(require("./routes/invitations"), { prefix: "/api/v1" });
|
||||
|
||||
try {
|
||||
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });
|
||||
|
||||
140
api/src/routes/invitations.js
Normal file
140
api/src/routes/invitations.js
Normal 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;
|
||||
Reference in New Issue
Block a user