@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.
- package/CHANGELOG.md +88 -0
- package/README.md +252 -0
- package/SERVER.md +25 -0
- package/dist/index.d.ts +1536 -0
- package/dist/index.js +4103 -0
- package/drizzle/0000_fresh_roxanne_simpson.sql +51 -0
- package/drizzle/0001_sprint_markdown_content.sql +1 -0
- package/drizzle/0002_task_touched_files.sql +8 -0
- package/drizzle/meta/0000_snapshot.json +372 -0
- package/drizzle/meta/0001_snapshot.json +379 -0
- package/drizzle/meta/_journal.json +27 -0
- package/drizzle.config.ts +14 -0
- package/package.json +40 -0
- package/src/application/container.ts +44 -0
- package/src/application/dto/plan-sprint-analytics.dto.ts +30 -0
- package/src/application/plan.service.ts +123 -0
- package/src/application/sprint.service.ts +118 -0
- package/src/application/task.service.ts +389 -0
- package/src/db/connection.ts +25 -0
- package/src/db/migrator.ts +46 -0
- package/src/db/schema/index.ts +14 -0
- package/src/db/schema/plans.ts +18 -0
- package/src/db/schema/relations.ts +36 -0
- package/src/db/schema/sprints.ts +33 -0
- package/src/db/schema/tasks.ts +62 -0
- package/src/domain/entities/index.ts +30 -0
- package/src/domain/entities/plan.entity.ts +33 -0
- package/src/domain/entities/sprint.entity.ts +44 -0
- package/src/domain/entities/task.entity.ts +80 -0
- package/src/domain/repositories/index.ts +9 -0
- package/src/domain/repositories/plan.repository.ts +21 -0
- package/src/domain/repositories/sprint.repository.ts +19 -0
- package/src/domain/repositories/task.repository.ts +35 -0
- package/src/domain/services/index.ts +9 -0
- package/src/domain/services/plan-domain.service.ts +44 -0
- package/src/domain/services/sprint-domain.service.ts +44 -0
- package/src/domain/services/task-domain.service.ts +136 -0
- package/src/errors/backend-errors.ts +75 -0
- package/src/http/app-factory.ts +55 -0
- package/src/http/controllers/health.controller.ts +33 -0
- package/src/http/controllers/plan.controller.ts +153 -0
- package/src/http/controllers/sprint.controller.ts +111 -0
- package/src/http/controllers/task.controller.ts +158 -0
- package/src/http/express-augmentation.d.ts +20 -0
- package/src/http/middleware/cors.ts +41 -0
- package/src/http/middleware/error-handler.ts +50 -0
- package/src/http/middleware/request-id.ts +28 -0
- package/src/http/middleware/validate.ts +54 -0
- package/src/http/routes/v1/index.ts +39 -0
- package/src/http/routes/v1/plan.routes.ts +51 -0
- package/src/http/routes/v1/schemas.ts +175 -0
- package/src/http/routes/v1/sprint.routes.ts +49 -0
- package/src/http/routes/v1/task.routes.ts +64 -0
- package/src/index.ts +34 -0
- package/src/infrastructure/observability/audit-log.ts +34 -0
- package/src/infrastructure/observability/request-correlation.ts +20 -0
- package/src/infrastructure/repositories/drizzle/drizzle-plan.repository.ts +138 -0
- package/src/infrastructure/repositories/drizzle/drizzle-sprint.repository.ts +137 -0
- package/src/infrastructure/repositories/drizzle/drizzle-task.repository.ts +403 -0
- package/src/infrastructure/repositories/drizzle/index.ts +16 -0
- package/src/infrastructure/repositories/drizzle/row-mappers.ts +106 -0
- package/src/infrastructure/repositories/drizzle/sqlite-db.ts +13 -0
- package/src/infrastructure/repositories/repository-factory.ts +54 -0
- package/src/infrastructure/security/auth-context.ts +35 -0
- package/src/infrastructure/security/input-guard.ts +21 -0
- package/src/infrastructure/security/rate-limiter.ts +65 -0
- package/src/mcp/bootstrap-sprintdock-sqlite.ts +45 -0
- package/src/mcp/mcp-query-helpers.ts +89 -0
- package/src/mcp/mcp-text-formatters.ts +204 -0
- package/src/mcp/mcp-tool-error.ts +24 -0
- package/src/mcp/plugins/context-tools.plugin.ts +107 -0
- package/src/mcp/plugins/default-plugins.ts +23 -0
- package/src/mcp/plugins/index.ts +21 -0
- package/src/mcp/plugins/mcp-tool-kit.ts +90 -0
- package/src/mcp/plugins/plan-tools.plugin.ts +426 -0
- package/src/mcp/plugins/sprint-tools.plugin.ts +396 -0
- package/src/mcp/plugins/task-tools.plugin.ts +528 -0
- package/src/mcp/plugins/task-workflow.plugin.ts +275 -0
- package/src/mcp/plugins/types.ts +45 -0
- package/src/mcp/register-sprintdock-mcp-tools.ts +50 -0
- package/src/mcp/sprintdock-mcp-capabilities.ts +14 -0
- package/src/mcp/sprintdock-mcp-runtime.ts +119 -0
- package/src/mcp/tool-guard.ts +58 -0
- package/src/mcp/transports/http-app-factory.ts +31 -0
- package/src/mcp/transports/http-entry.ts +27 -0
- package/src/mcp/transports/stdio-entry.ts +17 -0
- package/tests/application/container.test.ts +36 -0
- package/tests/application/plan.service.test.ts +114 -0
- package/tests/application/sprint.service.test.ts +138 -0
- package/tests/application/task.service.test.ts +325 -0
- package/tests/db/test-db.test.ts +112 -0
- package/tests/domain/plan-domain.service.test.ts +44 -0
- package/tests/domain/sprint-domain.service.test.ts +38 -0
- package/tests/domain/task-domain.service.test.ts +105 -0
- package/tests/errors/backend-errors.test.ts +44 -0
- package/tests/helpers/test-db.ts +43 -0
- package/tests/http/error-handler.test.ts +37 -0
- package/tests/http/plan.routes.test.ts +128 -0
- package/tests/http/sprint.routes.test.ts +72 -0
- package/tests/http/task.routes.test.ts +130 -0
- package/tests/http/test-app.ts +17 -0
- package/tests/infrastructure/drizzle-plan.repository.test.ts +62 -0
- package/tests/infrastructure/drizzle-sprint.repository.test.ts +49 -0
- package/tests/infrastructure/drizzle-task.repository.test.ts +132 -0
- package/tests/mcp/mcp-text-formatters.test.ts +246 -0
- package/tests/mcp/register-sprintdock-mcp-tools.test.ts +207 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: SQLite-backed SprintRepository using Drizzle ORM.
|
|
5
|
+
*/
|
|
6
|
+
import { and, asc, eq } from "drizzle-orm";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import type {
|
|
9
|
+
CreateSprintInput,
|
|
10
|
+
Sprint,
|
|
11
|
+
UpdateSprintInput
|
|
12
|
+
} from "../../../domain/entities/sprint.entity";
|
|
13
|
+
import type { SprintRepository } from "../../../domain/repositories/sprint.repository";
|
|
14
|
+
import { StorageError } from "../../../errors/backend-errors.js";
|
|
15
|
+
import { sprints } from "../../../db/schema/sprints";
|
|
16
|
+
import type { DrizzleSqliteDb } from "./sqlite-db";
|
|
17
|
+
import { toSprintEntity } from "./row-mappers";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Persists sprints with Drizzle SQLite.
|
|
21
|
+
*/
|
|
22
|
+
export class DrizzleSprintRepository implements SprintRepository {
|
|
23
|
+
public constructor(private readonly db: DrizzleSqliteDb) {}
|
|
24
|
+
|
|
25
|
+
/** @inheritdoc */
|
|
26
|
+
public async create(input: CreateSprintInput): Promise<Sprint> {
|
|
27
|
+
const id = randomUUID();
|
|
28
|
+
const now = new Date().toISOString();
|
|
29
|
+
let order: number;
|
|
30
|
+
if (input.order === undefined) {
|
|
31
|
+
const rows = this.db
|
|
32
|
+
.select()
|
|
33
|
+
.from(sprints)
|
|
34
|
+
.where(eq(sprints.planId, input.planId))
|
|
35
|
+
.all();
|
|
36
|
+
order =
|
|
37
|
+
rows.length === 0
|
|
38
|
+
? 0
|
|
39
|
+
: Math.max(...rows.map((r) => r.sprintOrder)) + 1;
|
|
40
|
+
} else {
|
|
41
|
+
order = input.order;
|
|
42
|
+
}
|
|
43
|
+
this.db
|
|
44
|
+
.insert(sprints)
|
|
45
|
+
.values({
|
|
46
|
+
id,
|
|
47
|
+
slug: input.slug,
|
|
48
|
+
planId: input.planId,
|
|
49
|
+
name: input.name,
|
|
50
|
+
goal: input.goal,
|
|
51
|
+
markdownContent: input.markdownContent ?? null,
|
|
52
|
+
status: "planned",
|
|
53
|
+
sprintOrder: order,
|
|
54
|
+
startDate: input.startDate ?? null,
|
|
55
|
+
endDate: input.endDate ?? null,
|
|
56
|
+
createdAt: now,
|
|
57
|
+
updatedAt: now
|
|
58
|
+
})
|
|
59
|
+
.run();
|
|
60
|
+
const created = this.db.select().from(sprints).where(eq(sprints.id, id)).get();
|
|
61
|
+
if (!created) {
|
|
62
|
+
throw new StorageError("Failed to read sprint after insert");
|
|
63
|
+
}
|
|
64
|
+
return toSprintEntity(created);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @inheritdoc */
|
|
68
|
+
public async findById(id: string): Promise<Sprint | null> {
|
|
69
|
+
const row = this.db.select().from(sprints).where(eq(sprints.id, id)).get();
|
|
70
|
+
return row ? toSprintEntity(row) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @inheritdoc */
|
|
74
|
+
public async findBySlug(
|
|
75
|
+
planId: string,
|
|
76
|
+
slug: string
|
|
77
|
+
): Promise<Sprint | null> {
|
|
78
|
+
const row = this.db
|
|
79
|
+
.select()
|
|
80
|
+
.from(sprints)
|
|
81
|
+
.where(and(eq(sprints.planId, planId), eq(sprints.slug, slug)))
|
|
82
|
+
.get();
|
|
83
|
+
return row ? toSprintEntity(row) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @inheritdoc */
|
|
87
|
+
public async listByPlanId(planId: string): Promise<Sprint[]> {
|
|
88
|
+
const rows = this.db
|
|
89
|
+
.select()
|
|
90
|
+
.from(sprints)
|
|
91
|
+
.where(eq(sprints.planId, planId))
|
|
92
|
+
.orderBy(asc(sprints.sprintOrder))
|
|
93
|
+
.all();
|
|
94
|
+
return rows.map(toSprintEntity);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @inheritdoc */
|
|
98
|
+
public async update(
|
|
99
|
+
id: string,
|
|
100
|
+
input: UpdateSprintInput
|
|
101
|
+
): Promise<Sprint | null> {
|
|
102
|
+
const existing = await this.findById(id);
|
|
103
|
+
if (!existing) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const now = new Date().toISOString();
|
|
107
|
+
this.db
|
|
108
|
+
.update(sprints)
|
|
109
|
+
.set({
|
|
110
|
+
name: input.name ?? existing.name,
|
|
111
|
+
goal: input.goal ?? existing.goal,
|
|
112
|
+
markdownContent:
|
|
113
|
+
input.markdownContent !== undefined
|
|
114
|
+
? input.markdownContent
|
|
115
|
+
: existing.markdownContent,
|
|
116
|
+
status: input.status ?? existing.status,
|
|
117
|
+
sprintOrder: input.order ?? existing.order,
|
|
118
|
+
startDate:
|
|
119
|
+
input.startDate !== undefined ? input.startDate : existing.startDate,
|
|
120
|
+
endDate: input.endDate !== undefined ? input.endDate : existing.endDate,
|
|
121
|
+
updatedAt: now
|
|
122
|
+
})
|
|
123
|
+
.where(eq(sprints.id, id))
|
|
124
|
+
.run();
|
|
125
|
+
return this.findById(id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @inheritdoc */
|
|
129
|
+
public async delete(id: string): Promise<boolean> {
|
|
130
|
+
const before = await this.findById(id);
|
|
131
|
+
if (!before) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
this.db.delete(sprints).where(eq(sprints.id, id)).run();
|
|
135
|
+
return (await this.findById(id)) === null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: SQLite-backed TaskRepository using Drizzle ORM.
|
|
5
|
+
*/
|
|
6
|
+
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import type {
|
|
9
|
+
CreateTaskInput,
|
|
10
|
+
Task,
|
|
11
|
+
TaskStatus,
|
|
12
|
+
TaskTouchedFile,
|
|
13
|
+
TaskTouchedFileInput,
|
|
14
|
+
UpdateTaskInput
|
|
15
|
+
} from "../../../domain/entities/task.entity";
|
|
16
|
+
import type {
|
|
17
|
+
TaskFilter,
|
|
18
|
+
TaskRepository
|
|
19
|
+
} from "../../../domain/repositories/task.repository";
|
|
20
|
+
import { StorageError, ValidationError } from "../../../errors/backend-errors.js";
|
|
21
|
+
import { sprints } from "../../../db/schema/sprints";
|
|
22
|
+
import { taskDependencies, taskTouchedFiles, tasks } from "../../../db/schema/tasks";
|
|
23
|
+
import type { DrizzleSqliteDb } from "./sqlite-db";
|
|
24
|
+
import {
|
|
25
|
+
toTaskDependencyEntity,
|
|
26
|
+
toTaskEntity,
|
|
27
|
+
toTaskTouchedFileEntity
|
|
28
|
+
} from "./row-mappers";
|
|
29
|
+
|
|
30
|
+
const TOUCHED_FILE_TYPES = new Set<string>([
|
|
31
|
+
"test",
|
|
32
|
+
"implementation",
|
|
33
|
+
"doc",
|
|
34
|
+
"config",
|
|
35
|
+
"other"
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function validateTouchedFileInputs(items: TaskTouchedFileInput[]): TaskTouchedFileInput[] {
|
|
39
|
+
const seenPaths = new Set<string>();
|
|
40
|
+
const out: TaskTouchedFileInput[] = [];
|
|
41
|
+
for (const raw of items) {
|
|
42
|
+
const path = raw.path.trim();
|
|
43
|
+
if (path.length === 0) {
|
|
44
|
+
throw new ValidationError("Touched file path cannot be empty");
|
|
45
|
+
}
|
|
46
|
+
const norm = path.replace(/\\/g, "/");
|
|
47
|
+
if (norm.split("/").some((s) => s === "..")) {
|
|
48
|
+
throw new ValidationError("Touched file path cannot contain '..'");
|
|
49
|
+
}
|
|
50
|
+
if (!TOUCHED_FILE_TYPES.has(raw.fileType)) {
|
|
51
|
+
throw new ValidationError(`Invalid touched file type: ${raw.fileType}`);
|
|
52
|
+
}
|
|
53
|
+
if (seenPaths.has(norm)) {
|
|
54
|
+
throw new ValidationError(`Duplicate touched path: ${path}`);
|
|
55
|
+
}
|
|
56
|
+
seenPaths.add(norm);
|
|
57
|
+
out.push({ path, fileType: raw.fileType });
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Persists tasks and dependency edges with Drizzle SQLite.
|
|
64
|
+
*/
|
|
65
|
+
export class DrizzleTaskRepository implements TaskRepository {
|
|
66
|
+
public constructor(private readonly db: DrizzleSqliteDb) {}
|
|
67
|
+
|
|
68
|
+
private replaceTouchedFiles(taskId: string, items: TaskTouchedFileInput[]): void {
|
|
69
|
+
this.db.transaction((tx) => {
|
|
70
|
+
tx
|
|
71
|
+
.delete(taskTouchedFiles)
|
|
72
|
+
.where(eq(taskTouchedFiles.taskId, taskId))
|
|
73
|
+
.run();
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
tx
|
|
76
|
+
.insert(taskTouchedFiles)
|
|
77
|
+
.values({
|
|
78
|
+
id: randomUUID(),
|
|
79
|
+
taskId,
|
|
80
|
+
path: item.path,
|
|
81
|
+
fileType: item.fileType
|
|
82
|
+
})
|
|
83
|
+
.run();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private touchedFilesByTaskIds(
|
|
89
|
+
taskIds: string[]
|
|
90
|
+
): Map<string, TaskTouchedFile[]> {
|
|
91
|
+
const map = new Map<string, TaskTouchedFile[]>();
|
|
92
|
+
for (const id of taskIds) {
|
|
93
|
+
map.set(id, []);
|
|
94
|
+
}
|
|
95
|
+
if (taskIds.length === 0) {
|
|
96
|
+
return map;
|
|
97
|
+
}
|
|
98
|
+
const rows = this.db
|
|
99
|
+
.select()
|
|
100
|
+
.from(taskTouchedFiles)
|
|
101
|
+
.where(inArray(taskTouchedFiles.taskId, taskIds))
|
|
102
|
+
.all();
|
|
103
|
+
for (const r of rows) {
|
|
104
|
+
const e = toTaskTouchedFileEntity(r);
|
|
105
|
+
const list = map.get(e.taskId);
|
|
106
|
+
if (list !== undefined) {
|
|
107
|
+
list.push(e);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const list of map.values()) {
|
|
111
|
+
list.sort((a, b) =>
|
|
112
|
+
a.path.localeCompare(b.path, undefined, { sensitivity: "base" })
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return map;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private attachTouchedFiles(taskList: Task[]): Task[] {
|
|
119
|
+
if (taskList.length === 0) {
|
|
120
|
+
return taskList;
|
|
121
|
+
}
|
|
122
|
+
const byId = this.touchedFilesByTaskIds(taskList.map((t) => t.id));
|
|
123
|
+
return taskList.map((t) => ({
|
|
124
|
+
...t,
|
|
125
|
+
touchedFiles: byId.get(t.id) ?? []
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @inheritdoc */
|
|
130
|
+
public async create(input: CreateTaskInput): Promise<Task> {
|
|
131
|
+
const id = randomUUID();
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
let order: number;
|
|
134
|
+
if (input.order === undefined) {
|
|
135
|
+
const rows = this.db
|
|
136
|
+
.select()
|
|
137
|
+
.from(tasks)
|
|
138
|
+
.where(eq(tasks.sprintId, input.sprintId))
|
|
139
|
+
.all();
|
|
140
|
+
order =
|
|
141
|
+
rows.length === 0
|
|
142
|
+
? 0
|
|
143
|
+
: Math.max(...rows.map((r) => r.taskOrder)) + 1;
|
|
144
|
+
} else {
|
|
145
|
+
order = input.order;
|
|
146
|
+
}
|
|
147
|
+
const clash = this.db
|
|
148
|
+
.select()
|
|
149
|
+
.from(tasks)
|
|
150
|
+
.where(
|
|
151
|
+
and(eq(tasks.sprintId, input.sprintId), eq(tasks.taskOrder, order))
|
|
152
|
+
)
|
|
153
|
+
.get();
|
|
154
|
+
if (clash) {
|
|
155
|
+
throw new ValidationError(`Duplicate task order in sprint: ${order}`);
|
|
156
|
+
}
|
|
157
|
+
this.db
|
|
158
|
+
.insert(tasks)
|
|
159
|
+
.values({
|
|
160
|
+
id,
|
|
161
|
+
sprintId: input.sprintId,
|
|
162
|
+
title: input.title,
|
|
163
|
+
description: input.description ?? null,
|
|
164
|
+
status: "todo",
|
|
165
|
+
priority: input.priority,
|
|
166
|
+
taskOrder: order,
|
|
167
|
+
assignee: input.assignee ?? null,
|
|
168
|
+
tags: input.tags ?? null,
|
|
169
|
+
createdAt: now,
|
|
170
|
+
updatedAt: now
|
|
171
|
+
})
|
|
172
|
+
.run();
|
|
173
|
+
const created = this.db.select().from(tasks).where(eq(tasks.id, id)).get();
|
|
174
|
+
if (!created) {
|
|
175
|
+
throw new StorageError("Failed to read task after insert");
|
|
176
|
+
}
|
|
177
|
+
if (input.dependsOnTaskIds?.length) {
|
|
178
|
+
await this.setDependencies(id, [...new Set(input.dependsOnTaskIds)]);
|
|
179
|
+
}
|
|
180
|
+
const finalRow = this.db.select().from(tasks).where(eq(tasks.id, id)).get();
|
|
181
|
+
if (!finalRow) {
|
|
182
|
+
throw new StorageError("Failed to read task after dependency write");
|
|
183
|
+
}
|
|
184
|
+
if (input.touchedFiles !== undefined) {
|
|
185
|
+
this.replaceTouchedFiles(id, validateTouchedFileInputs(input.touchedFiles));
|
|
186
|
+
}
|
|
187
|
+
const hydrated = this.attachTouchedFiles([toTaskEntity(finalRow)])[0];
|
|
188
|
+
if (!hydrated) {
|
|
189
|
+
throw new StorageError("Failed to hydrate task after create");
|
|
190
|
+
}
|
|
191
|
+
return hydrated;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @inheritdoc */
|
|
195
|
+
public async findById(id: string): Promise<Task | null> {
|
|
196
|
+
const row = this.db.select().from(tasks).where(eq(tasks.id, id)).get();
|
|
197
|
+
if (!row) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return this.attachTouchedFiles([toTaskEntity(row)])[0] ?? null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @inheritdoc */
|
|
204
|
+
public async listBySprintId(
|
|
205
|
+
sprintId: string,
|
|
206
|
+
filter?: TaskFilter
|
|
207
|
+
): Promise<Task[]> {
|
|
208
|
+
const conditions = [eq(tasks.sprintId, sprintId)];
|
|
209
|
+
if (filter?.status) {
|
|
210
|
+
conditions.push(eq(tasks.status, filter.status));
|
|
211
|
+
}
|
|
212
|
+
if (filter?.priority) {
|
|
213
|
+
conditions.push(eq(tasks.priority, filter.priority));
|
|
214
|
+
}
|
|
215
|
+
if (filter?.assignee !== undefined) {
|
|
216
|
+
conditions.push(eq(tasks.assignee, filter.assignee));
|
|
217
|
+
}
|
|
218
|
+
const rows = this.db
|
|
219
|
+
.select()
|
|
220
|
+
.from(tasks)
|
|
221
|
+
.where(and(...conditions))
|
|
222
|
+
.orderBy(asc(tasks.taskOrder))
|
|
223
|
+
.all();
|
|
224
|
+
return this.attachTouchedFiles(rows.map(toTaskEntity));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @inheritdoc */
|
|
228
|
+
public async listByPlanId(
|
|
229
|
+
planId: string,
|
|
230
|
+
filter?: TaskFilter
|
|
231
|
+
): Promise<Task[]> {
|
|
232
|
+
const conditions = [eq(sprints.planId, planId)];
|
|
233
|
+
if (filter?.status) {
|
|
234
|
+
conditions.push(eq(tasks.status, filter.status));
|
|
235
|
+
}
|
|
236
|
+
if (filter?.priority) {
|
|
237
|
+
conditions.push(eq(tasks.priority, filter.priority));
|
|
238
|
+
}
|
|
239
|
+
if (filter?.assignee !== undefined) {
|
|
240
|
+
conditions.push(eq(tasks.assignee, filter.assignee));
|
|
241
|
+
}
|
|
242
|
+
const rows = this.db
|
|
243
|
+
.select()
|
|
244
|
+
.from(tasks)
|
|
245
|
+
.innerJoin(sprints, eq(tasks.sprintId, sprints.id))
|
|
246
|
+
.where(and(...conditions))
|
|
247
|
+
.orderBy(asc(tasks.taskOrder))
|
|
248
|
+
.all();
|
|
249
|
+
return this.attachTouchedFiles(rows.map((row) => toTaskEntity(row.tasks)));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @inheritdoc */
|
|
253
|
+
public async update(
|
|
254
|
+
id: string,
|
|
255
|
+
input: UpdateTaskInput
|
|
256
|
+
): Promise<Task | null> {
|
|
257
|
+
const existing = await this.findById(id);
|
|
258
|
+
if (!existing) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const now = new Date().toISOString();
|
|
262
|
+
if (input.order !== undefined && input.order !== existing.order) {
|
|
263
|
+
const clash = this.db
|
|
264
|
+
.select()
|
|
265
|
+
.from(tasks)
|
|
266
|
+
.where(
|
|
267
|
+
and(
|
|
268
|
+
eq(tasks.sprintId, existing.sprintId),
|
|
269
|
+
eq(tasks.taskOrder, input.order)
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
.get();
|
|
273
|
+
if (clash && clash.id !== id) {
|
|
274
|
+
throw new ValidationError(
|
|
275
|
+
`Duplicate task order in sprint: ${input.order}`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
this.db
|
|
280
|
+
.update(tasks)
|
|
281
|
+
.set({
|
|
282
|
+
title: input.title ?? existing.title,
|
|
283
|
+
description:
|
|
284
|
+
input.description !== undefined
|
|
285
|
+
? input.description
|
|
286
|
+
: existing.description,
|
|
287
|
+
status: input.status ?? existing.status,
|
|
288
|
+
priority: input.priority ?? existing.priority,
|
|
289
|
+
taskOrder: input.order ?? existing.order,
|
|
290
|
+
assignee:
|
|
291
|
+
input.assignee !== undefined ? input.assignee : existing.assignee,
|
|
292
|
+
tags: input.tags !== undefined ? input.tags : existing.tags,
|
|
293
|
+
updatedAt: now
|
|
294
|
+
})
|
|
295
|
+
.where(eq(tasks.id, id))
|
|
296
|
+
.run();
|
|
297
|
+
if (input.touchedFiles !== undefined) {
|
|
298
|
+
this.replaceTouchedFiles(id, validateTouchedFileInputs(input.touchedFiles));
|
|
299
|
+
}
|
|
300
|
+
return this.findById(id);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @inheritdoc */
|
|
304
|
+
public async move(
|
|
305
|
+
taskId: string,
|
|
306
|
+
targetSprintId: string
|
|
307
|
+
): Promise<Task | null> {
|
|
308
|
+
const existing = await this.findById(taskId);
|
|
309
|
+
if (!existing) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
this.db.transaction((tx) => {
|
|
313
|
+
const rows = tx
|
|
314
|
+
.select()
|
|
315
|
+
.from(tasks)
|
|
316
|
+
.where(eq(tasks.sprintId, targetSprintId))
|
|
317
|
+
.all();
|
|
318
|
+
const nextOrder =
|
|
319
|
+
rows.length === 0
|
|
320
|
+
? 0
|
|
321
|
+
: Math.max(...rows.map((r) => r.taskOrder)) + 1;
|
|
322
|
+
const now = new Date().toISOString();
|
|
323
|
+
tx
|
|
324
|
+
.update(tasks)
|
|
325
|
+
.set({
|
|
326
|
+
sprintId: targetSprintId,
|
|
327
|
+
taskOrder: nextOrder,
|
|
328
|
+
updatedAt: now
|
|
329
|
+
})
|
|
330
|
+
.where(eq(tasks.id, taskId))
|
|
331
|
+
.run();
|
|
332
|
+
});
|
|
333
|
+
return this.findById(taskId);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** @inheritdoc */
|
|
337
|
+
public async delete(id: string): Promise<boolean> {
|
|
338
|
+
const before = await this.findById(id);
|
|
339
|
+
if (!before) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
this.db.delete(tasks).where(eq(tasks.id, id)).run();
|
|
343
|
+
return (await this.findById(id)) === null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** @inheritdoc */
|
|
347
|
+
public async getDependencies(taskId: string) {
|
|
348
|
+
const rows = this.db
|
|
349
|
+
.select()
|
|
350
|
+
.from(taskDependencies)
|
|
351
|
+
.where(eq(taskDependencies.taskId, taskId))
|
|
352
|
+
.all();
|
|
353
|
+
return rows.map(toTaskDependencyEntity);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** @inheritdoc */
|
|
357
|
+
public async listDependentTaskIds(dependsOnTaskId: string): Promise<string[]> {
|
|
358
|
+
const rows = this.db
|
|
359
|
+
.select()
|
|
360
|
+
.from(taskDependencies)
|
|
361
|
+
.where(eq(taskDependencies.dependsOnTaskId, dependsOnTaskId))
|
|
362
|
+
.all();
|
|
363
|
+
return rows.map((r) => r.taskId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** @inheritdoc */
|
|
367
|
+
public async setDependencies(
|
|
368
|
+
taskId: string,
|
|
369
|
+
dependsOnTaskIds: string[]
|
|
370
|
+
): Promise<void> {
|
|
371
|
+
const unique = [...new Set(dependsOnTaskIds)];
|
|
372
|
+
this.db.transaction((tx) => {
|
|
373
|
+
tx
|
|
374
|
+
.delete(taskDependencies)
|
|
375
|
+
.where(eq(taskDependencies.taskId, taskId))
|
|
376
|
+
.run();
|
|
377
|
+
for (const dependsOnTaskId of unique) {
|
|
378
|
+
tx
|
|
379
|
+
.insert(taskDependencies)
|
|
380
|
+
.values({ taskId, dependsOnTaskId })
|
|
381
|
+
.run();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** @inheritdoc */
|
|
387
|
+
public async countByStatusGlobally(): Promise<Record<TaskStatus, number>> {
|
|
388
|
+
const rows = this.db.select().from(tasks).all();
|
|
389
|
+
const out: Record<TaskStatus, number> = {
|
|
390
|
+
todo: 0,
|
|
391
|
+
in_progress: 0,
|
|
392
|
+
blocked: 0,
|
|
393
|
+
done: 0
|
|
394
|
+
};
|
|
395
|
+
for (const r of rows) {
|
|
396
|
+
const s = r.status as TaskStatus;
|
|
397
|
+
if (s in out) {
|
|
398
|
+
out[s] += 1;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Drizzle SQLite repository adapter exports.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DrizzlePlanRepository } from "./drizzle-plan.repository";
|
|
8
|
+
export { DrizzleSprintRepository } from "./drizzle-sprint.repository";
|
|
9
|
+
export { DrizzleTaskRepository } from "./drizzle-task.repository";
|
|
10
|
+
export type { DrizzleSqliteDb } from "./sqlite-db";
|
|
11
|
+
export {
|
|
12
|
+
toPlanEntity,
|
|
13
|
+
toSprintEntity,
|
|
14
|
+
toTaskDependencyEntity,
|
|
15
|
+
toTaskEntity
|
|
16
|
+
} from "./row-mappers";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Maps Drizzle SQLite rows to domain entities (single ORM boundary).
|
|
5
|
+
*/
|
|
6
|
+
import type { InferSelectModel } from "drizzle-orm";
|
|
7
|
+
import type { Plan } from "../../../domain/entities/plan.entity";
|
|
8
|
+
import type { Sprint } from "../../../domain/entities/sprint.entity";
|
|
9
|
+
import type {
|
|
10
|
+
Task,
|
|
11
|
+
TaskDependency,
|
|
12
|
+
TaskTouchedFile
|
|
13
|
+
} from "../../../domain/entities/task.entity";
|
|
14
|
+
import { plans } from "../../../db/schema/plans";
|
|
15
|
+
import { sprints } from "../../../db/schema/sprints";
|
|
16
|
+
import { taskDependencies, taskTouchedFiles, tasks } from "../../../db/schema/tasks";
|
|
17
|
+
|
|
18
|
+
type PlanRow = InferSelectModel<typeof plans>;
|
|
19
|
+
type SprintRow = InferSelectModel<typeof sprints>;
|
|
20
|
+
type TaskRow = InferSelectModel<typeof tasks>;
|
|
21
|
+
type TaskDependencyRow = InferSelectModel<typeof taskDependencies>;
|
|
22
|
+
type TaskTouchedFileRow = InferSelectModel<typeof taskTouchedFiles>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param row Persisted plan row.
|
|
26
|
+
* @returns Domain plan entity.
|
|
27
|
+
*/
|
|
28
|
+
export function toPlanEntity(row: PlanRow): Plan {
|
|
29
|
+
return {
|
|
30
|
+
id: row.id,
|
|
31
|
+
slug: row.slug,
|
|
32
|
+
title: row.title,
|
|
33
|
+
description: row.description ?? null,
|
|
34
|
+
markdownContent: row.markdownContent ?? null,
|
|
35
|
+
status: row.status as Plan["status"],
|
|
36
|
+
isActive: row.isActive,
|
|
37
|
+
createdAt: row.createdAt,
|
|
38
|
+
updatedAt: row.updatedAt
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param row Persisted sprint row.
|
|
44
|
+
* @returns Domain sprint entity.
|
|
45
|
+
*/
|
|
46
|
+
export function toSprintEntity(row: SprintRow): Sprint {
|
|
47
|
+
return {
|
|
48
|
+
id: row.id,
|
|
49
|
+
slug: row.slug,
|
|
50
|
+
planId: row.planId,
|
|
51
|
+
name: row.name,
|
|
52
|
+
goal: row.goal,
|
|
53
|
+
markdownContent: row.markdownContent ?? null,
|
|
54
|
+
status: row.status as Sprint["status"],
|
|
55
|
+
order: row.sprintOrder,
|
|
56
|
+
startDate: row.startDate ?? null,
|
|
57
|
+
endDate: row.endDate ?? null,
|
|
58
|
+
createdAt: row.createdAt,
|
|
59
|
+
updatedAt: row.updatedAt
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param row Persisted task row.
|
|
65
|
+
* @returns Domain task entity.
|
|
66
|
+
*/
|
|
67
|
+
export function toTaskEntity(row: TaskRow): Task {
|
|
68
|
+
return {
|
|
69
|
+
id: row.id,
|
|
70
|
+
sprintId: row.sprintId,
|
|
71
|
+
title: row.title,
|
|
72
|
+
description: row.description ?? null,
|
|
73
|
+
status: row.status as Task["status"],
|
|
74
|
+
priority: row.priority as Task["priority"],
|
|
75
|
+
order: row.taskOrder,
|
|
76
|
+
assignee: row.assignee ?? null,
|
|
77
|
+
tags: row.tags ?? null,
|
|
78
|
+
touchedFiles: [],
|
|
79
|
+
createdAt: row.createdAt,
|
|
80
|
+
updatedAt: row.updatedAt
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param row Persisted task_touched_files row.
|
|
86
|
+
* @returns Domain touched-file entity.
|
|
87
|
+
*/
|
|
88
|
+
export function toTaskTouchedFileEntity(row: TaskTouchedFileRow): TaskTouchedFile {
|
|
89
|
+
return {
|
|
90
|
+
id: row.id,
|
|
91
|
+
taskId: row.taskId,
|
|
92
|
+
path: row.path,
|
|
93
|
+
fileType: row.fileType as TaskTouchedFile["fileType"]
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param row Persisted task_dependencies row.
|
|
99
|
+
* @returns Domain dependency edge.
|
|
100
|
+
*/
|
|
101
|
+
export function toTaskDependencyEntity(row: TaskDependencyRow): TaskDependency {
|
|
102
|
+
return {
|
|
103
|
+
taskId: row.taskId,
|
|
104
|
+
dependsOnTaskId: row.dependsOnTaskId
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Shared Drizzle SQLite client type for better-sqlite3 and sql.js runtimes.
|
|
5
|
+
*/
|
|
6
|
+
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
|
7
|
+
import type { SQLJsDatabase } from "drizzle-orm/sql-js";
|
|
8
|
+
import type * as schema from "../../../db/schema/index";
|
|
9
|
+
|
|
10
|
+
/** Drizzle SQLite database used by repository adapters (file DB or in-memory tests). */
|
|
11
|
+
export type DrizzleSqliteDb =
|
|
12
|
+
| BetterSQLite3Database<typeof schema>
|
|
13
|
+
| SQLJsDatabase<typeof schema>;
|