@tulip-systems/create-app 0.12.4 → 0.14.0

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 (143) hide show
  1. package/dist/index.mjs +2866 -2949
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/templates/basic/package.json +37 -38
  4. package/dist/templates/basic/src/app/layout.tsx +20 -10
  5. package/dist/templates/code/package.json +80 -72
  6. package/dist/templates/code/public/sw.js +35 -0
  7. package/dist/templates/code/src/app/admin/contacts/_components/grid.tsx +1 -1
  8. package/dist/templates/code/src/app/admin/customers/_config/columns-data.tsx +1 -1
  9. package/dist/templates/code/src/app/admin/employees/_config/columns-data.tsx +1 -1
  10. package/dist/templates/code/src/app/admin/history/[id]/layout.tsx +33 -0
  11. package/dist/templates/code/src/app/admin/history/[id]/loading.tsx +11 -0
  12. package/dist/templates/code/src/app/admin/history/[id]/page.tsx +30 -0
  13. package/dist/templates/code/src/app/admin/history/_components/diff.client.tsx +377 -0
  14. package/dist/templates/code/src/app/admin/history/_components/field-comparison.tsx +153 -0
  15. package/dist/templates/code/src/app/admin/history/_components/tables.tsx +47 -0
  16. package/dist/templates/code/src/app/admin/history/_components/toolbars.tsx +54 -0
  17. package/dist/templates/code/src/app/admin/history/_config/columns-data.tsx +61 -0
  18. package/dist/templates/code/src/app/admin/history/_config/filters.ts +46 -0
  19. package/dist/templates/code/src/app/admin/history/_config/sorting.ts +30 -0
  20. package/dist/templates/code/src/app/admin/history/_lib/helpers.ts +98 -0
  21. package/dist/templates/code/src/app/admin/history/_lib/router.ts +100 -0
  22. package/dist/templates/code/src/app/admin/history/_lib/schema.ts +45 -0
  23. package/dist/templates/code/src/app/admin/history/_lib/service.server.ts +30 -0
  24. package/dist/templates/code/src/app/admin/history/_lib/validators.ts +118 -0
  25. package/dist/templates/code/src/app/admin/initiatives/_config/columns-data.tsx +1 -1
  26. package/dist/templates/code/src/app/admin/notifications/_components/command-toggle-push.tsx +115 -0
  27. package/dist/templates/code/src/app/admin/notifications/_components/tables.tsx +37 -0
  28. package/dist/templates/code/src/app/admin/notifications/_components/toolbars.tsx +21 -0
  29. package/dist/templates/code/src/app/admin/notifications/_config/columns-data.tsx +64 -0
  30. package/dist/templates/code/src/app/admin/notifications/_config/commands.tsx +40 -0
  31. package/dist/templates/code/src/app/admin/notifications/_lib/listeners.server.ts +69 -0
  32. package/dist/templates/code/src/app/admin/notifications/_lib/push.server.ts +80 -0
  33. package/dist/templates/code/src/app/admin/notifications/_lib/router.ts +82 -0
  34. package/dist/templates/code/src/app/admin/notifications/_lib/schema.ts +49 -0
  35. package/dist/templates/code/src/app/admin/notifications/_lib/validators.ts +19 -0
  36. package/dist/templates/code/src/app/admin/notifications/layout.tsx +17 -0
  37. package/dist/templates/code/src/app/admin/notifications/loading.tsx +9 -0
  38. package/dist/templates/code/src/app/admin/notifications/page.tsx +28 -0
  39. package/dist/templates/code/src/app/admin/projects/[id]/(default)/page.tsx +1 -1
  40. package/dist/templates/code/src/app/admin/projects/[id]/timeline/page.tsx +1 -1
  41. package/dist/templates/code/src/app/admin/projects/_config/columns-data.tsx +1 -1
  42. package/dist/templates/code/src/app/admin/projects/_lib/events.ts +64 -0
  43. package/dist/templates/code/src/app/admin/projects/_lib/listeners.server.ts +82 -0
  44. package/dist/templates/code/src/app/admin/projects/_lib/router.ts +145 -20
  45. package/dist/templates/code/src/app/admin/projects/timeline/page.tsx +1 -1
  46. package/dist/templates/code/src/app/admin/prospects/_config/columns-data.tsx +1 -1
  47. package/dist/templates/code/src/app/admin/queue/_components/create-command.tsx +97 -0
  48. package/dist/templates/code/src/app/admin/queue/_components/tables.tsx +37 -0
  49. package/dist/templates/code/src/app/admin/queue/_components/toolbars.tsx +16 -0
  50. package/dist/templates/code/src/app/admin/queue/_config/columns-data.tsx +44 -0
  51. package/dist/templates/code/src/app/admin/queue/_config/commands.tsx +39 -0
  52. package/dist/templates/code/src/app/admin/queue/_lib/queue.ts +27 -0
  53. package/dist/templates/code/src/app/admin/queue/_lib/router.ts +33 -0
  54. package/dist/templates/code/src/app/admin/queue/_lib/schema.ts +12 -0
  55. package/dist/templates/code/src/app/admin/queue/_lib/validators.ts +15 -0
  56. package/dist/templates/code/src/app/admin/quotations/_config/columns-data.tsx +1 -1
  57. package/dist/templates/code/src/app/admin/quotations/_config/commands.tsx +1 -1
  58. package/dist/templates/code/src/app/admin/settings/history/layout.tsx +6 -0
  59. package/dist/templates/code/src/app/admin/settings/history/loading.tsx +5 -0
  60. package/dist/templates/code/src/app/admin/settings/history/page.tsx +13 -0
  61. package/dist/templates/code/src/app/admin/settings/layout.tsx +11 -0
  62. package/dist/templates/code/src/app/admin/settings/queue/loading.tsx +9 -0
  63. package/dist/templates/code/src/app/admin/settings/queue/page.tsx +21 -0
  64. package/dist/templates/code/src/app/admin/tasks/_components/domains-tabs-grid.tsx +1 -1
  65. package/dist/templates/code/src/app/admin/tasks/_config/columns-data.tsx +1 -1
  66. package/dist/templates/code/src/app/admin/tasks/_config/columns-inline.tsx +1 -1
  67. package/dist/templates/code/src/app/admin/tasks/_lib/events.ts +40 -0
  68. package/dist/templates/code/src/app/admin/tasks/_lib/listeners.server.ts +46 -0
  69. package/dist/templates/code/src/app/admin/tasks/_lib/router.ts +222 -90
  70. package/dist/templates/code/src/app/admin/time/_config/columns-data.tsx +1 -1
  71. package/dist/templates/code/src/app/admin/time/_config/columns-inline.tsx +1 -1
  72. package/dist/templates/code/src/app/api/[transport]/route.ts +31 -21
  73. package/dist/templates/code/src/app/api/queue/dispatch/route.ts +4 -0
  74. package/dist/templates/code/src/app/auth/forget-password/page.tsx +1 -1
  75. package/dist/templates/code/src/app/auth/layout.tsx +1 -1
  76. package/dist/templates/code/src/app/auth/reset-password/page.tsx +1 -1
  77. package/dist/templates/code/src/app/layout.tsx +25 -13
  78. package/dist/templates/code/src/env.ts +12 -0
  79. package/dist/templates/code/src/lib/config/paths.tsx +8 -0
  80. package/dist/templates/code/src/server/auth/constants.ts +1 -1
  81. package/dist/templates/code/src/server/auth/init.ts +12 -5
  82. package/dist/templates/code/src/server/auth/permissions.ts +3 -0
  83. package/dist/templates/code/src/server/auth/schema.ts +100 -1
  84. package/dist/templates/code/src/server/db/schema.ts +3 -0
  85. package/dist/templates/code/src/server/events/handlers.ts +17 -0
  86. package/dist/templates/code/src/server/events/init.ts +18 -0
  87. package/dist/templates/code/src/server/events/listeners.ts +17 -0
  88. package/dist/templates/code/src/server/queue/handlers.ts +5 -0
  89. package/dist/templates/code/src/server/queue/init.ts +16 -0
  90. package/dist/templates/code/src/server/router/router.ts +13 -0
  91. package/dist/templates/spark/package.json +64 -65
  92. package/dist/templates/spark/src/app/admin/assignments/[id]/(default)/@dashboard/page.tsx +1 -1
  93. package/dist/templates/spark/src/app/admin/assignments/_config/columns-data.tsx +1 -1
  94. package/dist/templates/spark/src/app/admin/companies/_config/columns-data.tsx +1 -1
  95. package/dist/templates/spark/src/app/admin/contacts/_components/grid.tsx +1 -1
  96. package/dist/templates/spark/src/app/admin/customers/_config/columns-data.tsx +1 -1
  97. package/dist/templates/spark/src/app/admin/drive/_config/columns-data.tsx +1 -1
  98. package/dist/templates/spark/src/app/admin/emails/_components/send-email.tsx +1 -1
  99. package/dist/templates/spark/src/app/admin/emails/_config/columns-data.tsx +1 -1
  100. package/dist/templates/spark/src/app/admin/employees/_config/columns-data.tsx +1 -1
  101. package/dist/templates/spark/src/app/admin/executions/[id]/@dashboard/page.tsx +1 -1
  102. package/dist/templates/spark/src/app/admin/executions/_config/columns-data.tsx +1 -1
  103. package/dist/templates/spark/src/app/admin/products/[id]/(preview)/page.tsx +1 -1
  104. package/dist/templates/spark/src/app/admin/products/_config/columns-data.tsx +1 -1
  105. package/dist/templates/spark/src/app/admin/projects/[id]/(dashboard)/page.tsx +1 -1
  106. package/dist/templates/spark/src/app/admin/projects/[id]/messages/page.tsx +1 -1
  107. package/dist/templates/spark/src/app/admin/projects/[id]/statistics/page.tsx +1 -1
  108. package/dist/templates/spark/src/app/admin/projects/_config/columns-data.tsx +1 -1
  109. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/columns-data.tsx +1 -1
  110. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/columns-inline.tsx +1 -1
  111. package/dist/templates/spark/src/app/admin/purchase-order-items/_config/commands.tsx +1 -1
  112. package/dist/templates/spark/src/app/admin/purchase-orders/[id]/(default)/@dashboard/page.tsx +1 -1
  113. package/dist/templates/spark/src/app/admin/purchase-orders/_config/columns-data.tsx +1 -1
  114. package/dist/templates/spark/src/app/admin/quotation-items/_config/commands.tsx +1 -1
  115. package/dist/templates/spark/src/app/admin/quotations/[id]/(default)/@dashboard/page.tsx +1 -1
  116. package/dist/templates/spark/src/app/admin/quotations/_config/columns-data.tsx +1 -1
  117. package/dist/templates/spark/src/app/admin/run-tasks/_config/columns-data.tsx +1 -1
  118. package/dist/templates/spark/src/app/admin/runs/[id]/@dashboard/page.tsx +1 -1
  119. package/dist/templates/spark/src/app/admin/runs/_config/columns-data.tsx +1 -1
  120. package/dist/templates/spark/src/app/admin/sales-invoices/[id]/@dashboard/page.tsx +1 -1
  121. package/dist/templates/spark/src/app/admin/sales-invoices/_config/columns-data.tsx +1 -1
  122. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/columns-data.tsx +1 -1
  123. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/columns-inline.tsx +1 -1
  124. package/dist/templates/spark/src/app/admin/sales-order-executions/_config/commands.tsx +1 -1
  125. package/dist/templates/spark/src/app/admin/sales-order-items/_config/commands.tsx +1 -1
  126. package/dist/templates/spark/src/app/admin/sales-orders/[id]/(default)/@dashboard/page.tsx +1 -1
  127. package/dist/templates/spark/src/app/admin/sales-orders/[id]/(default)/layout.tsx +1 -1
  128. package/dist/templates/spark/src/app/admin/sales-orders/_config/columns-data.tsx +1 -1
  129. package/dist/templates/spark/src/app/admin/schedule/_components/schedule-card.tsx +1 -1
  130. package/dist/templates/spark/src/app/admin/schedule/_components/schedule-month-view.tsx +1 -1
  131. package/dist/templates/spark/src/app/admin/suppliers/_config/columns-data.tsx +1 -1
  132. package/dist/templates/spark/src/app/admin/tasks/_components/subproject-selector.tsx +1 -1
  133. package/dist/templates/spark/src/app/admin/tasks/_config/columns-data.tsx +1 -1
  134. package/dist/templates/spark/src/app/admin/tasks/_config/columns-inline.tsx +1 -1
  135. package/dist/templates/spark/src/app/admin/tasks/_config/commands.tsx +1 -1
  136. package/dist/templates/spark/src/app/admin/technical-sheets/[id]/page.tsx +1 -1
  137. package/dist/templates/spark/src/app/admin/technical-sheets/_config/columns-data.tsx +1 -1
  138. package/dist/templates/spark/src/app/admin/technical-sheets/_config/commands.tsx +1 -1
  139. package/dist/templates/spark/src/app/admin/templates/_config/columns-data.tsx +1 -1
  140. package/dist/templates/spark/src/app/auth/layout.tsx +1 -1
  141. package/dist/templates/spark/src/app/layout.tsx +25 -10
  142. package/dist/templates/spark/src/server/auth/init.ts +1 -0
  143. package/package.json +6 -6
