Compare commits

...

2 Commits

Author SHA1 Message Date
703541d29a 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>
2026-03-30 00:31:22 +00:00
653805af4c Fix GDPR export, delete-account cascade, errors.js boot crash
- Add GET /auth/export-data endpoint (GDPR data download)
- Fix delete-account: comprehensive cascade delete + /delete-account alias
- Remove non-existent sessions table reference that caused 500 errors
- Fix errors.js: add missing CREATE TABLE statement (was causing SyntaxError on boot)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:24:21 +00:00
8 changed files with 611 additions and 10 deletions

View File

@@ -114,6 +114,7 @@ const start = async () => {
await app.register(require("./routes/collaboration"), { prefix: "/api/v1" }); await app.register(require("./routes/collaboration"), { prefix: "/api/v1" });
await app.register(require("./routes/email"), { 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/errors"), { prefix: "/api/v1" });
await app.register(require("./routes/invitations"), { prefix: "/api/v1" });
try { try {
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" }); await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });

View File

@@ -101,16 +101,36 @@ async function authRoutes(app) {
return { status: 'not_implemented', message: 'WebAuthn biometric auth coming soon.' }; return { status: 'not_implemented', message: 'WebAuthn biometric auth coming soon.' };
}); });
// Delete account // Delete account (GDPR + Google Play requirement)
app.delete('/auth/account', { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req, reply) => { app.delete("/auth/account", { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req, reply) => {
const uid = req.user.id; const uid = req.user.id;
await app.db.query('DELETE FROM task_assignments WHERE user_id=$1 OR assigned_by=$1', [uid]); await app.db.query("DELETE FROM task_comments WHERE user_id=$1", [uid]);
await app.db.query('DELETE FROM tasks WHERE user_id=$1', [uid]); await app.db.query("DELETE FROM subtasks WHERE assigned_to=$1", [uid]);
await app.db.query('DELETE FROM task_groups WHERE user_id=$1', [uid]); await app.db.query("DELETE FROM task_collaboration WHERE from_user_id=$1 OR to_user_id=$1", [uid]);
await app.db.query('DELETE FROM goals WHERE user_id=$1', [uid]); await app.db.query("DELETE FROM task_assignments WHERE user_id=$1 OR assigned_by=$1", [uid]);
await app.db.query('DELETE FROM sessions WHERE user_id=$1', [uid]); await app.db.query("DELETE FROM push_subscriptions WHERE user_id=$1", [uid]);
await app.db.query('DELETE FROM users WHERE id=$1', [uid]); await app.db.query("DELETE FROM goals WHERE user_id=$1", [uid]);
return reply.send({ data: { deleted: true } }); await app.db.query("DELETE FROM connectors WHERE user_id=$1", [uid]);
await app.db.query("DELETE FROM tasks WHERE user_id=$1", [uid]);
await app.db.query("DELETE FROM task_groups WHERE user_id=$1", [uid]);
await app.db.query("DELETE FROM users WHERE id=$1", [uid]);
return reply.send({ data: { deleted: true, message: "Account and all data permanently deleted" } });
});
// Alias: /auth/delete-account (backward compat)
app.delete("/auth/delete-account", { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req, reply) => {
const uid = req.user.id;
await app.db.query("DELETE FROM task_comments WHERE user_id=$1", [uid]);
await app.db.query("DELETE FROM subtasks WHERE assigned_to=$1", [uid]);
await app.db.query("DELETE FROM task_collaboration WHERE from_user_id=$1 OR to_user_id=$1", [uid]);
await app.db.query("DELETE FROM task_assignments WHERE user_id=$1 OR assigned_by=$1", [uid]);
await app.db.query("DELETE FROM push_subscriptions WHERE user_id=$1", [uid]);
await app.db.query("DELETE FROM goals WHERE user_id=$1", [uid]);
await app.db.query("DELETE FROM connectors WHERE user_id=$1", [uid]);
await app.db.query("DELETE FROM tasks WHERE user_id=$1", [uid]);
await app.db.query("DELETE FROM task_groups WHERE user_id=$1", [uid]);
await app.db.query("DELETE FROM users WHERE id=$1", [uid]);
return reply.send({ data: { deleted: true, message: "Account and all data permanently deleted" } });
}); });
// OAuth initiate routes moved to ./oauth.js // OAuth initiate routes moved to ./oauth.js

View File

@@ -1,6 +1,17 @@
// Task Team — Error Tracking — 2026-03-29 // Task Team — Error Tracking — 2026-03-29
async function errorRoutes(app) { async function errorRoutes(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS error_logs (
id SERIAL PRIMARY KEY,
level VARCHAR(20) DEFAULT 'error',
message TEXT,
stack TEXT,
url TEXT,
method VARCHAR(10),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_errors_created ON error_logs(created_at DESC); CREATE INDEX IF NOT EXISTS idx_errors_created ON error_logs(created_at DESC);
`); `);

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;

View File

@@ -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<InviteData | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-blue-600 to-blue-800">
<div className="animate-spin h-8 w-8 border-b-2 border-white rounded-full" />
</div>
);
}
if (error && !invite) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-blue-600 to-blue-800 p-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl text-center">
<div className="text-4xl mb-4">&#128533;</div>
<h1 className="text-xl font-bold mb-2">Pozvanka neni platna</h1>
<p className="text-gray-500 mb-4">{error}</p>
<button
onClick={() => router.push('/login')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium"
>
Prihlasit se
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-blue-600 to-blue-800 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl">
<div className="text-center mb-6">
<div className="text-4xl mb-2">&#127881;</div>
<h1 className="text-xl font-bold">Task Team</h1>
<div className="flex items-center justify-center gap-2 mt-3">
{invite?.inviter_avatar ? (
<img
src={invite.inviter_avatar}
alt=""
className="w-10 h-10 rounded-full"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold">
{invite?.inviter_name?.[0]?.toUpperCase() || '?'}
</div>
)}
<span className="text-gray-600 dark:text-gray-300">
{invite?.inviter_name || 'Nekdo'} te zve
</span>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 mb-4">
<div className="flex items-center gap-2 mb-1">
<span>&#128203;</span>
<span className="font-semibold">{invite?.task_title || 'Task'}</span>
</div>
{invite?.task_description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
{invite.task_description}
</p>
)}
{invite?.due_at && (
<div className="text-sm text-gray-500 dark:text-gray-400 mt-2">
&#128197; {new Date(invite.due_at).toLocaleDateString('cs-CZ')}
</div>
)}
{invite?.message && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2 italic border-l-2 border-blue-400 pl-2">
&ldquo;{invite.message}&rdquo;
</p>
)}
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg p-2 mb-3">
{error}
</div>
)}
<div className="space-y-3">
<input
value={name}
onChange={e => 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 && (
<input
type="email"
value={email}
onChange={e => 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"
/>
)}
<input
type="password"
value={password}
onChange={e => 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"
/>
<button
onClick={accept}
disabled={accepting || !name.trim()}
className="w-full py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg font-bold text-lg disabled:opacity-50 transition-colors"
>
{accepting ? 'Zpracovavam...' : 'Prijmout a zaregistrovat'}
</button>
<button
onClick={() => router.push('/login')}
className="w-full py-2 text-blue-600 dark:text-blue-400 text-sm hover:underline"
>
Mam ucet - prihlasit se
</button>
</div>
</div>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import {
searchUsers, searchUsers,
} from "@/lib/api"; } from "@/lib/api";
import TaskForm from "@/components/TaskForm"; import TaskForm from "@/components/TaskForm";
import InviteModal from "@/components/InviteModal";
import StatusBadge from "@/components/StatusBadge"; import StatusBadge from "@/components/StatusBadge";
function isDone(status: string): boolean { function isDone(status: string): boolean {
@@ -23,7 +24,7 @@ function isDone(status: string): boolean {
} }
export default function TaskDetailPage() { export default function TaskDetailPage() {
const { token } = useAuth(); const { token, user } = useAuth();
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
@@ -36,6 +37,7 @@ export default function TaskDetailPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [subtasks, setSubtasks] = useState<Subtask[]>([]); const [subtasks, setSubtasks] = useState<Subtask[]>([]);
const [assigneeNames, setAssigneeNames] = useState<Record<string, string>>({}); const [assigneeNames, setAssigneeNames] = useState<Record<string, string>>({});
const [showInvite, setShowInvite] = useState(false);
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU"; const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
@@ -383,6 +385,16 @@ export default function TaskDetailPage() {
<p className="text-sm text-gray-400">{t("collab.noAssignees")}</p> <p className="text-sm text-gray-400">{t("collab.noAssignees")}</p>
)} )}
</div> </div>
{/* Invite Modal */}
{showInvite && token && user && (
<InviteModal
taskId={id}
token={token}
userId={user.id}
onClose={() => setShowInvite(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -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<InvitationResponse | null>(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 (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">Pozvat do ukolu</h2>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{!result ? (
<div className="space-y-3">
<input
value={name}
onChange={e => 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"
/>
<input
type="email"
value={email}
onChange={e => 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"
/>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Zprava (nepovinne)"
rows={2}
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 resize-none"
/>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg p-2">
{error}
</div>
)}
<button
onClick={handleInvite}
disabled={loading}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold disabled:opacity-50 transition-colors"
>
{loading ? 'Vytvarim...' : 'Vytvorit pozvanku'}
</button>
</div>
) : (
<div className="space-y-3">
<div className="bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 rounded-lg p-3 text-sm text-center">
Pozvanka vytvorena!
</div>
{/* Link display */}
<div className="flex items-center gap-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<input
readOnly
value={result.link}
className="flex-1 bg-transparent text-sm outline-none truncate"
/>
<button
onClick={copyLink}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
copied
? 'bg-green-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
{copied ? 'Skopirovano!' : 'Kopirovat'}
</button>
</div>
{/* Share buttons */}
<div className="grid grid-cols-3 gap-2">
<a
href={result.share.whatsapp}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-1 p-3 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-xl transition-colors"
>
<svg className="w-6 h-6 text-green-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
</svg>
<span className="text-xs font-medium text-green-700 dark:text-green-400">WhatsApp</span>
</a>
<a
href={result.share.telegram}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-1 p-3 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 rounded-xl transition-colors"
>
<svg className="w-6 h-6 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">Telegram</span>
</a>
<a
href={result.share.sms}
className="flex flex-col items-center gap-1 p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl transition-colors"
>
<svg className="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">SMS</span>
</a>
</div>
<button
onClick={onClose}
className="w-full py-2 text-gray-500 text-sm hover:text-gray-700 dark:hover:text-gray-300"
>
Zavrit
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -303,3 +303,44 @@ export function sendCollabRequest(token: string, taskId: string, data: { to_user
export function searchUsers(token: string, query: string) { export function searchUsers(token: string, query: string) {
return apiFetch<{ data: { id: string; name: string; email: string; avatar_url: string | null }[] }>(`/api/v1/auth/users/search?q=${encodeURIComponent(query)}`, { token }); return apiFetch<{ data: { id: string; name: string; email: string; avatar_url: string | null }[] }>(`/api/v1/auth/users/search?q=${encodeURIComponent(query)}`, { token });
} }
// Invitations
export interface Invitation {
id: string;
token: string;
task_id: string;
inviter_id: string | null;
invitee_email: string | null;
invitee_name: string | null;
message: string;
status: string;
expires_at: string;
accepted_at: string | null;
created_at: string;
}
export interface InvitationResponse {
invitation: Invitation;
link: string;
share: {
whatsapp: string;
telegram: string;
sms: string;
copy: string;
};
}
export function createInvitation(
token: string,
data: { task_id: string; invitee_email?: string; invitee_name?: string; message?: string; inviter_id?: string }
) {
return apiFetch<{ data: InvitationResponse }>("/api/v1/invitations", { method: "POST", body: data, token });
}
export function getTaskInvitations(token: string, taskId: string) {
return apiFetch<{ data: Invitation[] }>(`/api/v1/tasks/${taskId}/invitations`, { token });
}
export function revokeInvitation(token: string, invitationId: string) {
return apiFetch<{ data: Invitation }>(`/api/v1/invitations/${invitationId}`, { method: "DELETE", token });
}