@sprintdock/backend 0.4.2

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 (107) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +252 -0
  3. package/SERVER.md +25 -0
  4. package/dist/index.d.ts +1536 -0
  5. package/dist/index.js +4103 -0
  6. package/drizzle/0000_fresh_roxanne_simpson.sql +51 -0
  7. package/drizzle/0001_sprint_markdown_content.sql +1 -0
  8. package/drizzle/0002_task_touched_files.sql +8 -0
  9. package/drizzle/meta/0000_snapshot.json +372 -0
  10. package/drizzle/meta/0001_snapshot.json +379 -0
  11. package/drizzle/meta/_journal.json +27 -0
  12. package/drizzle.config.ts +14 -0
  13. package/package.json +40 -0
  14. package/src/application/container.ts +44 -0
  15. package/src/application/dto/plan-sprint-analytics.dto.ts +30 -0
  16. package/src/application/plan.service.ts +123 -0
  17. package/src/application/sprint.service.ts +118 -0
  18. package/src/application/task.service.ts +389 -0
  19. package/src/db/connection.ts +25 -0
  20. package/src/db/migrator.ts +46 -0
  21. package/src/db/schema/index.ts +14 -0
  22. package/src/db/schema/plans.ts +18 -0
  23. package/src/db/schema/relations.ts +36 -0
  24. package/src/db/schema/sprints.ts +33 -0
  25. package/src/db/schema/tasks.ts +62 -0
  26. package/src/domain/entities/index.ts +30 -0
  27. package/src/domain/entities/plan.entity.ts +33 -0
  28. package/src/domain/entities/sprint.entity.ts +44 -0
  29. package/src/domain/entities/task.entity.ts +80 -0
  30. package/src/domain/repositories/index.ts +9 -0
  31. package/src/domain/repositories/plan.repository.ts +21 -0
  32. package/src/domain/repositories/sprint.repository.ts +19 -0
  33. package/src/domain/repositories/task.repository.ts +35 -0
  34. package/src/domain/services/index.ts +9 -0
  35. package/src/domain/services/plan-domain.service.ts +44 -0
  36. package/src/domain/services/sprint-domain.service.ts +44 -0
  37. package/src/domain/services/task-domain.service.ts +136 -0
  38. package/src/errors/backend-errors.ts +75 -0
  39. package/src/http/app-factory.ts +55 -0
  40. package/src/http/controllers/health.controller.ts +33 -0
  41. package/src/http/controllers/plan.controller.ts +153 -0
  42. package/src/http/controllers/sprint.controller.ts +111 -0
  43. package/src/http/controllers/task.controller.ts +158 -0
  44. package/src/http/express-augmentation.d.ts +20 -0
  45. package/src/http/middleware/cors.ts +41 -0
  46. package/src/http/middleware/error-handler.ts +50 -0
  47. package/src/http/middleware/request-id.ts +28 -0
  48. package/src/http/middleware/validate.ts +54 -0
  49. package/src/http/routes/v1/index.ts +39 -0
  50. package/src/http/routes/v1/plan.routes.ts +51 -0
  51. package/src/http/routes/v1/schemas.ts +175 -0
  52. package/src/http/routes/v1/sprint.routes.ts +49 -0
  53. package/src/http/routes/v1/task.routes.ts +64 -0
  54. package/src/index.ts +34 -0
  55. package/src/infrastructure/observability/audit-log.ts +34 -0
  56. package/src/infrastructure/observability/request-correlation.ts +20 -0
  57. package/src/infrastructure/repositories/drizzle/drizzle-plan.repository.ts +138 -0
  58. package/src/infrastructure/repositories/drizzle/drizzle-sprint.repository.ts +137 -0
  59. package/src/infrastructure/repositories/drizzle/drizzle-task.repository.ts +403 -0
  60. package/src/infrastructure/repositories/drizzle/index.ts +16 -0
  61. package/src/infrastructure/repositories/drizzle/row-mappers.ts +106 -0
  62. package/src/infrastructure/repositories/drizzle/sqlite-db.ts +13 -0
  63. package/src/infrastructure/repositories/repository-factory.ts +54 -0
  64. package/src/infrastructure/security/auth-context.ts +35 -0
  65. package/src/infrastructure/security/input-guard.ts +21 -0
  66. package/src/infrastructure/security/rate-limiter.ts +65 -0
  67. package/src/mcp/bootstrap-sprintdock-sqlite.ts +45 -0
  68. package/src/mcp/mcp-query-helpers.ts +89 -0
  69. package/src/mcp/mcp-text-formatters.ts +204 -0
  70. package/src/mcp/mcp-tool-error.ts +24 -0
  71. package/src/mcp/plugins/context-tools.plugin.ts +107 -0
  72. package/src/mcp/plugins/default-plugins.ts +23 -0
  73. package/src/mcp/plugins/index.ts +21 -0
  74. package/src/mcp/plugins/mcp-tool-kit.ts +90 -0
  75. package/src/mcp/plugins/plan-tools.plugin.ts +426 -0
  76. package/src/mcp/plugins/sprint-tools.plugin.ts +396 -0
  77. package/src/mcp/plugins/task-tools.plugin.ts +528 -0
  78. package/src/mcp/plugins/task-workflow.plugin.ts +275 -0
  79. package/src/mcp/plugins/types.ts +45 -0
  80. package/src/mcp/register-sprintdock-mcp-tools.ts +50 -0
  81. package/src/mcp/sprintdock-mcp-capabilities.ts +14 -0
  82. package/src/mcp/sprintdock-mcp-runtime.ts +119 -0
  83. package/src/mcp/tool-guard.ts +58 -0
  84. package/src/mcp/transports/http-app-factory.ts +31 -0
  85. package/src/mcp/transports/http-entry.ts +27 -0
  86. package/src/mcp/transports/stdio-entry.ts +17 -0
  87. package/tests/application/container.test.ts +36 -0
  88. package/tests/application/plan.service.test.ts +114 -0
  89. package/tests/application/sprint.service.test.ts +138 -0
  90. package/tests/application/task.service.test.ts +325 -0
  91. package/tests/db/test-db.test.ts +112 -0
  92. package/tests/domain/plan-domain.service.test.ts +44 -0
  93. package/tests/domain/sprint-domain.service.test.ts +38 -0
  94. package/tests/domain/task-domain.service.test.ts +105 -0
  95. package/tests/errors/backend-errors.test.ts +44 -0
  96. package/tests/helpers/test-db.ts +43 -0
  97. package/tests/http/error-handler.test.ts +37 -0
  98. package/tests/http/plan.routes.test.ts +128 -0
  99. package/tests/http/sprint.routes.test.ts +72 -0
  100. package/tests/http/task.routes.test.ts +130 -0
  101. package/tests/http/test-app.ts +17 -0
  102. package/tests/infrastructure/drizzle-plan.repository.test.ts +62 -0
  103. package/tests/infrastructure/drizzle-sprint.repository.test.ts +49 -0
  104. package/tests/infrastructure/drizzle-task.repository.test.ts +132 -0
  105. package/tests/mcp/mcp-text-formatters.test.ts +246 -0
  106. package/tests/mcp/register-sprintdock-mcp-tools.test.ts +207 -0
  107. package/tsconfig.json +9 -0