@@ -0,0 +1,21 @@
1
+ import { InlineCommandMenu } from "@tulip-systems/commands/client";
2
+ import { TableLayout } from "@tulip-systems/data-tables";
3
+ import { DataTable } from "@tulip-systems/data-tables/client";
4
+ import { QueueExecutionsTableProvider } from "@/app/admin/queue/_components/tables";
5
+ import { QueueExecutionsTableToolbar } from "@/app/admin/queue/_components/toolbars";
6
+ import { queueExecutionColumns } from "@/app/admin/queue/_config/columns-data";
7
+ import { queueDeleteCommand, queueEnqueueCommand } from "@/app/admin/queue/_config/commands";
8
+
9
+ export default function Page() {
10
+ return (
11
+ <TableLayout className="p-content">
12
+ <QueueExecutionsTableProvider columns={queueExecutionColumns} commands={[queueDeleteCommand]}>
13
+ <QueueExecutionsTableToolbar>
14
+ <InlineCommandMenu data={null} commands={[queueEnqueueCommand]} />
15
+ </QueueExecutionsTableToolbar>
16
+
17
+ <DataTable />
18
+ </QueueExecutionsTableProvider>
19
+ </TableLayout>
20
+ );
21
+ }
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import type { Tab } from "@tulip-systems/app/components";
4
+ import { Link } from "@tulip-systems/core/navigation";
4
5
  import { buttonVariants } from "@tulip-systems/ui/button";
