diff --git a/api/src/index.js b/api/src/index.js index d302172..6f435d2 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -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" }); diff --git a/api/src/routes/invitations.js b/api/src/routes/invitations.js new file mode 100644 index 0000000..37174ce --- /dev/null +++ b/api/src/routes/invitations.js @@ -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; diff --git a/apps/tasks/app/invite/[token]/page.tsx b/apps/tasks/app/invite/[token]/page.tsx new file mode 100644 index 0000000..b2fd5fb --- /dev/null +++ b/apps/tasks/app/invite/[token]/page.tsx @@ -0,0 +1,192 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +interface InviteData { + inviter_name: string | null; + inviter_avatar: string | null; + invitee_name: string | null; + invitee_email: string | null; + task_title: string | null; + task_description: string | null; + due_at: string | null; + message: string | null; +} + +export default function InvitePage() { + const { token } = useParams(); + const router = useRouter(); + const [invite, setInvite] = useState(null); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [accepting, setAccepting] = useState(false); + + useEffect(() => { + fetch(`/api/v1/invite/${token}`) + .then(r => { + if (!r.ok) return r.json().then(d => { throw new Error(d.message || 'Not found'); }); + return r.json(); + }) + .then(d => { + setInvite(d.data); + setName(d.data?.invitee_name || ''); + setEmail(d.data?.invitee_email || ''); + }) + .catch(e => setError(e.message || 'Pozvanka nenalezena')) + .finally(() => setLoading(false)); + }, [token]); + + async function accept() { + if (!name || name.trim().length < 2) { + setError('Jmeno musi mit alespon 2 znaky'); + return; + } + setAccepting(true); + setError(''); + try { + const res = await fetch(`/api/v1/invite/${token}/accept`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + password: password || undefined, + email: email || invite?.invitee_email + }) + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || 'Chyba pri prijimani'); + } + if (data.data?.token) { + localStorage.setItem('taskteam_token', data.data.token); + localStorage.setItem('taskteam_user', JSON.stringify(data.data.user)); + router.push('/tasks'); + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Chyba pri prijimani'); + } + setAccepting(false); + } + + if (loading) { + return ( +
+
+
+ ); + } + + if (error && !invite) { + return ( +
+
+
😕
+

Pozvanka neni platna

+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+
🎉
+

Task Team

+
+ {invite?.inviter_avatar ? ( + + ) : ( +
+ {invite?.inviter_name?.[0]?.toUpperCase() || '?'} +
+ )} + + {invite?.inviter_name || 'Nekdo'} te zve + +
+
+ +
+
+ 📋 + {invite?.task_title || 'Task'} +
+ {invite?.task_description && ( +

+ {invite.task_description} +

+ )} + {invite?.due_at && ( +
+ 📅 {new Date(invite.due_at).toLocaleDateString('cs-CZ')} +
+ )} + {invite?.message && ( +

+ “{invite.message}” +

+ )} +
+ + {error && ( +
+ {error} +
+ )} + +
+ setName(e.target.value)} + placeholder="Tvoje jmeno" + className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none" + /> + {!invite?.invitee_email && ( + setEmail(e.target.value)} + placeholder="Tvuj email" + className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none" + /> + )} + setPassword(e.target.value)} + placeholder="Heslo (min 6 znaku)" + className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none" + /> + + +
+
+
+ ); +} diff --git a/apps/tasks/app/tasks/[id]/page.tsx b/apps/tasks/app/tasks/[id]/page.tsx index 7e02f97..f98dec8 100644 --- a/apps/tasks/app/tasks/[id]/page.tsx +++ b/apps/tasks/app/tasks/[id]/page.tsx @@ -16,6 +16,7 @@ import { searchUsers, } from "@/lib/api"; import TaskForm from "@/components/TaskForm"; +import InviteModal from "@/components/InviteModal"; import StatusBadge from "@/components/StatusBadge"; function isDone(status: string): boolean { @@ -23,7 +24,7 @@ function isDone(status: string): boolean { } export default function TaskDetailPage() { - const { token } = useAuth(); + const { token, user } = useAuth(); const { t, locale } = useTranslation(); const router = useRouter(); const params = useParams(); @@ -36,6 +37,7 @@ export default function TaskDetailPage() { const [error, setError] = useState(""); const [subtasks, setSubtasks] = useState([]); const [assigneeNames, setAssigneeNames] = useState>({}); + const [showInvite, setShowInvite] = useState(false); const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU"; @@ -383,6 +385,16 @@ export default function TaskDetailPage() {

{t("collab.noAssignees")}

)}
+ + {/* Invite Modal */} + {showInvite && token && user && ( + setShowInvite(false)} + /> + )} ); } diff --git a/apps/tasks/components/InviteModal.tsx b/apps/tasks/components/InviteModal.tsx new file mode 100644 index 0000000..3c3073e --- /dev/null +++ b/apps/tasks/components/InviteModal.tsx @@ -0,0 +1,184 @@ +'use client'; +import { useState } from 'react'; +import { createInvitation, InvitationResponse } from '@/lib/api'; + +interface InviteModalProps { + taskId: string; + token: string; + userId: string; + onClose: () => void; +} + +export default function InviteModal({ taskId, token, userId, onClose }: InviteModalProps) { + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [message, setMessage] = useState(''); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(''); + const [copied, setCopied] = useState(false); + + async function handleInvite() { + setLoading(true); + setError(''); + try { + const res = await createInvitation(token, { + task_id: taskId, + invitee_email: email || undefined, + invitee_name: name || undefined, + message: message || undefined, + inviter_id: userId, + }); + setResult(res.data); + } catch (e) { + setError(e instanceof Error ? e.message : 'Chyba pri vytvareni pozvanky'); + } + setLoading(false); + } + + async function copyLink() { + if (!result) return; + try { + await navigator.clipboard.writeText(result.link); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback + const input = document.createElement('input'); + input.value = result.link; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + } + + return ( +
+
e.stopPropagation()} + > +
+

Pozvat do ukolu

+ +
+ + {!result ? ( +
+ setName(e.target.value)} + placeholder="Jmeno (nepovinne)" + className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none" + /> + setEmail(e.target.value)} + placeholder="Email (nepovinne)" + className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none" + /> +