@@ -0,0 +1,379 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "6b3a33b8-8d7c-456a-98ce-725ef426babe",
5
+ "prevId": "83aff8bf-5357-4a7d-a572-042fd77858fb",
6
+ "tables": {
7
+ "plans": {
8
+ "name": "plans",
9
+ "columns": {
10
+ "id": {
11
+ "name": "id",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "slug": {
18
+ "name": "slug",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "title": {
25
+ "name": "title",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ },
31
+ "description": {
32
+ "name": "description",
33
+ "type": "text",
34
+ "primaryKey": false,
35
+ "notNull": false,
36
+ "autoincrement": false
37
+ },
38
+ "markdown_content": {
39
+ "name": "markdown_content",
40
+ "type": "text",
41
+ "primaryKey": false,
42
+ "notNull": false,
43
+ "autoincrement": false
44
+ },
45
+ "status": {
46
+ "name": "status",
47
+ "type": "text",
48
+ "primaryKey": false,
49
+ "notNull": true,
50
+ "autoincrement": false
51
+ },
52
+ "is_active": {
53
+ "name": "is_active",
54
+ "type": "integer",
55
+ "primaryKey": false,
56
+ "notNull": true,
57
+ "autoincrement": false,
58
+ "default": false
59
+ },
60
+ "created_at": {
61
+ "name": "created_at",
62
+ "type": "text",
63
+ "primaryKey": false,
64
+ "notNull": true,
65
+ "autoincrement": false
66
+ },
67
+ "updated_at": {
68
+ "name": "updated_at",
69
+ "type": "text",
70
+ "primaryKey": false,
71
+ "notNull": true,
72
+ "autoincrement": false
73
+ }
74
+ },
75
+ "indexes": {
76
+ "plans_slug_unique": {
77
+ "name": "plans_slug_unique",
78
+ "columns": [
79
+ "slug"
80
+ ],
81
+ "isUnique": true
82
+ }
83
+ },
84
+ "foreignKeys": {},
85
+ "compositePrimaryKeys": {},
86
+ "uniqueConstraints": {},
87
+ "checkConstraints": {}
88
+ },
89
+ "sprints": {
90
+ "name": "sprints",
91
+ "columns": {
92
+ "id": {
93
+ "name": "id",
94
+ "type": "text",
95
+ "primaryKey": true,
96
+ "notNull": true,
97
+ "autoincrement": false
98
+ },
99
+ "slug": {
100
+ "name": "slug",
101
+ "type": "text",
102
+ "primaryKey": false,
103
+ "notNull": true,
104
+ "autoincrement": false
105
+ },
106
+ "plan_id": {
107
+ "name": "plan_id",
108
+ "type": "text",
109
+ "primaryKey": false,
110
+ "notNull": true,
111
+ "autoincrement": false
112
+ },
113
+ "name": {
114
+ "name": "name",
115
+ "type": "text",
116
+ "primaryKey": false,
117
+ "notNull": true,
118
+ "autoincrement": false
119
+ },
120
+ "goal": {
121
+ "name": "goal",
122
+ "type": "text",
123
+ "primaryKey": false,
124
+ "notNull": true,
125
+ "autoincrement": false
126
+ },
127
+ "markdown_content": {
128
+ "name": "markdown_content",
129
+ "type": "text",
130
+ "primaryKey": false,
131
+ "notNull": false,
132
+ "autoincrement": false
133
+ },
134
+ "status": {
135
+ "name": "status",
136
+ "type": "text",
137
+ "primaryKey": false,
138
+ "notNull": true,
139
+ "autoincrement": false
140
+ },
141
+ "order": {
142
+ "name": "order",
143
+ "type": "integer",
144
+ "primaryKey": false,
145
+ "notNull": true,
146
+ "autoincrement": false,
147
+ "default": 0
148
+ },
149
+ "start_date": {
150
+ "name": "start_date",
151
+ "type": "text",
152
+ "primaryKey": false,
153
+ "notNull": false,
154
+ "autoincrement": false
155
+ },
156
+ "end_date": {
157
+ "name": "end_date",
158
+ "type": "text",
159
+ "primaryKey": false,
160
+ "notNull": false,
161
+ "autoincrement": false
162
+ },
163
+ "created_at": {
164
+ "name": "created_at",
165
+ "type": "text",
166
+ "primaryKey": false,
167
+ "notNull": true,
168
+ "autoincrement": false
169
+ },
170
+ "updated_at": {
171
+ "name": "updated_at",
172
+ "type": "text",
173
+ "primaryKey": false,
174
+ "notNull": true,
175
+ "autoincrement": false
176
+ }
177
+ },
178
+ "indexes": {
179
+ "sprints_plan_id_slug_unique": {
180
+ "name": "sprints_plan_id_slug_unique",
181
+ "columns": [
182
+ "plan_id",
183
+ "slug"
184
+ ],
185
+ "isUnique": true
186
+ }
187
+ },
188
+ "foreignKeys": {
189
+ "sprints_plan_id_plans_id_fk": {
190
+ "name": "sprints_plan_id_plans_id_fk",
191
+ "tableFrom": "sprints",
192
+ "tableTo": "plans",
193
+ "columnsFrom": [
194
+ "plan_id"
195
+ ],
196
+ "columnsTo": [
197
+ "id"
198
+ ],
199
+ "onDelete": "cascade",
200
+ "onUpdate": "no action"
201
+ }
202
+ },
203
+ "compositePrimaryKeys": {},
204
+ "uniqueConstraints": {},
205
+ "checkConstraints": {}
206
+ },
207
+ "task_dependencies": {
208
+ "name": "task_dependencies",
209
+ "columns": {
210
+ "task_id": {
211
+ "name": "task_id",
212
+ "type": "text",
213
+ "primaryKey": false,
214
+ "notNull": true,
215
+ "autoincrement": false
216
+ },
217
+ "depends_on_task_id": {
218
+ "name": "depends_on_task_id",
219
+ "type": "text",
220
+ "primaryKey": false,
221
+ "notNull": true,
222
+ "autoincrement": false
223
+ }
224
+ },
225
+ "indexes": {},
226
+ "foreignKeys": {
227
+ "task_dependencies_task_id_tasks_id_fk": {
228
+ "name": "task_dependencies_task_id_tasks_id_fk",
229
+ "tableFrom": "task_dependencies",
230
+ "tableTo": "tasks",
231
+ "columnsFrom": [
232
+ "task_id"
233
+ ],
234
+ "columnsTo": [
235
+ "id"
236
+ ],
237
+ "onDelete": "cascade",
238
+ "onUpdate": "no action"
239
+ },
240
+ "task_dependencies_depends_on_task_id_tasks_id_fk": {
241
+ "name": "task_dependencies_depends_on_task_id_tasks_id_fk",
242
+ "tableFrom": "task_dependencies",
243
+ "tableTo": "tasks",
244
+ "columnsFrom": [
245
+ "depends_on_task_id"
246
+ ],
247
+ "columnsTo": [
248
+ "id"
249
+ ],
250
+ "onDelete": "cascade",
251
+ "onUpdate": "no action"
252
+ }
253
+ },
254
+ "compositePrimaryKeys": {
255
+ "task_dependencies_task_id_depends_on_task_id_pk": {
256
+ "columns": [
257
+ "task_id",
258
+ "depends_on_task_id"
259
+ ],
260
+ "name": "task_dependencies_task_id_depends_on_task_id_pk"
261
+ }
262
+ },
263
+ "uniqueConstraints": {},
264
+ "checkConstraints": {}
265
+ },
266
+ "tasks": {
267
+ "name": "tasks",
268
+ "columns": {
269
+ "id": {
270
+ "name": "id",
271
+ "type": "text",
272
+ "primaryKey": true,
273
+ "notNull": true,
274
+ "autoincrement": false
275
+ },
276
+ "sprint_id": {
277
+ "name": "sprint_id",
278
+ "type": "text",
279
+ "primaryKey": false,
280
+ "notNull": true,
281
+ "autoincrement": false
282
+ },
283
+ "title": {
284
+ "name": "title",
285
+ "type": "text",
286
+ "primaryKey": false,
287
+ "notNull": true,
288
+ "autoincrement": false
289
+ },
290
+ "description": {
291
+ "name": "description",
292
+ "type": "text",
293
+ "primaryKey": false,
294
+ "notNull": false,
295
+ "autoincrement": false
296
+ },
297
+ "status": {
298
+ "name": "status",
299
+ "type": "text",
300
+ "primaryKey": false,
301
+ "notNull": true,
302
+ "autoincrement": false
303
+ },
304
+ "priority": {
305
+ "name": "priority",
306
+ "type": "text",
307
+ "primaryKey": false,
308
+ "notNull": true,
309
+ "autoincrement": false
310
+ },
311
+ "order": {
312
+ "name": "order",
313
+ "type": "integer",
314
+ "primaryKey": false,
315
+ "notNull": true,
316
+ "autoincrement": false,
317
+ "default": 0
318
+ },
319
+ "assignee": {
320
+ "name": "assignee",
321
+ "type": "text",
322
+ "primaryKey": false,
323
+ "notNull": false,
324
+ "autoincrement": false
325
+ },
326
+ "tags": {
327
+ "name": "tags",
328
+ "type": "text",
329
+ "primaryKey": false,
330
+ "notNull": false,
331
+ "autoincrement": false
332
+ },
333
+ "created_at": {
334
+ "name": "created_at",
335
+ "type": "text",
336
+ "primaryKey": false,
337
+ "notNull": true,
338
+ "autoincrement": false
339
+ },
340
+ "updated_at": {
341
+ "name": "updated_at",
342
+ "type": "text",
343
+ "primaryKey": false,
344
+ "notNull": true,
345
+ "autoincrement": false
346
+ }
347
+ },
348
+ "indexes": {},
349
+ "foreignKeys": {
350
+ "tasks_sprint_id_sprints_id_fk": {
351
+ "name": "tasks_sprint_id_sprints_id_fk",
352
+ "tableFrom": "tasks",
353
+ "tableTo": "sprints",
354
+ "columnsFrom": [
355
+ "sprint_id"
356
+ ],
357
+ "columnsTo": [
358
+ "id"
359
+ ],
360
+ "onDelete": "cascade",
361
+ "onUpdate": "no action"
362
+ }
363
+ },
364
+ "compositePrimaryKeys": {},
365
+ "uniqueConstraints": {},
366
+ "checkConstraints": {}
367
+ }
368
+ },
369
+ "views": {},
370
+ "enums": {},
371
+ "_meta": {
372
+ "schemas": {},
373
+ "tables": {},
374
+ "columns": {}
375
+ },
376
+ "internal": {
377
+ "indexes": {}
378
+ }
379
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1774083318369,
9
+ "tag": "0000_fresh_roxanne_simpson",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1774179892580,
16
+ "tag": "0001_sprint_markdown_content",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "6",
22
+ "when": 1775200000000,
23
+ "tag": "0002_task_touched_files",
24
+ "breakpoints": true
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Drizzle Kit configuration for SQLite schema and migrations.
5
+ */
6
+ import { defineConfig, type Config } from "drizzle-kit";
7
+
8
+ const config: Config = defineConfig({
9
+ dialect: "sqlite",
10
+ schema: "./src/db/schema/index.ts",
11
+ out: "./drizzle"
12
+ });
13
+
14
+ export default config;
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@sprintdock/backend",
3
+ "version": "0.4.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@modelcontextprotocol/sdk": "^1.27.1",
16
+ "better-sqlite3": "^12.8.0",
17
+ "drizzle-orm": "^0.38.4",
18
+ "express": "^5.1.0",
19
+ "zod": "^4.1.11",
20
+ "@sprintdock/env-manager": "0.2.2",
21
+ "@sprintdock/shared": "0.2.2"
22
+ },
23
+ "devDependencies": {
24
+ "@types/better-sqlite3": "^7.6.13",
25
+ "@types/express": "^5.0.6",
26
+ "@types/sql.js": "^1.4.10",
27
+ "@types/supertest": "^7.2.0",
28
+ "drizzle-kit": "^0.30.5",
29
+ "sql.js": "^1.12.0",
30
+ "supertest": "^7.2.2",
31
+ "tsx": "^4.21.0"
32
+ },
33
+ "scripts": {
34
+ "build": "tsup src/index.ts --dts --format esm --clean",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "test": "node --import tsx --test tests/**/*.test.ts",
37
+ "db:generate": "drizzle-kit generate",
38
+ "clean": "rimraf dist"
39
+ }
40
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Factory wiring application services and domain services for a repository set.
5
+ */
6
+ import type { RepositorySet } from "../infrastructure/repositories/repository-factory.js";
7
+ import { PlanDomainService } from "../domain/services/plan-domain.service.js";
8
+ import { SprintDomainService } from "../domain/services/sprint-domain.service.js";
9
+ import { TaskDomainService } from "../domain/services/task-domain.service.js";
10
+ import { PlanService } from "./plan.service.js";
11
+ import { SprintService } from "./sprint.service.js";
12
+ import { TaskService } from "./task.service.js";
13
+
14
+ /** Application-layer services exposed to protocol adapters. */
15
+ export interface ServiceSet {
16
+ planService: PlanService;
17
+ sprintService: SprintService;
18
+ taskService: TaskService;
19
+ }
20
+
21
+ /**
22
+ * Builds domain services and wires application services for the given repositories.
23
+ */
24
+ export function createApplicationServices(repos: RepositorySet): ServiceSet {
25
+ const planDomain = new PlanDomainService();
26
+ const sprintDomain = new SprintDomainService();
27
+ const taskDomain = new TaskDomainService();
28
+
29
+ const planService = new PlanService(repos.plans, planDomain);
30
+ const sprintService = new SprintService(
31
+ repos.sprints,
32
+ repos.plans,
33
+ planService,
34
+ sprintDomain
35
+ );
36
+ const taskService = new TaskService(
37
+ repos.tasks,
38
+ repos.sprints,
39
+ planService,
40
+ taskDomain
41
+ );
42
+
43
+ return { planService, sprintService, taskService };
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: API payload for per-sprint task status analytics within a plan.
5
+ */
6
+ import type { Sprint } from "../../domain/entities/sprint.entity";
7
+
8
+ /** Counts keyed by task status for JSON responses. */
9
+ export interface TaskStatusCountsDto {
10
+ todo: number;
11
+ in_progress: number;
12
+ blocked: number;
13
+ done: number;
14
+ }
15
+
16
+ export interface SprintTaskAnalyticsDto {
17
+ sprint: Sprint;
18
+ tasksByStatus: TaskStatusCountsDto;
19
+ totalTasks: number;
20
+ }
21
+
22
+ export interface PlanSprintTaskAnalyticsDto {
23
+ planId: string;
24
+ sprints: SprintTaskAnalyticsDto[];
25
+ rollup: {
26
+ tasksByStatus: TaskStatusCountsDto;
27
+ totalTasks: number;
28
+ sprintCount: number;
29
+ };
30
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Application service orchestrating plan operations and resolution.
5
+ */
6
+ import { NotFoundError } from "../errors/backend-errors.js";
7
+ import type {
8
+ CreatePlanInput,
9
+ Plan,
10
+ UpdatePlanInput
11
+ } from "../domain/entities/plan.entity";
12
+ import type { PlanRepository } from "../domain/repositories/plan.repository";
13
+ import type { PlanDomainService } from "../domain/services/plan-domain.service";
14
+
15
+ /**
16
+ * Coordinates plan persistence with domain transition rules.
17
+ */
18
+ export class PlanService {
19
+ public constructor(
20
+ private readonly planRepo: PlanRepository,
21
+ private readonly planDomain: PlanDomainService
22
+ ) {}
23
+
24
+ /** @returns All plans in creation order. */
25
+ public async listPlans(): Promise<Plan[]> {
26
+ return this.planRepo.list();
27
+ }
28
+
29
+ /**
30
+ * Creates a plan and activates it when it is the only plan in storage.
31
+ */
32
+ public async createPlan(input: CreatePlanInput): Promise<Plan> {
33
+ const beforeCount = (await this.planRepo.list()).length;
34
+ const plan = await this.planRepo.create(input);
35
+ if (beforeCount === 0) {
36
+ await this.planRepo.setActive(plan.id);
37
+ const refreshed = await this.planRepo.findById(plan.id);
38
+ if (!refreshed) {
39
+ throw new NotFoundError("Plan", plan.id);
40
+ }
41
+ return refreshed;
42
+ }
43
+ return plan;
44
+ }
45
+
46
+ /**
47
+ * Loads a plan by id, then by slug.
48
+ *
49
+ * @throws NotFoundError when neither matches.
50
+ */
51
+ public async getPlan(idOrSlug: string): Promise<Plan> {
52
+ const byId = await this.planRepo.findById(idOrSlug);
53
+ if (byId) {
54
+ return byId;
55
+ }
56
+ const bySlug = await this.planRepo.findBySlug(idOrSlug);
57
+ if (bySlug) {
58
+ return bySlug;
59
+ }
60
+ throw new NotFoundError("Plan", idOrSlug);
61
+ }
62
+
63
+ /**
64
+ * @throws NotFoundError when no plan is marked active.
65
+ */
66
+ public async getActivePlan(): Promise<Plan> {
67
+ const active = await this.planRepo.findActive();
68
+ if (!active) {
69
+ throw new NotFoundError("Active plan", "none");
70
+ }
71
+ return active;
72
+ }
73
+
74
+ /**
75
+ * Marks the resolved plan as the sole active plan.
76
+ */
77
+ public async setActivePlan(idOrSlug: string): Promise<void> {
78
+ const plan = await this.getPlan(idOrSlug);
79
+ await this.planRepo.setActive(plan.id);
80
+ }
81
+
82
+ /**
83
+ * Updates fields; validates plan status transitions through the domain service.
84
+ */
85
+ public async updatePlan(
86
+ idOrSlug: string,
87
+ input: UpdatePlanInput
88
+ ): Promise<Plan> {
89
+ const existing = await this.getPlan(idOrSlug);
90
+ if (input.status !== undefined && input.status !== existing.status) {
91
+ this.planDomain.validateTransition(existing.status, input.status);
92
+ }
93
+ const updated = await this.planRepo.update(existing.id, input);
94
+ if (!updated) {
95
+ throw new NotFoundError("Plan", existing.id);
96
+ }
97
+ return updated;
98
+ }
99
+
100
+ /**
101
+ * Deletes a plan by id.
102
+ *
103
+ * @throws NotFoundError when the plan does not exist.
104
+ */
105
+ public async deletePlan(id: string): Promise<void> {
106
+ const deleted = await this.planRepo.delete(id);
107
+ if (!deleted) {
108
+ throw new NotFoundError("Plan", id);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Resolves the current plan from an optional id or slug, otherwise the active plan.
114
+ *
115
+ * @throws NotFoundError when lookup fails or no active plan exists.
116
+ */
117
+ public async resolvePlan(idOrSlug?: string): Promise<Plan> {
118
+ if (idOrSlug === undefined || idOrSlug === "") {
119
+ return this.getActivePlan();
120
+ }
121
+ return this.getPlan(idOrSlug);
122
+ }
123
+ }