@umudik/task-bridge 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/apps/backend/dist/config.js +26 -0
  4. package/apps/backend/dist/db/epic-workflow-db.js +125 -0
  5. package/apps/backend/dist/db/library-db.js +123 -0
  6. package/apps/backend/dist/db/projects-db.js +110 -0
  7. package/apps/backend/dist/db/tasks-db.js +282 -0
  8. package/apps/backend/dist/db/users-db.js +117 -0
  9. package/apps/backend/dist/db/workflow-db.js +186 -0
  10. package/apps/backend/dist/db/workflow-template-db.js +715 -0
  11. package/apps/backend/dist/domain/project-member.js +15 -0
  12. package/apps/backend/dist/domain/task-template-graph.js +63 -0
  13. package/apps/backend/dist/domain/task.js +93 -0
  14. package/apps/backend/dist/domain/work-status.js +30 -0
  15. package/apps/backend/dist/domain/workflow-stage.js +186 -0
  16. package/apps/backend/dist/domain/workflow-state.js +73 -0
  17. package/apps/backend/dist/domain/workflow-template-id.js +6 -0
  18. package/apps/backend/dist/errors/app-error.js +24 -0
  19. package/apps/backend/dist/index.js +67 -0
  20. package/apps/backend/dist/lib/bridge-project.js +24 -0
  21. package/apps/backend/dist/lib/inbox-cursor.js +34 -0
  22. package/apps/backend/dist/lib/strings.js +15 -0
  23. package/apps/backend/dist/logger.js +29 -0
  24. package/apps/backend/dist/mappers/task-response.js +261 -0
  25. package/apps/backend/dist/middleware/auth.js +29 -0
  26. package/apps/backend/dist/openapi.js +716 -0
  27. package/apps/backend/dist/routes/admin-users.js +79 -0
  28. package/apps/backend/dist/routes/auth.js +81 -0
  29. package/apps/backend/dist/routes/connect.js +1 -0
  30. package/apps/backend/dist/routes/docs.js +13 -0
  31. package/apps/backend/dist/routes/health.js +6 -0
  32. package/apps/backend/dist/routes/library.js +139 -0
  33. package/apps/backend/dist/routes/projects.js +95 -0
  34. package/apps/backend/dist/routes/tasks.js +522 -0
  35. package/apps/backend/dist/routes/web.js +79 -0
  36. package/apps/backend/dist/routes/workflow-templates.js +152 -0
  37. package/apps/backend/dist/routes/workflow.js +165 -0
  38. package/apps/backend/dist/services/connect-target.js +4 -0
  39. package/apps/backend/dist/services/epic-service.js +269 -0
  40. package/apps/backend/dist/services/library-service.js +222 -0
  41. package/apps/backend/dist/services/project-registry.js +122 -0
  42. package/apps/backend/dist/services/task-assignee-service.js +42 -0
  43. package/apps/backend/dist/services/task-claim-policy.js +310 -0
  44. package/apps/backend/dist/services/task-queue.js +105 -0
  45. package/apps/backend/dist/services/task-service.js +198 -0
  46. package/apps/backend/dist/services/workflow-rules.js +18 -0
  47. package/apps/backend/dist/services/workflow-service.js +418 -0
  48. package/apps/backend/dist/services/workflow-spawn-service.js +179 -0
  49. package/apps/backend/dist/services/workflow-state-service.js +157 -0
  50. package/apps/backend/dist/services/workflow-template-service.js +204 -0
  51. package/apps/backend/public/assets/index-Bl1ciVpY.js +409 -0
  52. package/apps/backend/public/assets/index-ByKECv-I.css +1 -0
  53. package/apps/backend/public/index.html +13 -0
  54. package/bin/task-bridge.mjs +86 -0
  55. package/package.json +41 -0
