@x4lt7ab/tab-for-projects 0.1.0 → 0.1.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.
- package/package.json +6 -5
- package/src/domain/args.ts +11 -1
- package/src/domain/bootstrap.ts +12 -4
- package/src/domain/db/schema.ts +28 -7
- package/src/domain/entities.ts +8 -0
- package/src/domain/events.ts +26 -0
- package/src/domain/index.ts +3 -0
- package/src/domain/inputs.ts +2 -2
- package/src/domain/repositories/projects.ts +16 -3
- package/src/domain/repositories/tags.ts +77 -0
- package/src/domain/repositories/tasks.ts +69 -27
- package/src/domain/services/projects.ts +22 -7
- package/src/domain/services/tags.ts +85 -0
- package/src/domain/services/tasks.ts +50 -9
- package/src/domain/services.ts +31 -4
- package/src/mcp/index.ts +1 -1
- package/src/mcp/server.ts +175 -31
- package/src/mcp/standalone.ts +10 -7
- package/src/server/index.ts +44 -10
- package/src/server/routes/projects.ts +16 -5
- package/src/server/routes/tags.ts +61 -0
- package/src/server/routes/tasks.ts +86 -9
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc-DqGufNeO.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7-Dx4kXJAl.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc-CkhJZR-_.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc-DO1Apj_S.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc-BOeWTOD4.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc-DlzME5K_.woff2 +0 -0
- package/src/web/dist/assets/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc-CBcvBZtf.woff2 +0 -0
- package/src/web/dist/assets/index-aUW2zejq.js +49 -0
- package/src/web/dist/assets/kJEPBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJO1Q-CpotRDAj.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggOxSuXd-Dvxsihut.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggSxSuXd-DL7QRZyv.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggexSg-DHIcAJRg.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggixSuXd-usUDDRr7.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggmxSuXd-Ch3YOpNY.woff2 +0 -0
- package/src/web/dist/assets/xn7gYHE41ni1AdIRggqxSuXd-C8S-KRRz.woff2 +0 -0
- package/src/web/dist/index.html +2 -11
- package/src/web/src/App.tsx +222 -22
- package/src/web/src/components/TopBar.tsx +4 -2
- package/src/web/src/useRealtimeEvents.ts +62 -0
- package/src/web/vite.config.ts +6 -1
- package/src/web/dist/assets/index-Bonqd4_2.js +0 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x4lt7ab/tab-for-projects",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tab-for-projects": "./src/index.ts"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"bun": ">=1.3"
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
|
-
"dev": "bun run --
|
|
15
|
+
"dev": "concurrently --kill-others --names 'api,web' \"bun run --watch src/index.ts\" \"cd src/web && vite\"",
|
|
16
16
|
"build": "cd src/web && vite build",
|
|
17
17
|
"start": "bun run src/index.ts",
|
|
18
18
|
"serve": "bun run build && bun run start",
|
|
@@ -20,8 +20,7 @@
|
|
|
20
20
|
"prepublishOnly": "bun run build"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@modelcontextprotocol/sdk": "
|
|
24
|
-
"@x4lt7ab/tab-for-projects": "/Users/alttab/AltT4b/project-management",
|
|
23
|
+
"@modelcontextprotocol/sdk": "1.27.1",
|
|
25
24
|
"hono": "^4",
|
|
26
25
|
"ulid": "^2",
|
|
27
26
|
"zod": "^4.3.6"
|
|
@@ -31,9 +30,11 @@
|
|
|
31
30
|
"@types/react": "^19.1.2",
|
|
32
31
|
"@types/react-dom": "^19.1.2",
|
|
33
32
|
"@vitejs/plugin-react": "^4.4.1",
|
|
33
|
+
"concurrently": "^9.2.1",
|
|
34
34
|
"react": "^19.1.0",
|
|
35
35
|
"react-dom": "^19.1.0",
|
|
36
36
|
"typescript": "^5.8.3",
|
|
37
|
-
"vite": "^6.3.5"
|
|
37
|
+
"vite": "^6.3.5",
|
|
38
|
+
"vite-plugin-webfont-dl": "^3"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/domain/args.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface ServerOptions {
|
|
|
9
9
|
export function parseArgs(defaults: { port: number; portEnv: string }): ServerOptions {
|
|
10
10
|
const args = process.argv.slice(2);
|
|
11
11
|
let port = Number(process.env[defaults.portEnv]) || defaults.port;
|
|
12
|
-
let host = process.env.PM_HOST ?? "
|
|
12
|
+
let host = process.env.PM_HOST ?? "0.0.0.0";
|
|
13
13
|
let dbPath: string | undefined = process.env.SQLITE_PATH;
|
|
14
14
|
|
|
15
15
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -29,6 +29,16 @@ export function getNetworkAddress(): string | undefined {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export function parseCorsOrigins(envValue?: string): (origin: string) => boolean {
|
|
33
|
+
if (!envValue) {
|
|
34
|
+
return (origin) =>
|
|
35
|
+
origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1");
|
|
36
|
+
}
|
|
37
|
+
if (envValue.trim() === "*") return () => true;
|
|
38
|
+
const allowed = new Set(envValue.split(",").map(s => s.trim()).filter(Boolean));
|
|
39
|
+
return (origin) => allowed.has(origin);
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
export function logListening(name: string, host: string, port: number): void {
|
|
33
43
|
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
34
44
|
console.error(`${name} listening on http://${displayHost}:${port}`);
|
package/src/domain/bootstrap.ts
CHANGED
|
@@ -3,24 +3,32 @@ import { createDatabase } from "./db/connection";
|
|
|
3
3
|
import { runMigrations } from "./db/schema";
|
|
4
4
|
import { ProjectRepository } from "./repositories/projects";
|
|
5
5
|
import { TaskRepository } from "./repositories/tasks";
|
|
6
|
+
import { TagRepository } from "./repositories/tags";
|
|
6
7
|
import { ProjectService } from "./services/projects";
|
|
7
8
|
import { TaskService } from "./services/tasks";
|
|
8
|
-
import
|
|
9
|
+
import { TagService } from "./services/tags";
|
|
10
|
+
import type { IProjectService, ITaskService, ITagService } from "./services";
|
|
11
|
+
import { EventBus } from "./events";
|
|
9
12
|
|
|
10
13
|
export interface AppContext {
|
|
11
14
|
db: Database;
|
|
12
15
|
projectService: IProjectService;
|
|
13
16
|
taskService: ITaskService;
|
|
17
|
+
tagService: ITagService;
|
|
18
|
+
eventBus: EventBus;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export function bootstrap(dbPath?: string): AppContext {
|
|
17
22
|
const db = createDatabase(dbPath);
|
|
18
23
|
runMigrations(db);
|
|
19
24
|
|
|
25
|
+
const eventBus = new EventBus();
|
|
20
26
|
const projectRepo = new ProjectRepository(db);
|
|
21
27
|
const taskRepo = new TaskRepository(db);
|
|
22
|
-
const
|
|
23
|
-
const
|
|
28
|
+
const tagRepo = new TagRepository(db);
|
|
29
|
+
const projectService = new ProjectService(projectRepo, eventBus);
|
|
30
|
+
const taskService = new TaskService(taskRepo, projectRepo, eventBus);
|
|
31
|
+
const tagService = new TagService(tagRepo, taskRepo);
|
|
24
32
|
|
|
25
|
-
return { db, projectService, taskService };
|
|
33
|
+
return { db, projectService, taskService, tagService, eventBus };
|
|
26
34
|
}
|
package/src/domain/db/schema.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
|
-
import { PROJECT_STATUSES, TASK_STATUSES } from "../statuses";
|
|
3
|
-
|
|
4
|
-
const projectStatusCheck = PROJECT_STATUSES.map((s) => `'${s}'`).join(", ");
|
|
5
|
-
const taskStatusCheck = TASK_STATUSES.map((s) => `'${s}'`).join(", ");
|
|
6
2
|
|
|
7
3
|
export function runMigrations(db: Database): void {
|
|
8
4
|
db.run(`
|
|
@@ -12,7 +8,7 @@ export function runMigrations(db: Database): void {
|
|
|
12
8
|
name TEXT NOT NULL,
|
|
13
9
|
description TEXT NOT NULL DEFAULT '',
|
|
14
10
|
status TEXT NOT NULL DEFAULT 'active'
|
|
15
|
-
CHECK (status IN (
|
|
11
|
+
CHECK (status IN ('active', 'paused', 'completed', 'archived')),
|
|
16
12
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
17
13
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
18
14
|
)
|
|
@@ -24,12 +20,37 @@ export function runMigrations(db: Database): void {
|
|
|
24
20
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
25
21
|
id TEXT PRIMARY KEY,
|
|
26
22
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
23
|
+
number INTEGER NOT NULL,
|
|
27
24
|
title TEXT NOT NULL,
|
|
28
25
|
description TEXT NOT NULL DEFAULT '',
|
|
29
26
|
status TEXT NOT NULL DEFAULT 'todo'
|
|
30
|
-
CHECK (status IN (
|
|
27
|
+
CHECK (status IN ('todo', 'in_progress', 'done')),
|
|
28
|
+
priority INTEGER CHECK (priority BETWEEN 1 AND 10),
|
|
31
29
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
32
|
-
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
30
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
31
|
+
UNIQUE(project_id, number)
|
|
32
|
+
)
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id)");
|
|
36
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_tasks_project_number ON tasks(project_id, number)");
|
|
37
|
+
|
|
38
|
+
// ── Tags ──────────────────────────────────────────────────
|
|
39
|
+
db.run(`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
name TEXT NOT NULL UNIQUE,
|
|
43
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
44
|
+
)
|
|
45
|
+
`);
|
|
46
|
+
|
|
47
|
+
db.run(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS task_tags (
|
|
49
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
50
|
+
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
51
|
+
PRIMARY KEY (task_id, tag_id)
|
|
33
52
|
)
|
|
34
53
|
`);
|
|
54
|
+
|
|
55
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_task_tags_tag_id ON task_tags(tag_id)");
|
|
35
56
|
}
|
package/src/domain/entities.ts
CHANGED
|
@@ -13,9 +13,17 @@ export interface Project {
|
|
|
13
13
|
export interface Task {
|
|
14
14
|
id: string;
|
|
15
15
|
project_id: string;
|
|
16
|
+
number: number;
|
|
16
17
|
title: string;
|
|
17
18
|
description: string;
|
|
18
19
|
status: TaskStatus;
|
|
20
|
+
priority: number | null;
|
|
19
21
|
created_at: string;
|
|
20
22
|
updated_at: string;
|
|
21
23
|
}
|
|
24
|
+
|
|
25
|
+
export interface Tag {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
created_at: string;
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Project, Task } from "./entities";
|
|
2
|
+
|
|
3
|
+
export type DomainEvent =
|
|
4
|
+
| { entity: "project"; action: "created" | "updated" | "deleted"; payload: Project | { id: string } }
|
|
5
|
+
| { entity: "task"; action: "created" | "updated" | "deleted"; payload: Task | { id: string } };
|
|
6
|
+
|
|
7
|
+
type Listener = (event: DomainEvent) => void;
|
|
8
|
+
|
|
9
|
+
export class EventBus {
|
|
10
|
+
private listeners = new Set<Listener>();
|
|
11
|
+
|
|
12
|
+
subscribe(fn: Listener): () => void {
|
|
13
|
+
this.listeners.add(fn);
|
|
14
|
+
return () => this.listeners.delete(fn);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
emit(event: DomainEvent): void {
|
|
18
|
+
for (const fn of this.listeners) {
|
|
19
|
+
try {
|
|
20
|
+
fn(event);
|
|
21
|
+
} catch {
|
|
22
|
+
// listener errors must not break the emitter
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/domain/index.ts
CHANGED
|
@@ -3,10 +3,13 @@ export * from "./entities";
|
|
|
3
3
|
export * from "./inputs";
|
|
4
4
|
export * from "./errors";
|
|
5
5
|
export * from "./services";
|
|
6
|
+
export * from "./events";
|
|
6
7
|
export * from "./bootstrap";
|
|
7
8
|
export { createDatabase, getDbPath } from "./db/connection";
|
|
8
9
|
export { runMigrations } from "./db/schema";
|
|
9
10
|
export { ProjectRepository } from "./repositories/projects";
|
|
10
11
|
export { TaskRepository } from "./repositories/tasks";
|
|
12
|
+
export { TagRepository } from "./repositories/tags";
|
|
11
13
|
export { ProjectService } from "./services/projects";
|
|
12
14
|
export { TaskService } from "./services/tasks";
|
|
15
|
+
export { TagService } from "./services/tags";
|
package/src/domain/inputs.ts
CHANGED
|
@@ -8,6 +8,6 @@ export type UpdateProjectInput = Partial<
|
|
|
8
8
|
>;
|
|
9
9
|
|
|
10
10
|
export type CreateTaskInput = Pick<Task, "title"> &
|
|
11
|
-
Partial<Pick<Task, "description" | "status">>;
|
|
11
|
+
Partial<Pick<Task, "description" | "status" | "priority">>;
|
|
12
12
|
|
|
13
|
-
export type UpdateTaskInput = Partial<Pick<Task, "title" | "description" | "status">>;
|
|
13
|
+
export type UpdateTaskInput = Partial<Pick<Task, "title" | "description" | "status" | "priority">>;
|
|
@@ -2,14 +2,27 @@ import type { Database } from "bun:sqlite";
|
|
|
2
2
|
import { ulid } from "ulid";
|
|
3
3
|
import type { Project } from "../entities";
|
|
4
4
|
import type { CreateProjectInput, UpdateProjectInput } from "../inputs";
|
|
5
|
+
import type { ProjectFilter } from "../services";
|
|
5
6
|
|
|
6
7
|
export class ProjectRepository {
|
|
7
8
|
constructor(private db: Database) {}
|
|
8
9
|
|
|
9
|
-
findAll(): Project[] {
|
|
10
|
+
findAll(limit: number, offset: number, filter?: ProjectFilter): Project[] {
|
|
11
|
+
if (filter?.status) {
|
|
12
|
+
return this.db
|
|
13
|
+
.query("SELECT * FROM projects WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?")
|
|
14
|
+
.all(filter.status, limit, offset) as Project[];
|
|
15
|
+
}
|
|
10
16
|
return this.db
|
|
11
|
-
.query("SELECT * FROM projects ORDER BY created_at DESC")
|
|
12
|
-
.all() as Project[];
|
|
17
|
+
.query("SELECT * FROM projects ORDER BY created_at DESC LIMIT ? OFFSET ?")
|
|
18
|
+
.all(limit, offset) as Project[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
count(filter?: ProjectFilter): number {
|
|
22
|
+
if (filter?.status) {
|
|
23
|
+
return (this.db.query("SELECT COUNT(*) as total FROM projects WHERE status = ?").get(filter.status) as { total: number }).total;
|
|
24
|
+
}
|
|
25
|
+
return (this.db.query("SELECT COUNT(*) as total FROM projects").get() as { total: number }).total;
|
|
13
26
|
}
|
|
14
27
|
|
|
15
28
|
findById(id: string): Project | null {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { ulid } from "ulid";
|
|
3
|
+
import type { Tag, Task } from "../entities";
|
|
4
|
+
|
|
5
|
+
export class TagRepository {
|
|
6
|
+
constructor(private db: Database) {}
|
|
7
|
+
|
|
8
|
+
findAll(limit: number, offset: number): Tag[] {
|
|
9
|
+
return this.db
|
|
10
|
+
.query("SELECT * FROM tags ORDER BY name ASC LIMIT ? OFFSET ?")
|
|
11
|
+
.all(limit, offset) as Tag[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
count(): number {
|
|
15
|
+
return (this.db.query("SELECT COUNT(*) as total FROM tags").get() as { total: number }).total;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
findById(id: string): Tag | null {
|
|
19
|
+
return this.db.query("SELECT * FROM tags WHERE id = ?").get(id) as Tag | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
findByName(name: string): Tag | null {
|
|
23
|
+
return this.db.query("SELECT * FROM tags WHERE name = ?").get(name) as Tag | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
create(name: string): Tag {
|
|
27
|
+
const id = ulid();
|
|
28
|
+
this.db.query("INSERT INTO tags (id, name) VALUES (?, ?)").run(id, name);
|
|
29
|
+
return this.db.query("SELECT * FROM tags WHERE id = ?").get(id) as Tag;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
delete(id: string): boolean {
|
|
33
|
+
const result = this.db.query("DELETE FROM tags WHERE id = ?").run(id);
|
|
34
|
+
return result.changes > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
addTagToTask(taskId: string, tagId: string): void {
|
|
38
|
+
this.db
|
|
39
|
+
.query("INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (?, ?)")
|
|
40
|
+
.run(taskId, tagId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
removeTagFromTask(taskId: string, tagId: string): boolean {
|
|
44
|
+
const result = this.db
|
|
45
|
+
.query("DELETE FROM task_tags WHERE task_id = ? AND tag_id = ?")
|
|
46
|
+
.run(taskId, tagId);
|
|
47
|
+
return result.changes > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getTagsForTask(taskId: string): Tag[] {
|
|
51
|
+
return this.db
|
|
52
|
+
.query(
|
|
53
|
+
`SELECT t.* FROM tags t
|
|
54
|
+
JOIN task_tags tt ON tt.tag_id = t.id
|
|
55
|
+
WHERE tt.task_id = ?
|
|
56
|
+
ORDER BY t.name ASC`
|
|
57
|
+
)
|
|
58
|
+
.all(taskId) as Tag[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
findTasksByTag(tagId: string, limit: number, offset: number): Task[] {
|
|
62
|
+
return this.db
|
|
63
|
+
.query(
|
|
64
|
+
`SELECT t.* FROM tasks t
|
|
65
|
+
JOIN task_tags tt ON tt.task_id = t.id
|
|
66
|
+
WHERE tt.tag_id = ?
|
|
67
|
+
ORDER BY t.created_at ASC LIMIT ? OFFSET ?`
|
|
68
|
+
)
|
|
69
|
+
.all(tagId, limit, offset) as Task[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
countTasksByTag(tagId: string): number {
|
|
73
|
+
return (this.db.query(
|
|
74
|
+
"SELECT COUNT(*) as total FROM task_tags WHERE tag_id = ?"
|
|
75
|
+
).get(tagId) as { total: number }).total;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -1,62 +1,104 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
2
|
import { ulid } from "ulid";
|
|
3
3
|
import type { Task } from "../entities";
|
|
4
|
-
import type { CreateTaskInput } from "../inputs";
|
|
5
|
-
import type {
|
|
4
|
+
import type { CreateTaskInput, UpdateTaskInput } from "../inputs";
|
|
5
|
+
import type { TaskFilter } from "../services";
|
|
6
6
|
|
|
7
7
|
export class TaskRepository {
|
|
8
8
|
constructor(private db: Database) {}
|
|
9
9
|
|
|
10
|
-
findByProject(projectId: string): Task[] {
|
|
10
|
+
findByProject(projectId: string, limit: number, offset: number, filter?: TaskFilter): Task[] {
|
|
11
|
+
if (filter?.tag) {
|
|
12
|
+
const statusClause = filter.status ? "AND t.status = ?" : "";
|
|
13
|
+
return this.db
|
|
14
|
+
.query(
|
|
15
|
+
`SELECT t.* FROM tasks t
|
|
16
|
+
JOIN task_tags tt ON tt.task_id = t.id
|
|
17
|
+
JOIN tags tg ON tg.id = tt.tag_id
|
|
18
|
+
WHERE tg.name = ? AND t.project_id = ? ${statusClause}
|
|
19
|
+
ORDER BY t.created_at ASC LIMIT ? OFFSET ?`
|
|
20
|
+
)
|
|
21
|
+
.all(filter.tag, projectId, ...(filter.status ? [filter.status] : []), limit, offset) as Task[];
|
|
22
|
+
}
|
|
23
|
+
if (filter?.status) {
|
|
24
|
+
return this.db
|
|
25
|
+
.query("SELECT * FROM tasks WHERE project_id = ? AND status = ? ORDER BY created_at ASC LIMIT ? OFFSET ?")
|
|
26
|
+
.all(projectId, filter.status, limit, offset) as Task[];
|
|
27
|
+
}
|
|
11
28
|
return this.db
|
|
12
|
-
.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY created_at ASC")
|
|
13
|
-
.all(projectId) as Task[];
|
|
29
|
+
.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY created_at ASC LIMIT ? OFFSET ?")
|
|
30
|
+
.all(projectId, limit, offset) as Task[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
countByProject(projectId: string, filter?: TaskFilter): number {
|
|
34
|
+
if (filter?.tag) {
|
|
35
|
+
const statusClause = filter.status ? "AND t.status = ?" : "";
|
|
36
|
+
return (this.db.query(
|
|
37
|
+
`SELECT COUNT(*) as total FROM tasks t
|
|
38
|
+
JOIN task_tags tt ON tt.task_id = t.id
|
|
39
|
+
JOIN tags tg ON tg.id = tt.tag_id
|
|
40
|
+
WHERE tg.name = ? AND t.project_id = ? ${statusClause}`
|
|
41
|
+
).get(filter.tag, projectId, ...(filter.status ? [filter.status] : [])) as { total: number }).total;
|
|
42
|
+
}
|
|
43
|
+
if (filter?.status) {
|
|
44
|
+
return (this.db.query("SELECT COUNT(*) as total FROM tasks WHERE project_id = ? AND status = ?").get(projectId, filter.status) as { total: number }).total;
|
|
45
|
+
}
|
|
46
|
+
return (this.db.query("SELECT COUNT(*) as total FROM tasks WHERE project_id = ?").get(projectId) as { total: number }).total;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
findByNumber(projectId: string, number: number): Task | null {
|
|
50
|
+
return this.db
|
|
51
|
+
.query("SELECT * FROM tasks WHERE project_id = ? AND number = ?")
|
|
52
|
+
.get(projectId, number) as Task | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
findById(id: string): Task | null {
|
|
56
|
+
return this.db.query("SELECT * FROM tasks WHERE id = ?").get(id) as Task | null;
|
|
14
57
|
}
|
|
15
58
|
|
|
16
59
|
create(projectId: string, input: CreateTaskInput): Task {
|
|
17
60
|
const id = ulid();
|
|
18
61
|
const now = new Date().toISOString();
|
|
19
62
|
|
|
20
|
-
this.db
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
input.status ?? "todo",
|
|
31
|
-
|
|
32
|
-
now
|
|
33
|
-
);
|
|
63
|
+
this.db.transaction(() => {
|
|
64
|
+
const row = this.db
|
|
65
|
+
.query("SELECT COALESCE(MAX(number), 0) + 1 as next_number FROM tasks WHERE project_id = ?")
|
|
66
|
+
.get(projectId) as { next_number: number };
|
|
67
|
+
|
|
68
|
+
this.db
|
|
69
|
+
.query(
|
|
70
|
+
`INSERT INTO tasks (id, project_id, number, title, description, status, priority, created_at, updated_at)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
72
|
+
)
|
|
73
|
+
.run(id, projectId, row.next_number, input.title, input.description ?? "", input.status ?? "todo", input.priority ?? null, now, now);
|
|
74
|
+
})();
|
|
34
75
|
|
|
35
76
|
return this.db.query("SELECT * FROM tasks WHERE id = ?").get(id) as Task;
|
|
36
77
|
}
|
|
37
78
|
|
|
38
|
-
update(id: string, input: UpdateTaskInput): Task | null {
|
|
79
|
+
update(id: string, projectId: string, input: UpdateTaskInput): Task | null {
|
|
39
80
|
const existing = this.db
|
|
40
|
-
.query("SELECT * FROM tasks WHERE id = ?")
|
|
41
|
-
.get(id) as Task | null;
|
|
81
|
+
.query("SELECT * FROM tasks WHERE id = ? AND project_id = ?")
|
|
82
|
+
.get(id, projectId) as Task | null;
|
|
42
83
|
if (!existing) return null;
|
|
43
84
|
|
|
44
85
|
const title = input.title ?? existing.title;
|
|
45
86
|
const description = input.description ?? existing.description;
|
|
46
87
|
const status = input.status ?? existing.status;
|
|
88
|
+
const priority = input.priority !== undefined ? input.priority : existing.priority;
|
|
47
89
|
const now = new Date().toISOString();
|
|
48
90
|
|
|
49
91
|
this.db
|
|
50
92
|
.query(
|
|
51
|
-
`UPDATE tasks SET title = ?, description = ?, status = ?, updated_at = ? WHERE id = ?`
|
|
93
|
+
`UPDATE tasks SET title = ?, description = ?, status = ?, priority = ?, updated_at = ? WHERE id = ? AND project_id = ?`
|
|
52
94
|
)
|
|
53
|
-
.run(title, description, status, now, id);
|
|
95
|
+
.run(title, description, status, priority, now, id, projectId);
|
|
54
96
|
|
|
55
|
-
return this.db.query("SELECT * FROM tasks WHERE id = ?").get(id) as Task;
|
|
97
|
+
return this.db.query("SELECT * FROM tasks WHERE id = ? AND project_id = ?").get(id, projectId) as Task;
|
|
56
98
|
}
|
|
57
99
|
|
|
58
|
-
delete(id: string): boolean {
|
|
59
|
-
const result = this.db.query("DELETE FROM tasks WHERE id = ?").run(id);
|
|
100
|
+
delete(id: string, projectId: string): boolean {
|
|
101
|
+
const result = this.db.query("DELETE FROM tasks WHERE id = ? AND project_id = ?").run(id, projectId);
|
|
60
102
|
return result.changes > 0;
|
|
61
103
|
}
|
|
62
104
|
}
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import type { Project } from "../entities";
|
|
2
2
|
import type { CreateProjectInput, UpdateProjectInput } from "../inputs";
|
|
3
|
-
import type { IProjectService } from "../services";
|
|
3
|
+
import type { IProjectService, Paginated, ProjectFilter } from "../services";
|
|
4
4
|
import { ServiceError } from "../errors";
|
|
5
5
|
import type { ProjectRepository } from "../repositories/projects";
|
|
6
6
|
import { PROJECT_STATUSES } from "../statuses";
|
|
7
|
+
import type { EventBus } from "../events";
|
|
7
8
|
|
|
8
9
|
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
10
|
|
|
10
11
|
export class ProjectService implements IProjectService {
|
|
11
|
-
constructor(private repo: ProjectRepository) {}
|
|
12
|
+
constructor(private repo: ProjectRepository, private eventBus?: EventBus) {}
|
|
12
13
|
|
|
13
|
-
findAll(): Project
|
|
14
|
-
return
|
|
14
|
+
findAll(limit = 50, offset = 0, filter?: ProjectFilter): Paginated<Project> {
|
|
15
|
+
return {
|
|
16
|
+
data: this.repo.findAll(limit, offset, filter),
|
|
17
|
+
total: this.repo.count(filter),
|
|
18
|
+
};
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
findBySlug(slug: string): Project | null {
|
|
@@ -43,7 +47,9 @@ export class ProjectService implements IProjectService {
|
|
|
43
47
|
if (this.repo.findBySlug(input.slug)) {
|
|
44
48
|
throw new ServiceError("slug already exists", 409);
|
|
45
49
|
}
|
|
46
|
-
|
|
50
|
+
const project = this.repo.create(input);
|
|
51
|
+
this.eventBus?.emit({ entity: "project", action: "created", payload: project });
|
|
52
|
+
return project;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
update(slug: string, input: UpdateProjectInput): Project | null {
|
|
@@ -59,10 +65,19 @@ export class ProjectService implements IProjectService {
|
|
|
59
65
|
if (input.status !== undefined && !(PROJECT_STATUSES as readonly string[]).includes(input.status)) {
|
|
60
66
|
throw new ServiceError(`status must be one of: ${PROJECT_STATUSES.join(", ")}`, 400);
|
|
61
67
|
}
|
|
62
|
-
|
|
68
|
+
const project = this.repo.update(slug, input);
|
|
69
|
+
if (project) {
|
|
70
|
+
this.eventBus?.emit({ entity: "project", action: "updated", payload: project });
|
|
71
|
+
}
|
|
72
|
+
return project;
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
delete(slug: string): boolean {
|
|
66
|
-
|
|
76
|
+
const project = this.repo.findBySlug(slug);
|
|
77
|
+
const deleted = this.repo.delete(slug);
|
|
78
|
+
if (deleted && project) {
|
|
79
|
+
this.eventBus?.emit({ entity: "project", action: "deleted", payload: { id: project.id } });
|
|
80
|
+
}
|
|
81
|
+
return deleted;
|
|
67
82
|
}
|
|
68
83
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Tag, Task } from "../entities";
|
|
2
|
+
import type { ITagService, Paginated } from "../services";
|
|
3
|
+
import { ServiceError } from "../errors";
|
|
4
|
+
import type { TagRepository } from "../repositories/tags";
|
|
5
|
+
import type { TaskRepository } from "../repositories/tasks";
|
|
6
|
+
|
|
7
|
+
export class TagService implements ITagService {
|
|
8
|
+
constructor(
|
|
9
|
+
private tagRepo: TagRepository,
|
|
10
|
+
private taskRepo: TaskRepository,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
findAll(limit = 100, offset = 0): Paginated<Tag> {
|
|
14
|
+
return {
|
|
15
|
+
data: this.tagRepo.findAll(limit, offset),
|
|
16
|
+
total: this.tagRepo.count(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
findByName(name: string): Tag | null {
|
|
21
|
+
return this.tagRepo.findByName(name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
create(name: string): Tag {
|
|
25
|
+
const trimmed = name.trim().toLowerCase();
|
|
26
|
+
if (!trimmed) {
|
|
27
|
+
throw new ServiceError("tag name is required", 400);
|
|
28
|
+
}
|
|
29
|
+
if (trimmed.length > 50) {
|
|
30
|
+
throw new ServiceError("tag name must be 50 characters or fewer", 400);
|
|
31
|
+
}
|
|
32
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(trimmed)) {
|
|
33
|
+
throw new ServiceError("tag name must be lowercase alphanumeric with hyphens", 400);
|
|
34
|
+
}
|
|
35
|
+
const existing = this.tagRepo.findByName(trimmed);
|
|
36
|
+
if (existing) {
|
|
37
|
+
throw new ServiceError("tag already exists", 409);
|
|
38
|
+
}
|
|
39
|
+
return this.tagRepo.create(trimmed);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
delete(id: string): boolean {
|
|
43
|
+
return this.tagRepo.delete(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
addTagToTask(taskId: string, tagName: string): Tag {
|
|
47
|
+
const task = this.taskRepo.findById(taskId);
|
|
48
|
+
if (!task) {
|
|
49
|
+
throw new ServiceError("task not found", 404);
|
|
50
|
+
}
|
|
51
|
+
const trimmed = tagName.trim().toLowerCase();
|
|
52
|
+
// Auto-create tag if it doesn't exist
|
|
53
|
+
let tag = this.tagRepo.findByName(trimmed);
|
|
54
|
+
if (!tag) {
|
|
55
|
+
if (!trimmed || trimmed.length > 50) {
|
|
56
|
+
throw new ServiceError("tag name must be 1-50 lowercase alphanumeric characters with hyphens", 400);
|
|
57
|
+
}
|
|
58
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(trimmed)) {
|
|
59
|
+
throw new ServiceError("tag name must be lowercase alphanumeric with hyphens", 400);
|
|
60
|
+
}
|
|
61
|
+
tag = this.tagRepo.create(trimmed);
|
|
62
|
+
}
|
|
63
|
+
this.tagRepo.addTagToTask(taskId, tag.id);
|
|
64
|
+
return tag;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
removeTagFromTask(taskId: string, tagId: string): boolean {
|
|
68
|
+
return this.tagRepo.removeTagFromTask(taskId, tagId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getTagsForTask(taskId: string): Tag[] {
|
|
72
|
+
return this.tagRepo.getTagsForTask(taskId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
findTasksByTag(tagName: string, limit = 100, offset = 0): Paginated<Task> {
|
|
76
|
+
const tag = this.tagRepo.findByName(tagName);
|
|
77
|
+
if (!tag) {
|
|
78
|
+
throw new ServiceError("tag not found", 404);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
data: this.tagRepo.findTasksByTag(tag.id, limit, offset),
|
|
82
|
+
total: this.tagRepo.countTasksByTag(tag.id),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|