UI fix + Data Export + Weekly Report + WebSocket

- Header: groups+status on one row
- GET /auth/export-data (GDPR data download)
- Weekly AI report cron (Monday 8:00)
- WebSocket /ws endpoint (real-time notifications)
- @fastify/websocket installed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 00:17:57 +00:00
parent 03d7bd8de6
commit 5751ab832f
14 changed files with 182 additions and 42 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

114
api/package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.7.0", "@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5", "@fastify/swagger-ui": "^5.2.5",
"@fastify/websocket": "^11.2.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
@@ -367,6 +368,27 @@
"yaml": "^2.4.1" "yaml": "^2.4.1"
} }
}, },
"node_modules/@fastify/websocket": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"duplexify": "^4.1.3",
"fastify-plugin": "^5.0.0",
"ws": "^8.16.0"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
@@ -767,6 +789,18 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1",
"stream-shift": "^1.0.2"
}
},
"node_modules/ecdsa-sig-formatter": { "node_modules/ecdsa-sig-formatter": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -776,6 +810,15 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -1462,6 +1505,15 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openapi-types": { "node_modules/openapi-types": {
"version": "12.1.3", "version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
@@ -1767,6 +1819,20 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -2016,6 +2082,21 @@
"reusify": "^1.0.0" "reusify": "^1.0.0"
} }
}, },
"node_modules/stream-shift": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -2101,6 +2182,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -2142,6 +2229,33 @@
"node": ">= 16" "node": ">= 16"
} }
}, },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -20,6 +20,7 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.7.0", "@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5", "@fastify/swagger-ui": "^5.2.5",
"@fastify/websocket": "^11.2.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",

View File

@@ -6,6 +6,7 @@ const jwt = require("@fastify/jwt");
const rateLimit = require("@fastify/rate-limit"); const rateLimit = require("@fastify/rate-limit");
const { Pool } = require("pg"); const { Pool } = require("pg");
const Redis = require("ioredis"); const Redis = require("ioredis");
const websocket = require("@fastify/websocket");
const swagger = require("@fastify/swagger"); const swagger = require("@fastify/swagger");
const swaggerUi = require("@fastify/swagger-ui"); const swaggerUi = require("@fastify/swagger-ui");
@@ -85,6 +86,16 @@ const start = async () => {
uiConfig: { docExpansion: "list", deepLinking: true } uiConfig: { docExpansion: "list", deepLinking: true }
}); });
// WebSocket support
await app.register(websocket);
app.get("/ws", { websocket: true }, (socket, req) => {
socket.on("message", (msg) => {
socket.send(JSON.stringify({ type: "ack", message: msg.toString() }));
});
socket.send(JSON.stringify({ type: "connected", timestamp: new Date().toISOString() }));
});
// Register routes // Register routes
await app.register(require("./routes/tasks"), { prefix: "/api/v1" }); await app.register(require("./routes/tasks"), { prefix: "/api/v1" });
await app.register(require("./routes/groups"), { prefix: "/api/v1" }); await app.register(require("./routes/groups"), { prefix: "/api/v1" });

View File