@@ -0,0 +1,522 @@
1
+ import { z } from "zod";
2
+ import { assertAuth } from "../middleware/auth.js";
3
+ import { buildInboxItems, mapComments, mapTaskDetail, } from "../mappers/task-response.js";
4
+ import { getProjectById, refreshProjectRegistry } from "../services/project-registry.js";
5
+ import { addBridgeTaskUserComment, allocateTaskId, claimBridgeTask, getBridgeTask, listBridgeTasks, releaseBridgeTask, updateBridgeTaskSpec, upsertBridgeTask, } from "../services/task-service.js";
6
+ import { applyTodoCascadeFromTask, listEpicSubtasks, spawnEpicWorkflow, syncEpicStage, updateTaskWorkStatus, } from "../services/epic-service.js";
7
+ import { resolveEpicId } from "../domain/task.js";
8
+ import { resolveWorkStatus, workStatusLabel } from "../domain/work-status.js";
9
+ import { getStageTitleLookup, resolveNewTaskPlacement } from "../services/workflow-service.js";
10
+ import { isWorkStatus } from "../domain/work-status.js";
11
+ import { claimNextTask, listPendingTasks, validateTaskClaim, } from "../services/task-queue.js";
12
+ import { resolveClaimActor } from "../services/task-claim-policy.js";
13
+ import { createEpicRecords } from "../services/workflow-state-service.js";
14
+ import { AppError } from "../errors/app-error.js";
15
+ const createEpicBodySchema = z
16
+ .object({
17
+ text: z.string().trim().optional(),
18
+ projectId: z.string().trim().min(1),
19
+ title: z.string().trim().optional(),
20
+ description: z.string().trim().optional(),
21
+ })
22
+ .superRefine((data, ctx) => {
23
+ let titleTrimmed = "";
24
+ if (data.title) {
25
+ titleTrimmed = data.title;
26
+ }
27
+ let textTrimmed = "";
28
+ if (data.text) {
29
+ textTrimmed = data.text;
30
+ }
31
+ if (!titleTrimmed && !textTrimmed) {
32
+ ctx.addIssue({
33
+ code: z.ZodIssueCode.custom,
34
+ message: "title or text is required",
35
+ path: ["title"],
36
+ });
37
+ }
38
+ });
39
+ const createTaskBodySchema = z
40
+ .object({
41
+ parentId: z.coerce.number().int().positive(),
42
+ title: z.string().trim().min(1),
43
+ description: z.string().trim().optional(),
44
+ stageId: z.string().trim().min(1).optional(),
45
+ });
46
+ function resolveEpicFields(body) {
47
+ let titleInput = "";
48
+ if (body.title) {
49
+ titleInput = body.title;
50
+ }
51
+ let descriptionInput = "";
52
+ if (body.description) {
53
+ descriptionInput = body.description;
54
+ }
55
+ let text = "";
56
+ if (body.text) {
57
+ text = body.text;
58
+ }
59
+ if (titleInput) {
60
+ return {
61
+ title: titleInput.slice(0, 200),
62
+ description: descriptionInput || text,
63
+ };
64
+ }
65
+ const newline = text.indexOf("\n");
66
+ if (newline === -1) {
67
+ return {
68
+ title: text.slice(0, 200),
69
+ description: "",
70
+ };
71
+ }
72
+ const firstLine = text.slice(0, newline);
73
+ const rest = text.slice(newline + 1);
74
+ return {
75
+ title: (firstLine || text).slice(0, 200),
76
+ description: rest,
77
+ };
78
+ }
79
+ function createdItemResponse(task) {
80
+ return {
81
+ id: task.id,
82
+ title: task.title,
83
+ createdAt: task.createdAt,
84
+ projectId: task.projectId,
85
+ projectName: task.projectName,
86
+ parentId: task.parentId,
87
+ stageId: task.stageId,
88
+ assignee: task.assignee,
89
+ };
90
+ }
91
+ const taskIdParamsSchema = z.object({
92
+ id: z.coerce.number().int().positive(),
93
+ });
94
+ const claimTaskBodySchema = z.object({
95
+ claimedBy: z.string().trim().min(1),
96
+ });
97
+ const claimNextBodySchema = z.object({
98
+ claimedBy: z.string().trim().min(1),
99
+ projectId: z.string().trim().min(1).optional(),
100
+ });
101
+ const patchTaskBodySchema = z
102
+ .object({
103
+ comment: z
104
+ .object({
105
+ text: z.string().trim().min(1),
106
+ by: z.string().trim().min(1).default("web"),
107
+ })
108
+ .optional(),
109
+ description: z.string().trim().optional(),
110
+ })
111
+ .superRefine((data, ctx) => {
112
+ if (!data.comment && !data.description) {
113
+ ctx.addIssue({
114
+ code: z.ZodIssueCode.custom,
115
+ message: "comment or description is required",
116
+ });
117
+ }
118
+ });
119
+ const workStatusBodySchema = z.object({
120
+ workStatus: z.enum(["todo", "in_progress", "done"]),
121
+ claimedBy: z.string().trim().min(1),
122
+ });
123
+ const inboxQuerySchema = z.object({
124
+ projectId: z.string().trim().min(1).nullable().default(null),
125
+ commentsOnly: z
126
+ .enum(["true", "false"])
127
+ .nullable()
128
+ .default(null)
129
+ .transform((value) => {
130
+ if (value === null) {
131
+ return null;
132
+ }
133
+ return value === "true";
134
+ }),
135
+ epicsOnly: z
136
+ .enum(["true", "false"])
137
+ .nullable()
138
+ .default(null)
139
+ .transform((value) => {
140
+ if (value === null) {
141
+ return null;
142
+ }
143
+ return value === "true";
144
+ }),
145
+ cursor: z.string().trim().min(1).nullable().default(null),
146
+ limit: z.coerce.number().int().positive().max(100).optional().default(20),
147
+ });
148
+ export function taskRoutes(app) {
149
+ app.post("/epics", async (request, reply) => {
150
+ const user = assertAuth(request);
151
+ const body = createEpicBodySchema.parse(request.body);
152
+ refreshProjectRegistry();
153
+ const project = getProjectById(body.projectId);
154
+ if (!project) {
155
+ return reply.status(400).send({ error: "Unknown project" });
156
+ }
157
+ const id = allocateTaskId();
158
+ const { title, description } = resolveEpicFields(body);
159
+ const placement = resolveNewTaskPlacement(project.id);
160
+ let hasText = false;
161
+ if (body.text) {
162
+ hasText = body.text.length > 0;
163
+ }
164
+ let hasTitle = false;
165
+ if (body.title) {
166
+ hasTitle = body.title.length > 0;
167
+ }
168
+ let createdBy = user.id;
169
+ if (hasText && !hasTitle) {
170
+ createdBy = user.id;
171
+ }
172
+ createEpicRecords({
173
+ id,
174
+ projectId: project.id,
175
+ title,
176
+ description,
177
+ stageId: placement.stageId,
178
+ createdBy,
179
+ });
180
+ const epic = upsertBridgeTask({
181
+ id,
182
+ projectId: project.id,
183
+ projectName: project.name,
184
+ title,
185
+ description,
186
+ createdBy,
187
+ createdAt: null,
188
+ stageId: placement.stageId,
189
+ assignee: placement.assignee,
190
+ assigneeRole: null,
191
+ assigneeKind: null,
192
+ parentId: null,
193
+ epicId: null,
194
+ templateId: null,
195
+ workStatus: null,
196
+ });
197
+ spawnEpicWorkflow(epic);
198
+ syncEpicStage(epic.id);
199
+ return reply.status(201).send(createdItemResponse(epic));
200
+ });
201
+ app.post("/tasks", async (request, reply) => {
202
+ assertAuth(request);
203
+ const body = createTaskBodySchema.parse(request.body);
204
+ refreshProjectRegistry();
205
+ const parent = getBridgeTask(body.parentId);
206
+ if (!parent) {
207
+ return reply.status(400).send({ error: "Unknown parent task" });
208
+ }
209
+ const allTasks = listBridgeTasks();
210
+ const epicId = resolveEpicId(allTasks, parent);
211
+ if (!epicId) {
212
+ return reply.status(400).send({ error: "Task must belong to an epic" });
213
+ }
214
+ const epic = getBridgeTask(epicId);
215
+ if (!epic || epic.parentId !== null) {
216
+ return reply.status(400).send({ error: "Unknown epic" });
217
+ }
218
+ const project = getProjectById(epic.projectId);
219
+ if (!project) {
220
+ return reply.status(400).send({ error: "Unknown project" });
221
+ }
222
+ const placement = resolveNewTaskPlacement(project.id);
223
+ let bodyStageId = null;
224
+ if (body.stageId) {
225
+ bodyStageId = body.stageId;
226
+ }
227
+ let stageId = bodyStageId;
228
+ if (stageId === null) {
229
+ stageId = parent.stageId;
230
+ }
231
+ if (stageId === null) {
232
+ stageId = epic.stageId;
233
+ }
234
+ if (stageId === null) {
235
+ stageId = placement.stageId;
236
+ }
237
+ let descriptionValue = "";
238
+ if (body.description) {
239
+ descriptionValue = body.description;
240
+ }
241
+ const task = upsertBridgeTask({
242
+ id: allocateTaskId(),
243
+ projectId: project.id,
244
+ projectName: project.name,
245
+ title: body.title.slice(0, 200),
246
+ description: descriptionValue,
247
+ createdBy: "web",
248
+ createdAt: null,
249
+ stageId,
250
+ assignee: null,
251
+ assigneeRole: null,
252
+ assigneeKind: null,
253
+ parentId: parent.id,
254
+ epicId: epic.id,
255
+ templateId: null,
256
+ workStatus: "todo",
257
+ });
258
+ applyTodoCascadeFromTask(task, "web", { laterStages: null, descendants: false });
259
+ syncEpicStage(epic.id);
260
+ return reply.status(201).send(createdItemResponse(task));
261
+ });
262
+ app.get("/tasks", (request) => {
263
+ assertAuth(request);
264
+ const bridgeTasks = listBridgeTasks();
265
+ return {
266
+ items: bridgeTasks.map((task) => ({
267
+ id: task.id,
268
+ title: task.title,
269
+ projectId: task.projectId,
270
+ projectName: task.projectName,
271
+ parentId: task.parentId,
272
+ stageId: task.stageId,
273
+ assignee: task.assignee,
274
+ createdBy: task.createdBy,
275
+ createdAt: task.createdAt,
276
+ claimedBy: task.claimedBy,
277
+ claimedAt: task.claimedAt,
278
+ events: task.events,
279
+ })),
280
+ };
281
+ });
282
+ app.post("/tasks/:id/claim", async (request, reply) => {
283
+ assertAuth(request);
284
+ const { id } = taskIdParamsSchema.parse(request.params);
285
+ const claimBody = request.body || {};
286
+ const body = claimTaskBodySchema.parse(claimBody);
287
+ const existing = getBridgeTask(id);
288
+ if (!existing) {
289
+ return reply.status(404).send({ error: "Task not found" });
290
+ }
291
+ const claimedBy = body.claimedBy;
292
+ const actor = resolveClaimActor(existing.projectId, claimedBy);
293
+ if (!actor) {
294
+ return reply.status(404).send({ error: "Project member not found" });
295
+ }
296
+ const blockReason = validateTaskClaim(id, actor);
297
+ if (blockReason) {
298
+ const existingAgain = getBridgeTask(id);
299
+ if (!existingAgain) {
300
+ return reply.status(404).send({ error: "Task not found" });
301
+ }
302
+ return reply.status(409).send({ error: blockReason });
303
+ }
304
+ const task = claimBridgeTask(id, body.claimedBy);
305
+ if (!task) {
306
+ return reply.status(409).send({ error: "Task is not available to claim" });
307
+ }
308
+ return task;
309
+ });
310
+ app.post("/tasks/:id/unclaim", async (request, reply) => {
311
+ assertAuth(request);
312
+ const { id } = taskIdParamsSchema.parse(request.params);
313
+ const task = releaseBridgeTask(id);
314
+ if (!task) {
315
+ return reply.status(404).send({ error: "Task not found" });
316
+ }
317
+ return { taskId: task.id, stageId: task.stageId };
318
+ });
319
+ app.post("/worker/claim-next", async (request, reply) => {
320
+ assertAuth(request);
321
+ const claimNextBody = request.body || {};
322
+ const body = claimNextBodySchema.parse(claimNextBody);
323
+ if (!body.projectId) {
324
+ return reply.status(400).send({ error: "projectId is required" });
325
+ }
326
+ const actor = resolveClaimActor(body.projectId, body.claimedBy);
327
+ if (!actor) {
328
+ return reply.status(404).send({ error: "Project member not found" });
329
+ }
330
+ const claimed = claimNextTask(actor, { projectId: body.projectId });
331
+ if (!claimed) {
332
+ return reply.status(404).send({ error: "No tasks available" });
333
+ }
334
+ const { task, item } = claimed;
335
+ return Object.assign({}, item, {
336
+ stageId: task.stageId,
337
+ claimedBy: task.claimedBy,
338
+ claimedAt: task.claimedAt,
339
+ comments: mapComments(task),
340
+ });
341
+ });
342
+ app.patch("/tasks/:id/work-status", async (request, reply) => {
343
+ assertAuth(request);
344
+ const { id } = taskIdParamsSchema.parse(request.params);
345
+ const workStatusBody = request.body || {};
346
+ const body = workStatusBodySchema.parse(workStatusBody);
347
+ if (!isWorkStatus(body.workStatus)) {
348
+ return reply.status(400).send({ error: "Invalid work status" });
349
+ }
350
+ const existing = getBridgeTask(id);
351
+ if (!existing) {
352
+ return reply.status(404).send({ error: "Task not found" });
353
+ }
354
+ const actor = resolveClaimActor(existing.projectId, body.claimedBy);
355
+ if (!actor) {
356
+ return reply.status(404).send({ error: "Project member not found" });
357
+ }
358
+ try {
359
+ const updated = updateTaskWorkStatus(id, body.workStatus, actor.claimedBy, actor);
360
+ if (!updated) {
361
+ if (existing.parentId === null) {
362
+ return reply.status(400).send({ error: "Only subtasks support work status" });
363
+ }
364
+ return reply.status(404).send({ error: "Task not found" });
365
+ }
366
+ return mapTaskDetail(updated);
367
+ }
368
+ catch (error) {
369
+ if (error instanceof AppError) {
370
+ return reply.status(error.statusCode).send({ error: error.message });
371
+ }
372
+ throw error;
373
+ }
374
+ });
375
+ app.get("/projects/:projectId/epics/:epicId/tasks", async (request, reply) => {
376
+ assertAuth(request);
377
+ const params = z
378
+ .object({
379
+ projectId: z.string().trim().min(1),
380
+ epicId: z.coerce.number().int().positive(),
381
+ })
382
+ .parse(request.params);
383
+ const epic = getBridgeTask(params.epicId);
384
+ if (!epic || epic.projectId !== params.projectId || epic.parentId !== null) {
385
+ return reply.status(404).send({ error: "Epic not found" });
386
+ }
387
+ syncEpicStage(epic.id);
388
+ const refreshedRaw = getBridgeTask(params.epicId);
389
+ let refreshed = epic;
390
+ if (refreshedRaw) {
391
+ refreshed = refreshedRaw;
392
+ }
393
+ const subtasks = listEpicSubtasks(epic.id);
394
+ const stageTitles = getStageTitleLookup(params.projectId);
395
+ let epicStageTitle = null;
396
+ if (refreshed.stageId) {
397
+ const t = stageTitles.get(refreshed.stageId);
398
+ if (t) {
399
+ epicStageTitle = t;
400
+ }
401
+ else {
402
+ epicStageTitle = refreshed.stageId;
403
+ }
404
+ }
405
+ return {
406
+ epicId: refreshed.id,
407
+ epicTitle: refreshed.title,
408
+ stageId: refreshed.stageId,
409
+ stageTitle: epicStageTitle,
410
+ tasks: subtasks.map((task) => {
411
+ let taskStageTitle = null;
412
+ if (task.stageId) {
413
+ const t = stageTitles.get(task.stageId);
414
+ if (t) {
415
+ taskStageTitle = t;
416
+ }
417
+ else {
418
+ taskStageTitle = task.stageId;
419
+ }
420
+ }
421
+ return {
422
+ id: task.id,
423
+ title: task.title,
424
+ stageId: task.stageId,
425
+ stageTitle: taskStageTitle,
426
+ templateId: task.templateId,
427
+ workStatus: resolveWorkStatus(task),
428
+ workStatusLabel: workStatusLabel(resolveWorkStatus(task)),
429
+ assignee: task.assignee,
430
+ };
431
+ }),
432
+ };
433
+ });
434
+ app.get("/tasks/:id", async (request, reply) => {
435
+ assertAuth(request);
436
+ const { id } = taskIdParamsSchema.parse(request.params);
437
+ const task = getBridgeTask(id);
438
+ if (!task) {
439
+ return reply.status(404).send({ error: "Task not found" });
440
+ }
441
+ return mapTaskDetail(task);
442
+ });
443
+ app.patch("/tasks/:id", async (request, reply) => {
444
+ assertAuth(request);
445
+ const { id } = taskIdParamsSchema.parse(request.params);
446
+ const patchBody = request.body || {};
447
+ const body = patchTaskBodySchema.parse(patchBody);
448
+ const existing = getBridgeTask(id);
449
+ if (!existing) {
450
+ return reply.status(404).send({ error: "Task not found" });
451
+ }
452
+ if (existing.parentId === null) {
453
+ if (!body.comment && !body.description) {
454
+ return reply.status(400).send({ error: "Epics support description or comment updates" });
455
+ }
456
+ let task = existing;
457
+ if (body.description) {
458
+ let commentBy = "web";
459
+ if (body.comment) {
460
+ commentBy = body.comment.by;
461
+ }
462
+ const updated = updateBridgeTaskSpec(id, {
463
+ description: body.description,
464
+ title: null,
465
+ by: commentBy,
466
+ });
467
+ if (!updated) {
468
+ return reply.status(404).send({ error: "Task not found" });
469
+ }
470
+ task = updated;
471
+ }
472
+ if (body.comment) {
473
+ const updated = addBridgeTaskUserComment(id, body.comment.by, body.comment.text);
474
+ if (!updated) {
475
+ return reply.status(404).send({ error: "Task not found" });
476
+ }
477
+ task = updated;
478
+ }
479
+ return mapTaskDetail(task);
480
+ }
481
+ if (!body.comment) {
482
+ return reply.status(400).send({ error: "comment is required" });
483
+ }
484
+ const task = addBridgeTaskUserComment(id, body.comment.by, body.comment.text);
485
+ if (!task) {
486
+ return reply.status(404).send({ error: "Task not found" });
487
+ }
488
+ return mapTaskDetail(task);
489
+ });
490
+ app.get("/worker/pending", async (request, reply) => {
491
+ assertAuth(request);
492
+ const query = z
493
+ .object({
494
+ projectId: z.string().trim().min(1).optional(),
495
+ claimedBy: z.string().trim().min(1).optional(),
496
+ })
497
+ .parse(request.query);
498
+ let actor = null;
499
+ if (query.claimedBy) {
500
+ if (!query.projectId) {
501
+ return reply.status(400).send({ error: "projectId is required when filtering by claimedBy" });
502
+ }
503
+ actor = resolveClaimActor(query.projectId, query.claimedBy);
504
+ if (!actor) {
505
+ return reply.status(404).send({ error: "Project member not found" });
506
+ }
507
+ }
508
+ const items = listPendingTasks(query.projectId, actor);
509
+ return { items };
510
+ });
511
+ app.get("/inbox", (request) => {
512
+ assertAuth(request);
513
+ const query = inboxQuerySchema.parse(request.query);
514
+ return buildInboxItems({
515
+ projectId: query.projectId,
516
+ commentsOnly: query.commentsOnly,
517
+ epicsOnly: query.epicsOnly,
518
+ cursor: query.cursor,
519
+ limit: query.limit,
520
+ });
521
+ });
522
+ }
@@ -0,0 +1,79 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import fastifyStatic from "@fastify/static";
5
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
6
+ function resolveWebRoot() {
7
+ const candidates = [
8
+ join(moduleDir, "..", "..", "public"),
9
+ join(moduleDir, "..", "..", "web", "dist"),
10
+ join(process.cwd(), "public"),
11
+ join(process.cwd(), "apps", "web", "dist"),
12
+ ];
13
+ for (const candidate of candidates) {
14
+ if (existsSync(join(candidate, "index.html"))) {
15
+ return candidate;
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+ function resolveMobileApkPath(root) {
21
+ return join(root, "downloads", "task-bridge.apk");
22
+ }
23
+ export async function webRoutes(app) {
24
+ const root = resolveWebRoot();
25
+ if (!root) {
26
+ return;
27
+ }
28
+ const mobileApkPath = resolveMobileApkPath(root);
29
+ app.get("/mobile/release", async (_request, reply) => {
30
+ if (!existsSync(mobileApkPath)) {
31
+ return reply.send({ available: false });
32
+ }
33
+ const stats = statSync(mobileApkPath);
34
+ return reply.send({
35
+ available: true,
36
+ downloadUrl: "/downloads/task-bridge.apk",
37
+ sizeBytes: stats.size,
38
+ fileName: "task-bridge.apk",
39
+ });
40
+ });
41
+ app.get("/downloads/task-bridge.apk", async (_request, reply) => {
42
+ if (!existsSync(mobileApkPath)) {
43
+ return reply.status(404).send({ error: "Android APK not bundled in this image" });
44
+ }
45
+ return reply
46
+ .type("application/vnd.android.package-archive")
47
+ .header("Content-Disposition", 'attachment; filename="task-bridge.apk"')
48
+ .sendFile("downloads/task-bridge.apk", root);
49
+ });
50
+ await app.register(fastifyStatic, {
51
+ root,
52
+ prefix: "/app/",
53
+ wildcard: false,
54
+ });
55
+ const spa = (_request, reply) => reply.sendFile("index.html", root);
56
+ app.get("/", async (_request, reply) => reply.redirect("/app/login"));
57
+ app.get("/app", async (_request, reply) => reply.redirect("/app/login"));
58
+ app.get("/app/login", spa);
59
+ app.get("/app/projects", spa);
60
+ app.get("/app/projects/:projectId/tasks", spa);
61
+ app.get("/app/projects/:projectId/inbox", spa);
62
+ app.get("/app/projects/:projectId/tasks/:taskId", spa);
63
+ app.get("/app/projects/:projectId/mobile", spa);
64
+ app.get("/app/projects/:projectId/workflow", spa);
65
+ app.get("/app/workflow-templates", spa);
66
+ app.get("/app/setup", spa);
67
+ app.get("/app/admin/users", spa);
68
+ app.get("/app/*", async (request, reply) => {
69
+ const questionIndex = request.url.indexOf("?");
70
+ let path = request.url;
71
+ if (questionIndex >= 0) {
72
+ path = request.url.slice(0, questionIndex);
73
+ }
74
+ if (path.startsWith("/app/assets/")) {
75
+ return reply.status(404).send({ error: "Not found" });
76
+ }
77
+ return reply.sendFile("index.html", root);
78
+ });
79
+ }