5
6
  import { cn } from "@tulip-systems/ui/cn";
6
7
  import { Skeleton } from "@tulip-systems/ui/skeleton";
7
- import Link from "next/link";
8
8
  import { useSelectedLayoutSegment } from "next/navigation";
9
9
 
10
10
  type TaskSubprojectTabGridProps = {
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { formatDate } from "@tulip-systems/core";
4
+ import { Link } from "@tulip-systems/core/navigation";
4
5
  import { TableCell, type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
5
6
  import { createTableSelectCell } from "@tulip-systems/data-tables/client";
6
7
  import { cn } from "@tulip-systems/ui/cn";
7
- import Link from "next/link";
8
8
  import type { TaskTableData } from "../_components/tables";
9
9
  import { TaskStatusField } from "./status";
10
10
 
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ import { Link } from "@tulip-systems/core/navigation";
2
3
  import { type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
3
4
  import {
4
5
  createTableIndexCell,
@@ -15,7 +16,6 @@ import {
15
16
  InlineTableCell,
16
17
  InlineTableCellError,
17
18
  } from "@tulip-systems/inline/client";
18
- import Link from "next/link";
19
19
  import { z } from "zod";
20
20
  import { orpc } from "@/server/router/client";
21
21
  import type { TaskTableData, TaskTableMeta } from "../_components/tables";
@@ -0,0 +1,40 @@
1
+ import { defineEvent } from "@tulip-systems/events";
2
+ import { z } from "zod";
3
+ import { eventHistoryMetadata, historyJSONSchema } from "@/app/admin/history/_lib/validators";
4
+ import { taskStatusSchema } from "./validators";
5
+
6
+ /**
7
+ * Published for each created or imported task. Carries actor, identity, project context,
8
+ * initial status and the complete inserted row snapshot.
9
+ */
10
+ export const taskCreatedEvent = defineEvent("task.created", {
11
+ version: 2,
12
+ schema: z.object({
13
+ ...eventHistoryMetadata,
14
+ taskId: z.uuid(),
15
+ projectId: z.uuid(),
16
+ projectTitle: z.string(),
17
+ code: z.string(),
18
+ title: z.string(),
19
+ status: taskStatusSchema,
20
+ after: z.record(z.string(), historyJSONSchema),
21
+ }),
22
+ });
23
+
24
+ /**
25
+ * Published for general edits, status changes and assignment changes, once per affected task.
26
+ * Carries actor, identity, project context and complete before/after row snapshots.
27
+ */
28
+ export const taskUpdatedEvent = defineEvent("task.updated", {
29
+ version: 1,
30
+ schema: z.object({
31
+ ...eventHistoryMetadata,
32
+ taskId: z.uuid(),
33
+ projectId: z.uuid(),
34
+ projectTitle: z.string(),
35
+ code: z.string(),
36
+ title: z.string(),
37
+ before: z.record(z.string(), historyJSONSchema),
38
+ after: z.record(z.string(), historyJSONSchema),
39
+ }),
40
+ });
@@ -0,0 +1,46 @@
1
+ import "server-cli-only";
2
+
3
+ import { createHistoryDiff } from "@/app/admin/history/_lib/helpers";
4
+ import { createHistoryEntry } from "@/app/admin/history/_lib/service.server";
5
+ import { taskCreatedEvent, taskUpdatedEvent } from "./events";
6
+
7
+ /**
8
+ * Tasks supply historical labels, project context and diffs from captured row snapshots.
9
+ */
10
+ export const taskHistoryEventListeners = {
11
+ taskCreated: taskCreatedEvent.listen(async ({ event, payload: p }) => {
12
+ await createHistoryEntry({
13
+ eventId: event.id,
14
+ eventName: event.name,
15
+ eventVersion: event.version,
16
+ occurredAt: event.occurredAt,
17
+ actorId: p.actor.id,
18
+ actorName: p.actor.name,
19
+ resourceType: "tasks",
20
+ resourceId: p.taskId,
21
+ resourceLabel: `${p.code} · ${p.title}`,
22
+ context: [{ type: "projects", id: p.projectId, label: p.projectTitle }],
23
+ action: "created",
24
+ diff: createHistoryDiff(null, p.after),
25
+ });
26
+ }),
27
+ taskUpdated: taskUpdatedEvent.listen(async ({ event, payload: p }) => {
28
+ const diff = createHistoryDiff(p.before, p.after);
29
+ if (!diff.changes.some((change) => change.path !== "/updated_at")) return;
30
+
31
+ await createHistoryEntry({
32
+ eventId: event.id,
33
+ eventName: event.name,
34
+ eventVersion: event.version,
35
+ occurredAt: event.occurredAt,
36
+ actorId: p.actor.id,
37
+ actorName: p.actor.name,
38
+ resourceType: "tasks",
39
+ resourceId: p.taskId,
40
+ resourceLabel: `${p.code} · ${p.title}`,
41
+ context: [{ type: "projects", id: p.projectId, label: p.projectTitle }],
42
+ action: "updated",
43
+ diff,
44
+ });
45
+ }),
46
+ };
@@ -8,12 +8,15 @@ import {
8
8
  offsetPaginationSchema,
9
9
  parseOffsetPagination,
10
10
  } from "@tulip-systems/query";
11
- import { and, asc, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
11
+ import { and, asc, count, eq, getTableColumns, gte, inArray, sql } from "drizzle-orm";
12
+ import { after } from "next/server";
12
13
  import z from "zod";
13
14
  import { domains } from "@/app/admin/domains/_lib/schema";
15
+ import type { JSONValue } from "@/app/admin/history/_lib/validators";
14
16
  import { initiatives } from "@/app/admin/initiatives/_lib/schema";
15
17
  import { getTableData } from "@/app/lib/utils/table-data.server";
16
18
  import { employees, projects, timeEntries, users } from "@/server/db/schema";
19
+ import { events } from "@/server/events/init";
17
20
  import { permissionMiddleware } from "@/server/router/middleware";
18
21
  import { protectedProcedure } from "@/server/router/procedures";
19
22
  import type { TaskTableData } from "../_components/tables";
@@ -28,6 +31,7 @@ import {
28
31
  taskTableSortsParser,
29
32
  taskTableSortsSchema,
30
33
  } from "../_config/sorting";
34
+ import { taskCreatedEvent, taskUpdatedEvent } from "./events";
31
35
  import { extractTaskCodeSQL, generateTaskCode } from "./helpers";
32
36
  import { tasks } from "./schema";
33
37
  import {
@@ -265,17 +269,6 @@ export const tasksRouter = {
265
269
  .use(permissionMiddleware({ task: ["create"] }))
266
270
  .input(createTaskSchema)
267
271
  .handler(async ({ context, input }) => {
268
- const [project] = await context.db
269
- .select()
270
- .from(projects)
271
- .where(eq(projects.id, input.projectId));
272
-
273
- if (!project) {
274
- throw new ORPCError("NOT_FOUND", {
275
- message: "Project kon niet worden gevonden",
276
- });
277
- }
278
-
279
272
  if (input.parentId) {
280
273
  const [parent] = await context.db.select().from(tasks).where(eq(tasks.id, input.parentId));
281
274
 
@@ -292,51 +285,61 @@ export const tasksRouter = {
292
285
  }
293
286
  }
294
287
 
295
- const [lastTask] = await context.db
296
- .select({ index: tasks.index })
297
- .from(tasks)
298
- .limit(1)
299
- .where(eq(tasks.projectId, input.projectId))
300
- .orderBy(desc(tasks.index));
301
-
302
- const [lastCode] = await context.db
303
- .select({ lastNumber: extractTaskCodeSQL(tasks.code) })
304
- .from(tasks)
305
- .limit(1)
306
- .where(eq(tasks.projectId, input.projectId))
307
- .orderBy(desc(extractTaskCodeSQL(tasks.code)));
308
-
309
- const index = (lastTask?.index ?? 0) + 1;
310
-
311
- const lastNumber = lastCode?.lastNumber ?? null;
312
- const code = generateTaskCode(project.key, lastNumber);
288
+ const { result, projectTitle } = await context.db.transaction(async (tx) => {
289
+ const [project] = await tx
290
+ .select({ key: projects.key, title: projects.title })
291
+ .from(projects)
292
+ .where(eq(projects.id, input.projectId))
293
+ .for("update");
294
+ if (!project) {
295
+ throw new ORPCError("NOT_FOUND", { message: "Project kon niet worden gevonden" });
296
+ }
313
297
 
314
- const [result] = await context.db
315
- .insert(tasks)
316
- .values({ ...input, code, index })
317
- .returning();
298
+ // Allocate the task code and ordering position while holding the project lock.
299
+ const [lastTask] = await tx
300
+ .select({
301
+ index: sql<number>`coalesce(max(${tasks.index}), 0)`,
302
+ number: sql<number>`coalesce(max(${extractTaskCodeSQL(tasks.code)}), 0)`,
303
+ })
304
+ .from(tasks)
305
+ .where(eq(tasks.projectId, input.projectId));
306
+
307
+ const [result] = await tx
308
+ .insert(tasks)
309
+ .values({
310
+ ...input,
311
+ index: (lastTask?.index ?? 0) + 1,
312
+ code: generateTaskCode(project.key, lastTask?.number ?? 0),
313
+ })
314
+ .returning({
315
+ ...getTableColumns(tasks),
316
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
317
+ });
318
+ if (!result) throw new ORPCError("INTERNAL_SERVER_ERROR");
319
+ return { result, projectTitle: project.title };
320
+ });
318
321
 
319
- if (!result) {
320
- throw new ORPCError("INTERNAL_SERVER_ERROR", {
321
- message: "Task kon niet worden aangemaakt",
322
- });
323
- }
322
+ after(async () => {
323
+ await events.publish(
324
+ taskCreatedEvent,
325
+ {
326
+ actor: { id: context.user.id, name: context.user.name },
327
+ taskId: result.id,
328
+ projectId: result.projectId,
329
+ projectTitle,
330
+ code: result.code,
331
+ title: result.title,
332
+ status: result.status,
333
+ after: result.after,
334
+ },
335
+ { occurredAt: new Date().toISOString() },
336
+ );
337
+ });
324
338
  }),
325
339
  importExcel: protectedProcedure
326
340
  .use(permissionMiddleware({ task: ["create"] }))
327
341
  .input(importExcelTasksSchema)
328
342
  .handler(async ({ context, input }) => {
329
- const [project] = await context.db
330
- .select()
331
- .from(projects)
332
- .where(eq(projects.id, input.projectId));
333
-
334
- if (!project) {
335
- throw new ORPCError("NOT_FOUND", {
336
- message: "Project kon niet worden gevonden",
337
- });
338
- }
339
-
340
343
  const assigneeEmails = input.items
341
344
  .map((item) => item.assignee)
342
345
  .filter((email): email is string => Boolean(email));
@@ -347,25 +350,8 @@ export const tasksRouter = {
347
350
  .leftJoin(users, eq(users.id, employees.userId))
348
351
  .where(inArray(users.email, assigneeEmails));
349
352
 
350
- const [lastTask] = await context.db
351
- .select({ index: tasks.index })
352
- .from(tasks)
353
- .limit(1)
354
- .where(eq(tasks.projectId, input.projectId))
355
- .orderBy(desc(tasks.index));
356
-
357
- const [lastCode] = await context.db
358
- .select({ lastNumber: extractTaskCodeSQL(tasks.code) })
359
- .from(tasks)
360
- .limit(1)
361
- .where(eq(tasks.projectId, input.projectId))
362
- .orderBy(desc(extractTaskCodeSQL(tasks.code)));
363
-
364
- const lastIndex = (lastTask?.index ?? 0) + 1;
365
- const lastNumber = lastCode?.lastNumber ?? 0;
366
-
367
353
  const values = input.items.map(
368
- (item, index) =>
354
+ (item) =>
369
355
  ({
370
356
  projectId: input.projectId,
371
357
  initiativeId: input.initiativeId,
@@ -377,20 +363,67 @@ export const tasksRouter = {
377
363
  ? (assignees.find((assignee) => assignee.email === item.assignee)?.id ?? null)
378
364
  : null,
379
365
  description: convertMarkdownToEditorContent(item.description ?? ""),
380
- code: generateTaskCode(project.key, lastNumber + index),
381
- index: lastIndex + index,
382
366
  }) satisfies Partial<Task>,
383
367
  );
384
368
 
385
- const results = await context.db.insert(tasks).values(values).returning();
369
+ if (!values.length) return [];
370
+ const { results, projectTitle } = await context.db.transaction(async (tx) => {
371
+ const [project] = await tx
372
+ .select({ key: projects.key, title: projects.title })
373
+ .from(projects)
374
+ .where(eq(projects.id, input.projectId))
375
+ .for("update");
376
+ if (!project) {
377
+ throw new ORPCError("NOT_FOUND", { message: "Project kon niet worden gevonden" });
378
+ }
386
379
 
387
- if (!results) {
388
- throw new ORPCError("BAD_REQUEST", {
389
- message: "Tasks konden niet worden geïmporteerd",
390
- });
391
- }
380
+ // Allocate the task code and ordering position while holding the project lock.
381
+ const [lastTask] = await tx
382
+ .select({
383
+ index: sql<number>`coalesce(max(${tasks.index}), 0)`,
384
+ number: sql<number>`coalesce(max(${extractTaskCodeSQL(tasks.code)}), 0)`,
385
+ })
386
+ .from(tasks)
387
+ .where(eq(tasks.projectId, input.projectId));
388
+
389
+ const results = await tx
390
+ .insert(tasks)
391
+ .values(
392
+ values.map((value, index) => ({
393
+ ...value,
394
+ index: (lastTask?.index ?? 0) + index + 1,
395
+ code: generateTaskCode(project.key, (lastTask?.number ?? 0) + index),
396
+ })),
397
+ )
398
+ .returning({
399
+ ...getTableColumns(tasks),
400
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
401
+ });
402
+ return { results, projectTitle: project.title };
403
+ });
404
+
405
+ after(async () => {
406
+ await Promise.all(
407
+ results.map((task) =>
408
+ events.publish(
409
+ taskCreatedEvent,
410
+ {
411
+ actor: { id: context.user.id, name: context.user.name },
412
+ taskId: task.id,
413
+ projectId: task.projectId,
414
+ projectTitle,
415
+ code: task.code,
416
+ title: task.title,
417
+ status: task.status,
418
+ after: task.after,
419
+ },
420
+ { occurredAt: new Date().toISOString() },
421
+ ),
422
+ ),
423
+ );
424
+ });
392
425
 
393
- return results;
426
+ return results.map(({ after: _after, ...task }) => task);
394
427
  }),
395
428
  update: protectedProcedure
396
429
  .use(permissionMiddleware({ task: ["update"] }))
@@ -400,33 +433,132 @@ export const tasksRouter = {
400
433
  const [result] = await context.db
401
434
  .update(tasks)
402
435
  .set(input.data)
403
- .where(eq(tasks.id, input.id))
404
- .returning();
405
-
406
- if (!result) {
407
- throw new ORPCError("INTERNAL_SERVER_ERROR", {
408
- message: "Task kon niet worden aangemaakt",
436
+ .from(projects)
437
+ .where(
438
+ and(eq(tasks.id, input.id), eq(projects.id, input.data.projectId ?? tasks.projectId)),
439
+ )
440
+ .returning({
441
+ id: tasks.id,
442
+ projectId: tasks.projectId,
443
+ code: tasks.code,
444
+ title: tasks.title,
445
+ projectTitle: projects.title,
446
+ before: sql<Record<string, JSONValue>>`to_jsonb(OLD)`,
447
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
409
448
  });
410
- }
449
+
450
+ if (!result) throw new ORPCError("NOT_FOUND", { message: "Task kon niet worden gewijzigd" });
451
+
452
+ after(async () => {
453
+ await events.publish(
454
+ taskUpdatedEvent,
455
+ {
456
+ actor: { id: context.user.id, name: context.user.name },
457
+ taskId: result.id,
458
+ projectId: result.projectId,
459
+ projectTitle: result.projectTitle,
460
+ code: result.code,
461
+ title: result.title,
462
+ before: result.before,
463
+ after: result.after,
464
+ },
465
+ { occurredAt: new Date().toISOString() },
466
+ );
467
+ });
411
468
  }),
412
469
  setStatus: protectedProcedure
413
470
  .use(permissionMiddleware({ task: ["update"] }))
414
471
  .input(bulkActionSchema.extend({ status: taskStatusSchema }))
415
472
  .handler(async ({ context, input }) => {
416
- await context.db
473
+ const updated = await context.db
417
474
  .update(tasks)
418
475
  .set({ status: input.status })
419
- .where(inArray(tasks.id, input.ids))
420
- .returning();
476
+ .from(projects)
477
+ .where(
478
+ and(
479
+ eq(tasks.projectId, projects.id),
480
+ inArray(tasks.id, input.ids),
481
+ sql`${tasks.status} IS DISTINCT FROM ${input.status}`,
482
+ ),
483
+ )
484
+ .returning({
485
+ id: tasks.id,
486
+ projectId: tasks.projectId,
487
+ projectTitle: projects.title,
488
+ code: tasks.code,
489
+ title: tasks.title,
490
+ before: sql<Record<string, JSONValue>>`to_jsonb(OLD)`,
491
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
492
+ });
493
+
494
+ after(async () => {
495
+ await Promise.all(
496
+ updated.map((task) =>
497
+ events.publish(
498
+ taskUpdatedEvent,
499
+ {
500
+ actor: { id: context.user.id, name: context.user.name },
501
+ taskId: task.id,
502
+ projectId: task.projectId,
503
+ projectTitle: task.projectTitle,
504
+ code: task.code,
505
+ title: task.title,
506
+ before: task.before,
507
+ after: task.after,
508
+ },
509
+ { occurredAt: new Date().toISOString() },
510
+ ),
511
+ ),
512
+ );
513
+ });
421
514
  }),
422
515
  setAssignee: protectedProcedure
423
516
  .use(permissionMiddleware({ task: ["update"] }))
424
- .input(bulkActionSchema.extend({ assigneeId: z.string() }))
517
+ .input(bulkActionSchema.extend({ assigneeId: z.uuid().nullable() }))
425
518
  .handler(async ({ context, input }) => {
426
- await context.db
519
+ const updated = await context.db
427
520
  .update(tasks)
428
521
  .set({ assigneeId: input.assigneeId })
429
- .where(inArray(tasks.id, input.ids));
522
+ .from(projects)
523
+ .where(
524
+ and(
525
+ eq(tasks.projectId, projects.id),
526
+ inArray(tasks.id, input.ids),
527
+ sql`${tasks.assigneeId} IS DISTINCT FROM ${input.assigneeId}`,
528
+ ),
529
+ )
530
+ .returning({
531
+ id: tasks.id,
532
+ projectId: tasks.projectId,
533
+ projectTitle: projects.title,
534
+ code: tasks.code,
535
+ title: tasks.title,
536
+ before: sql<Record<string, JSONValue>>`to_jsonb(OLD)`,
537
+ after: sql<Record<string, JSONValue>>`to_jsonb(NEW)`,
538
+ });
539
+
540
+ if (!updated.length) return;
541
+
542
+ after(async () => {
543
+ await Promise.all(
544
+ updated.map((task) =>
545
+ events.publish(
546
+ taskUpdatedEvent,
547
+ {
548
+ actor: { id: context.user.id, name: context.user.name },
549
+ taskId: task.id,
550
+ projectId: task.projectId,
551
+ projectTitle: task.projectTitle,
552
+ code: task.code,
553
+ title: task.title,
554
+ before: task.before,
555
+ after: task.after,
556
+ },
557
+ { occurredAt: new Date().toISOString() },
558
+ ),
559
+ ),
560
+ );
561
+ });
430
562
  }),
431
563
  move: protectedProcedure
432
564
  .use(permissionMiddleware({ task: ["update"] }))
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { formatDateTime, formatDuration } from "@tulip-systems/core";
4
+ import { Link } from "@tulip-systems/core/navigation";
4
5
  import { TableCell, type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
5
6
  import { createTableSelectCell } from "@tulip-systems/data-tables/client";
6
- import Link from "next/link";
7
7
  import type { User } from "@/server/auth/validators";
8
8
  import type { Customer } from "../../customers/_lib/validators";
9
9
  import type { Employee } from "../../employees/_lib/validators";
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { formatDuration } from "@tulip-systems/core";
4
+ import { Link } from "@tulip-systems/core/navigation";
4
5
  import { type TableColumnDef, TableColumnHeader } from "@tulip-systems/data-tables";
5
6
  import { createTableSelectCell, TableReadOnlyCell } from "@tulip-systems/data-tables/client";
6
7
  import {
@@ -9,7 +10,6 @@ import {
9
10
  InlineTableCell,
10
11
  InlineTableCellError,
11
12
  } from "@tulip-systems/inline/client";
12
- import Link from "next/link";
13
13
  import { z } from "zod";
14
14
  import { orpc } from "@/server/router/client";
15
15
  import { formatDurationInDecimalHours } from "../_lib/helpers";
@@ -1,18 +1,18 @@
1
- import { mcpHandler } from "@better-auth/oauth-provider";
1
+ import { requireMcpAuth } from "@better-auth/mcp";
2
+ import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
2
3
  import { type AnyProcedure, call } from "@orpc/server";
3
- import { createMcpHandler } from "mcp-handler";
4
- import { OAUTH_AUDIENCES, OAUTH_ISSUER, OAUTH_JWKS_URI } from "@/server/auth/constants";
4
+ import {
5
+ OAUTH_AUDIENCE,
6
+ OAUTH_ISSUER,
7
+ OAUTH_JWKS_URI,
8
+ OAUTH_SCOPES,
9
+ } from "@/server/auth/constants";
10
+ import { auth } from "@/server/auth/init";
5
11
  import { type McpProcedure, mcpRouter } from "@/server/mcp/router";
6
12
  import { createContext } from "@/server/router/init";
7
13
 
8
- const handler = mcpHandler(
9
- {
10
- jwksUrl: OAUTH_JWKS_URI,
11
- verifyOptions: {
12
- issuer: OAUTH_ISSUER,
13
- audience: OAUTH_AUDIENCES,
14
- },
15
- },
14
+ const POST = requireMcpAuth(
15
+ auth,
16
16
  (req, jwt) => {
17
17
  const transport = new URL(req.url).pathname.split("/").filter(Boolean).pop();
18
18
 
@@ -22,8 +22,12 @@ const handler = mcpHandler(
22
22
  });
23
23
  }
24
24
 
25
- return createMcpHandler(
26
- (server) => {
25
+ const mcpServerHandler = createMcpHandler(
26
+ () => {
27
+ const server = new McpServer({
28
+ name: "tulip-demo-code",
29
+ version: "1.0.0",
30
+ });
27
31
  const context = createContext({ headers: req.headers, jwt });
28
32
 
29
33
  const entries = Object.entries(mcpRouter) as Array<[string, McpProcedure]>;
@@ -33,7 +37,7 @@ const handler = mcpHandler(
33
37
 
34
38
  const definition = procedure["~orpc"];
35
39
 
36
- server.registerTool<never, Procedure["~orpc"]["inputSchema"]>(
40
+ server.registerTool(
37
41
  procedureName,
38
42
  {
39
43
  title: definition.meta.title,
@@ -44,18 +48,24 @@ const handler = mcpHandler(
44
48
  call(procedure as AnyProcedure, input, { context })) as never,
45
49
  );
46
50
  });
51
+
52
+ return server;
47
53
  },
48
54
  {
49
- serverInfo: {
50
- name: "tulip-demo-code",
51
- version: "1.0.0",
52
- },
53
- verboseLogs: true,
55
+ legacy: "reject",
54
56
  },
55
- )(req);
57
+ );
58
+
59
+ return mcpServerHandler.fetch(req);
60
+ },
61
+ {
62
+ resource: OAUTH_AUDIENCE,
63
+ issuer: OAUTH_ISSUER,
64
+ jwksUrl: OAUTH_JWKS_URI,
65
+ challengeScopes: [...OAUTH_SCOPES],
56
66
  },
57
67
  );
58
68
 
59
69
  export const maxDuration = 60;
60
70
 
61
- export { handler as DELETE, handler as GET, handler as POST };
71
+ export { POST };
@@ -0,0 +1,4 @@
1
+ import { queueHandlers } from "@/server/queue/handlers";
2
+ import { queue } from "@/server/queue/init";
3
+
4
+ export const POST = queue.handleCallback({ handlers: queueHandlers });
@@ -8,8 +8,8 @@ import {
8
8
  ForgetPasswordTitle,
9
9
  ForgetPasswordView,
10
10
  } from "@tulip-systems/auth/components/client";
11
+ import { Link } from "@tulip-systems/core/navigation";
11
12
  import type { Metadata } from "next";
12
- import Link from "next/link";
13
13
 
14
14
  export const metadata: Metadata = {
15
15
  title: "Wachtwoord vergeten",