Compare commits
6 Commits
v1.0.0
...
703541d29a
| Author | SHA1 | Date | |
|---|---|---|---|
| 703541d29a | |||
| 653805af4c | |||
| 5751ab832f | |||
| 03d7bd8de6 | |||
| 3f04637550 | |||
| 79f7e18c8c |
BIN
android/store-assets/feature-graphic.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
android/store-assets/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
android/store-assets/phone-1-tasks.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
android/store-assets/phone-2-calendar.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
android/store-assets/phone-3-chat.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
android/store-assets/phone-4-goals.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
android/store-assets/screenshot-1.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
android/store-assets/tablet10-1-tasks.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
android/store-assets/tablet10-2-calendar.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
android/store-assets/tablet7-1-tasks.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
android/store-assets/tablet7-2-calendar.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
114
api/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" });
|
||||||
@@ -103,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" });
|
||||||
|
|||||||
@@ -101,8 +101,57 @@ 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 (GDPR + Google Play requirement)
|
||||||
|
app.delete("/auth/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" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
|
||||||
|
// 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();
|
||||||
@@ -117,3 +166,4 @@ async function authRoutes(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = authRoutes;
|
module.exports = authRoutes;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
192
apps/tasks/app/invite/[token]/page.tsx
Normal 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">😕</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">🎉</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>📋</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">
|
||||||
|
📅 {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">
|
||||||
|
“{invite.message}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/tasks/app/privacy/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 800, margin: "0 auto", padding: "40px 24px", fontFamily: "system-ui", lineHeight: 1.8 }}>
|
||||||
|
<h1 style={{ fontSize: 28, fontWeight: "bold", marginBottom: 24 }}>Privacy Policy - Task Team</h1>
|
||||||
|
<p style={{ color: "#666", marginBottom: 16 }}>Last updated: March 30, 2026</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Data We Collect</h2>
|
||||||
|
<p>Account info (name, email), task data, GPS location (optional), audio for voice-to-text (optional).</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>How We Use It</h2>
|
||||||
|
<p>Task management, sync across devices, reminders, GPS auto-verify task completion.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Data Storage</h2>
|
||||||
|
<p>Your data is stored on secure servers in the European Union (Hetzner Cloud, Germany). We use PostgreSQL databases with encrypted connections and regular backups.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Data Sharing</h2>
|
||||||
|
<p>We do NOT sell data. Services used: Hetzner Cloud Germany, Anthropic Claude AI.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Your Rights (GDPR)</h2>
|
||||||
|
<p>You have the right to access, correct, and delete your data. Contact: privacy@it-enterprise.cz</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Permissions</h2>
|
||||||
|
<p>Location and microphone are optional, revocable anytime in your device settings.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Children</h2>
|
||||||
|
<p>Not directed to children under 13.</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginTop: 32, marginBottom: 12 }}>Contact</h2>
|
||||||
|
<p>IT Enterprise s.r.o. | privacy@it-enterprise.cz | tasks.hasdo.info</p>
|
||||||
|
|
||||||
|
<p style={{ marginTop: 32, color: "#999", fontSize: 14 }}>© 2026 IT Enterprise s.r.o. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -217,6 +217,21 @@ export default function SettingsPage() {
|
|||||||
<div className="text-center text-xs text-muted py-4">
|
<div className="text-center text-xs text-muted py-4">
|
||||||
<p>{t("common.appName")} {t("common.appVersion")}</p>
|
<p>{t("common.appName")} {t("common.appVersion")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="bg-red-50 dark:bg-red-900/20 rounded-xl p-4 border border-red-200 dark:border-red-800 mt-6">
|
||||||
|
<h2 className="font-semibold text-red-600 dark:text-red-400 mb-2">Smazat ucet</h2>
|
||||||
|
<p className="text-sm text-red-500 mb-3">Trvale smazat ucet a vsechna data. Tuto akci nelze vratit.</p>
|
||||||
|
<button onClick={() => {
|
||||||
|
if (confirm("Opravdu chcete trvale smazat svuj ucet a vsechna data?")) {
|
||||||
|
const token = localStorage.getItem("taskteam_token");
|
||||||
|
fetch("/api/v1/auth/delete-account", { method: "DELETE", headers: { Authorization: "Bearer " + token } })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => { localStorage.clear(); window.location.href = "/login"; });
|
||||||
|
}
|
||||||
|
}} className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium">
|
||||||
|
Smazat ucet
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
184
apps/tasks/components/InviteModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
11
backup.sh
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
cd /opt/task-team || exit 1
|
||||||
BACKUP_DIR="/opt/task-team/backups"
|
BACKUP_DIR="/opt/task-team/backups"
|
||||||
DATE=$(date +%Y%m%d_%H%M)
|
DATE=$(date +%Y%m%d_%H%M)
|
||||||
PGDUMP="/usr/lib/postgresql/18/bin/pg_dump"
|
PGDUMP="/usr/lib/postgresql/18/bin/pg_dump"
|
||||||
@@ -10,13 +11,13 @@ PGPASSWORD="TaskTeam2026!" $PGDUMP -h 10.10.10.10 -U taskteam -d taskteam -F c -
|
|||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
# Compress
|
# Compress
|
||||||
gzip -f "$BACKUP_DIR/taskteam_$DATE.dump" 2>/dev/null
|
gzip -f "$BACKUP_DIR/taskteam_$DATE.dump" 2>/dev/null
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') OK: taskteam_$DATE.dump.gz"
|
echo "$(date "+%Y-%m-%d %H:%M:%S") OK: taskteam_$DATE.dump.gz"
|
||||||
else
|
else
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') FAIL: pg_dump exited with error"
|
echo "$(date "+%Y-%m-%d %H:%M:%S") FAIL: pg_dump exited with error"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Keep last 7 daily backups
|
# Keep last 14 daily backups
|
||||||
find $BACKUP_DIR -name "*.dump.gz" -mtime +7 -delete
|
find $BACKUP_DIR -name "*.dump.gz" -mtime +14 -delete
|
||||||
find $BACKUP_DIR -name "*.dump" -mtime +7 -delete
|
find $BACKUP_DIR -name "*.dump" -mtime +14 -delete
|
||||||
|
|
||||||
ls -lh $BACKUP_DIR/ | tail -5
|
ls -lh $BACKUP_DIR/ | tail -5
|
||||||
|
|||||||
BIN
backups/releases/android/task-team-v1.0.0.apk
Normal file
BIN
backups/taskteam_20260329_2147.dump.gz
Normal file
@@ -13,7 +13,10 @@
|
|||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "info.hasdo.taskteam"
|
"bundleIdentifier": "info.hasdo.taskteam",
|
||||||
|
"infoPlist": {
|
||||||
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|||||||
39
mobile/fdroid/metadata/info.hasdo.taskteam.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
Categories:
|
||||||
|
- Office
|
||||||
|
- Time
|
||||||
|
License: AGPL-3.0-only
|
||||||
|
AuthorName: IT Enterprise Solution s.r.o.
|
||||||
|
AuthorEmail: apps@it-enterprise.cz
|
||||||
|
AuthorWebSite: https://it-enterprise.cz
|
||||||
|
SourceCode: https://git.hasdo.info/admin/task-team
|
||||||
|
IssueTracker: https://git.hasdo.info/admin/task-team/issues
|
||||||
|
|
||||||
|
AutoName: Task Team
|
||||||
|
Description: |
|
||||||
|
Personal life management system with AI assistant.
|
||||||
|
Manage tasks, goals, calendar, and team collaboration.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
* Task management with groups and priorities
|
||||||
|
* Calendar with color-coded time zones
|
||||||
|
* AI chat assistant (Claude)
|
||||||
|
* Goal planning with AI study planner
|
||||||
|
* Team collaboration (assign, transfer, subtasks)
|
||||||
|
* Multi-language (CZ, HE, RU, UA)
|
||||||
|
* Odoo, Moodle, Pohoda connectors
|
||||||
|
|
||||||
|
RepoType: git
|
||||||
|
Repo: https://git.hasdo.info/admin/task-team.git
|
||||||
|
|
||||||
|
Builds:
|
||||||
|
- versionName: 1.0.0
|
||||||
|
versionCode: 1
|
||||||
|
commit: v1.0.0
|
||||||
|
subdir: mobile
|
||||||
|
gradle:
|
||||||
|
- yes
|
||||||
|
|
||||||
|
AutoUpdateMode: Version
|
||||||
|
UpdateCheckMode: Tags
|
||||||
|
CurrentVersion: 1.0.0
|
||||||
|
CurrentVersionCode: 1
|
||||||
36
mobile/store-listing/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Task Team — Store Listing
|
||||||
|
|
||||||
|
## Short Description (80 chars)
|
||||||
|
Osobní systém řízení života s AI asistentem. Úkoly, cíle, kalendář, spolupráce.
|
||||||
|
|
||||||
|
## Full Description
|
||||||
|
Task Team je komplexní systém řízení osobního života propojený s AI agentem.
|
||||||
|
|
||||||
|
**Hlavní funkce:**
|
||||||
|
- Správa úkolů s barevnými skupinami a prioritami
|
||||||
|
- Kalendář s časovými zónami pro každou skupinu
|
||||||
|
- AI chat asistent pro plánování a doporučení
|
||||||
|
- Cílový planner s AI generovanými studijními plány
|
||||||
|
- Týmová spolupráce: přiřazení, předání, podúkoly
|
||||||
|
- 4 jazyky: čeština, hebrejština, ruština, ukrajinština
|
||||||
|
- Connectors: Odoo, Moodle, Pohoda
|
||||||
|
- PWA + Android + iOS
|
||||||
|
|
||||||
|
**Technologie:** React Native, Expo, Fastify, PostgreSQL, Redis, Claude AI
|
||||||
|
|
||||||
|
## Links
|
||||||
|
- Web: https://tasks.hasdo.info
|
||||||
|
- API Docs: https://api.hasdo.info/docs
|
||||||
|
- Source: https://git.hasdo.info/admin/task-team
|
||||||
|
- Android: https://expo.dev/accounts/it-enterprise/projects/task-team
|
||||||
|
- Company: IT Enterprise Solution s.r.o.
|
||||||
|
- Contact: apps@it-enterprise.cz
|
||||||
|
|
||||||
|
## APKPure Submission
|
||||||
|
1. Go to https://apkpure.com/submit-apk
|
||||||
|
2. Upload APK from Expo build
|
||||||
|
3. Fill in listing details from above
|
||||||
|
|
||||||
|
## Aurora Store
|
||||||
|
Aurora Store indexes F-Droid and other repos automatically.
|
||||||
|
Once on F-Droid, it appears in Aurora.
|
||||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
VERSION=${1:-$(date +%Y%m%d_%H%M)}
|
VERSION=${1:-$(date +%Y%m%d_%H%M)}
|
||||||
PLATFORM=${2:-android}
|
PLATFORM=${2:-android}
|
||||||
FILE=$3
|
FILE=${3:-}
|
||||||
|
|
||||||
if [ -z "${FILE:-}" ]; then
|
if [ -z "${FILE:-}" ]; then
|
||||||
echo "Usage: upload-release.sh <version> <platform> <file>"
|
echo "Usage: upload-release.sh <version> <platform> <file>"
|
||||||
|
|||||||
15
scripts/weekly-report.sh
Executable 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"
|
||||||