@@ -115,6 +115,23 @@ async function authRoutes(app) {
// OAuth initiate routes moved to ./oauth.js // OAuth initiate routes moved to ./oauth.js
// Export all user data (GDPR)
app.get("/auth/export-data", { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req) => {
const userId = req.user.id;
const { rows: user } = await app.db.query("SELECT id,email,name,phone,language,created_at FROM users WHERE id=$1", [userId]);
const { rows: tasks } = await app.db.query("SELECT * FROM tasks WHERE user_id=$1", [userId]);
const { rows: goals } = await app.db.query("SELECT * FROM goals WHERE user_id=$1", [userId]);
const { rows: groups } = await app.db.query("SELECT * FROM task_groups WHERE user_id=$1 OR user_id IS NULL", [userId]);
const { rows: comments } = await app.db.query("SELECT * FROM task_comments WHERE user_id=$1", [userId]);
return {
exported_at: new Date().toISOString(),
user: user[0] || {},
tasks, goals, groups, comments,
total: { tasks: tasks.length, goals: goals.length, comments: comments.length }
};
});
// Search users by name or email (for collaboration) // Search users by name or email (for collaboration)
app.get('/auth/users/search', async (req) => { app.get('/auth/users/search', async (req) => {
const q = (req.query.q || '').trim(); const q = (req.query.q || '').trim();
@@ -130,21 +147,3 @@ async function authRoutes(app) {
module.exports = authRoutes; module.exports = authRoutes;
// Delete account (GDPR + Google Play requirement)
app.delete("/auth/delete-account", { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req) => {
const userId = req.user.id;
// Delete all user data in order (foreign keys)
await app.db.query("DELETE FROM task_comments WHERE user_id = $1", [userId]);
await app.db.query("DELETE FROM subtasks WHERE assigned_to = $1", [userId]);
await app.db.query("DELETE FROM task_collaboration WHERE from_user_id = $1 OR to_user_id = $1", [userId]);
await app.db.query("DELETE FROM task_assignments WHERE user_id = $1", [userId]);
await app.db.query("DELETE FROM push_subscriptions WHERE user_id = $1", [userId]);
await app.db.query("DELETE FROM goals WHERE user_id = $1", [userId]);
await app.db.query("DELETE FROM connectors WHERE user_id = $1", [userId]);
await app.db.query("DELETE FROM tasks WHERE user_id = $1", [userId]);
await app.db.query("DELETE FROM task_groups WHERE user_id = $1", [userId]);
await app.db.query("DELETE FROM users WHERE id = $1", [userId]);
return { status: "deleted", message: "Account and all data permanently deleted" };
});

View File

@@ -130,17 +130,16 @@ export default function TasksPage() {
return ( return (
<div className="space-y-2 pb-24 sm:pb-8 px-4 sm:px-0"> <div className="space-y-2 pb-24 sm:pb-8 px-4 sm:px-0">
{/* Group dropdown + Status pills row */} {/* Group dropdown + Status pills — single compact row */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-nowrap">
<div className="flex-shrink-0">
<GroupSelector <GroupSelector
groups={groups} groups={groups}
selected={selectedGroup} selected={selectedGroup}
onSelect={setSelectedGroup} onSelect={setSelectedGroup}
/> />
</div> </div>
<div className="flex gap-1 overflow-x-auto scrollbar-hide flex-1 min-w-0">
{/* Status filter pills - compact */}
<div className="flex gap-1 overflow-x-auto scrollbar-hide px-4">
{statusOptions.map((opt) => ( {statusOptions.map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -155,6 +154,7 @@ export default function TasksPage() {
</button> </button>
))} ))}
</div> </div>
</div>
{/* Swipeable task list area */} {/* Swipeable task list area */}
<div {...swipeHandlers} className="relative min-h-[200px]"> <div {...swipeHandlers} className="relative min-h-[200px]">

15
scripts/weekly-report.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# AI Weekly Report — runs every Monday 8:00 AM
export ANTHROPIC_API_KEY="$(grep ANTHROPIC_API_KEY /opt/task-team/api/.env | cut -d= -f2)"
# Get task stats
STATS=$(curl -s http://localhost:3000/api/v1/system/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get(tasks,{})))" 2>/dev/null || echo "{}")
# Generate AI report
REPORT=$(curl -s -X POST http://localhost:3000/api/v1/chat \
-H "Content-Type: application/json" \
-d "{\"message\":\"Vytvor tydeni report. Stats: $STATS. Shrn co se podarilo, co je treba zlepsit, doporuceni na pristi tyden. Odpovez v cestine, strucne.\"}" | python3 -c "import sys,json; print(json.load(sys.stdin).get(data,{}).get(reply,No report))" 2>/dev/null || echo "Report generation failed")
# Log
echo "$(date): $REPORT" >> /var/log/taskteam-weekly.log
echo "$REPORT"