@alt-t4b/pm-domain 0.1.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.
- package/package.json +15 -0
- package/src/bootstrap.ts +26 -0
- package/src/db/connection.ts +21 -0
- package/src/db/schema.ts +35 -0
- package/src/entities.ts +21 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +12 -0
- package/src/inputs.ts +13 -0
- package/src/repositories/projects.ts +75 -0
- package/src/repositories/tasks.ts +62 -0
- package/src/services/projects.ts +68 -0
- package/src/services/tasks.ts +62 -0
- package/src/services.ts +22 -0
- package/src/statuses.ts +5 -0
package/package.json
ADDED
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { createDatabase } from "./db/connection";
|
|
3
|
+
import { runMigrations } from "./db/schema";
|
|
4
|
+
import { ProjectRepository } from "./repositories/projects";
|
|
5
|
+
import { TaskRepository } from "./repositories/tasks";
|
|
6
|
+
import { ProjectService } from "./services/projects";
|
|
7
|
+
import { TaskService } from "./services/tasks";
|
|
8
|
+
import type { IProjectService, ITaskService } from "./services";
|
|
9
|
+
|
|
10
|
+
export interface AppContext {
|
|
11
|
+
db: Database;
|
|
12
|
+
projectService: IProjectService;
|
|
13
|
+
taskService: ITaskService;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function bootstrap(dbPath?: string): AppContext {
|
|
17
|
+
const db = createDatabase(dbPath);
|
|
18
|
+
runMigrations(db);
|
|
19
|
+
|
|
20
|
+
const projectRepo = new ProjectRepository(db);
|
|
21
|
+
const taskRepo = new TaskRepository(db);
|
|
22
|
+
const projectService = new ProjectService(projectRepo);
|
|
23
|
+
const taskService = new TaskService(taskRepo, projectRepo);
|
|
24
|
+
|
|
25
|
+
return { db, projectService, taskService };
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DB_PATH = join(homedir(), ".tab", "project-management", "sqlite.db");
|
|
7
|
+
|
|
8
|
+
export function getDbPath(): string {
|
|
9
|
+
return process.env.SQLITE_PATH ?? DEFAULT_DB_PATH;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createDatabase(dbPath?: string): Database {
|
|
13
|
+
const resolvedPath = dbPath ?? getDbPath();
|
|
14
|
+
mkdirSync(join(resolvedPath, ".."), { recursive: true });
|
|
15
|
+
|
|
16
|
+
const db = new Database(resolvedPath);
|
|
17
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
18
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
19
|
+
|
|
20
|
+
return db;
|
|
21
|
+
}
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
|
|
7
|
+
export function runMigrations(db: Database): void {
|
|
8
|
+
db.run(`
|
|
9
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
slug TEXT NOT NULL UNIQUE,
|
|
12
|
+
name TEXT NOT NULL,
|
|
13
|
+
description TEXT NOT NULL DEFAULT '',
|
|
14
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
15
|
+
CHECK (status IN (${projectStatusCheck})),
|
|
16
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
17
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
18
|
+
)
|
|
19
|
+
`);
|
|
20
|
+
|
|
21
|
+
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)");
|
|
22
|
+
|
|
23
|
+
db.run(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
27
|
+
title TEXT NOT NULL,
|
|
28
|
+
description TEXT NOT NULL DEFAULT '',
|
|
29
|
+
status TEXT NOT NULL DEFAULT 'todo'
|
|
30
|
+
CHECK (status IN (${taskStatusCheck})),
|
|
31
|
+
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'))
|
|
33
|
+
)
|
|
34
|
+
`);
|
|
35
|
+
}
|
package/src/entities.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ProjectStatus, TaskStatus } from "./statuses";
|
|
2
|
+
|
|
3
|
+
export interface Project {
|
|
4
|
+
id: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
status: ProjectStatus;
|
|
9
|
+
created_at: string;
|
|
10
|
+
updated_at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Task {
|
|
14
|
+
id: string;
|
|
15
|
+
project_id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
status: TaskStatus;
|
|
19
|
+
created_at: string;
|
|
20
|
+
updated_at: string;
|
|
21
|
+
}
|
package/src/errors.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./statuses";
|
|
2
|
+
export * from "./entities";
|
|
3
|
+
export * from "./inputs";
|
|
4
|
+
export * from "./errors";
|
|
5
|
+
export * from "./services";
|
|
6
|
+
export * from "./bootstrap";
|
|
7
|
+
export { createDatabase, getDbPath } from "./db/connection";
|
|
8
|
+
export { runMigrations } from "./db/schema";
|
|
9
|
+
export { ProjectRepository } from "./repositories/projects";
|
|
10
|
+
export { TaskRepository } from "./repositories/tasks";
|
|
11
|
+
export { ProjectService } from "./services/projects";
|
|
12
|
+
export { TaskService } from "./services/tasks";
|
package/src/inputs.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Project, Task } from "./entities";
|
|
2
|
+
|
|
3
|
+
export type CreateProjectInput = Pick<Project, "name" | "slug"> &
|
|
4
|
+
Partial<Pick<Project, "description" | "status">>;
|
|
5
|
+
|
|
6
|
+
export type UpdateProjectInput = Partial<
|
|
7
|
+
Pick<Project, "name" | "description" | "status">
|
|
8
|
+
>;
|
|
9
|
+
|
|
10
|
+
export type CreateTaskInput = Pick<Task, "title"> &
|
|
11
|
+
Partial<Pick<Task, "description" | "status">>;
|
|
12
|
+
|
|
13
|
+
export type UpdateTaskInput = Partial<Pick<Task, "title" | "description" | "status">>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { ulid } from "ulid";
|
|
3
|
+
import type { Project } from "../entities";
|
|
4
|
+
import type { CreateProjectInput, UpdateProjectInput } from "../inputs";
|
|
5
|
+
|
|
6
|
+
export class ProjectRepository {
|
|
7
|
+
constructor(private db: Database) {}
|
|
8
|
+
|
|
9
|
+
findAll(): Project[] {
|
|
10
|
+
return this.db
|
|
11
|
+
.query("SELECT * FROM projects ORDER BY created_at DESC")
|
|
12
|
+
.all() as Project[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
findById(id: string): Project | null {
|
|
16
|
+
return (
|
|
17
|
+
this.db.query("SELECT * FROM projects WHERE id = ?").get(id) as Project | null
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
findBySlug(slug: string): Project | null {
|
|
22
|
+
return (
|
|
23
|
+
this.db.query("SELECT * FROM projects WHERE slug = ?").get(slug) as Project | null
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
create(input: CreateProjectInput): Project {
|
|
28
|
+
const id = ulid();
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
|
|
31
|
+
this.db
|
|
32
|
+
.query(
|
|
33
|
+
`INSERT INTO projects (id, slug, name, description, status, created_at, updated_at)
|
|
34
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
35
|
+
)
|
|
36
|
+
.run(
|
|
37
|
+
id,
|
|
38
|
+
input.slug,
|
|
39
|
+
input.name,
|
|
40
|
+
input.description ?? "",
|
|
41
|
+
input.status ?? "active",
|
|
42
|
+
now,
|
|
43
|
+
now
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return this.findById(id)!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
update(slug: string, input: UpdateProjectInput): Project | null {
|
|
50
|
+
const existing = this.findBySlug(slug);
|
|
51
|
+
if (!existing) return null;
|
|
52
|
+
|
|
53
|
+
const name = input.name ?? existing.name;
|
|
54
|
+
const description = input.description ?? existing.description;
|
|
55
|
+
const status = input.status ?? existing.status;
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
|
|
58
|
+
this.db
|
|
59
|
+
.query(
|
|
60
|
+
`UPDATE projects
|
|
61
|
+
SET name = ?, description = ?, status = ?, updated_at = ?
|
|
62
|
+
WHERE slug = ?`
|
|
63
|
+
)
|
|
64
|
+
.run(name, description, status, now, slug);
|
|
65
|
+
|
|
66
|
+
return this.findBySlug(slug)!;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
delete(slug: string): boolean {
|
|
70
|
+
const result = this.db
|
|
71
|
+
.query("DELETE FROM projects WHERE slug = ?")
|
|
72
|
+
.run(slug);
|
|
73
|
+
return result.changes > 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { ulid } from "ulid";
|
|
3
|
+
import type { Task } from "../entities";
|
|
4
|
+
import type { CreateTaskInput } from "../inputs";
|
|
5
|
+
import type { UpdateTaskInput } from "../inputs";
|
|
6
|
+
|
|
7
|
+
export class TaskRepository {
|
|
8
|
+
constructor(private db: Database) {}
|
|
9
|
+
|
|
10
|
+
findByProject(projectId: string): Task[] {
|
|
11
|
+
return this.db
|
|
12
|
+
.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY created_at ASC")
|
|
13
|
+
.all(projectId) as Task[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
create(projectId: string, input: CreateTaskInput): Task {
|
|
17
|
+
const id = ulid();
|
|
18
|
+
const now = new Date().toISOString();
|
|
19
|
+
|
|
20
|
+
this.db
|
|
21
|
+
.query(
|
|
22
|
+
`INSERT INTO tasks (id, project_id, title, description, status, created_at, updated_at)
|
|
23
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
24
|
+
)
|
|
25
|
+
.run(
|
|
26
|
+
id,
|
|
27
|
+
projectId,
|
|
28
|
+
input.title,
|
|
29
|
+
input.description ?? "",
|
|
30
|
+
input.status ?? "todo",
|
|
31
|
+
now,
|
|
32
|
+
now
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return this.db.query("SELECT * FROM tasks WHERE id = ?").get(id) as Task;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
update(id: string, input: UpdateTaskInput): Task | null {
|
|
39
|
+
const existing = this.db
|
|
40
|
+
.query("SELECT * FROM tasks WHERE id = ?")
|
|
41
|
+
.get(id) as Task | null;
|
|
42
|
+
if (!existing) return null;
|
|
43
|
+
|
|
44
|
+
const title = input.title ?? existing.title;
|
|
45
|
+
const description = input.description ?? existing.description;
|
|
46
|
+
const status = input.status ?? existing.status;
|
|
47
|
+
const now = new Date().toISOString();
|
|
48
|
+
|
|
49
|
+
this.db
|
|
50
|
+
.query(
|
|
51
|
+
`UPDATE tasks SET title = ?, description = ?, status = ?, updated_at = ? WHERE id = ?`
|
|
52
|
+
)
|
|
53
|
+
.run(title, description, status, now, id);
|
|
54
|
+
|
|
55
|
+
return this.db.query("SELECT * FROM tasks WHERE id = ?").get(id) as Task;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
delete(id: string): boolean {
|
|
59
|
+
const result = this.db.query("DELETE FROM tasks WHERE id = ?").run(id);
|
|
60
|
+
return result.changes > 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Project } from "../entities";
|
|
2
|
+
import type { CreateProjectInput, UpdateProjectInput } from "../inputs";
|
|
3
|
+
import type { IProjectService } from "../services";
|
|
4
|
+
import { ServiceError } from "../errors";
|
|
5
|
+
import type { ProjectRepository } from "../repositories/projects";
|
|
6
|
+
import { PROJECT_STATUSES } from "../statuses";
|
|
7
|
+
|
|
8
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
|
+
|
|
10
|
+
export class ProjectService implements IProjectService {
|
|
11
|
+
constructor(private repo: ProjectRepository) {}
|
|
12
|
+
|
|
13
|
+
findAll(): Project[] {
|
|
14
|
+
return this.repo.findAll();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
findBySlug(slug: string): Project | null {
|
|
18
|
+
return this.repo.findBySlug(slug);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
create(input: CreateProjectInput): Project {
|
|
22
|
+
if (!input.name?.trim()) {
|
|
23
|
+
throw new ServiceError("name is required", 400);
|
|
24
|
+
}
|
|
25
|
+
if (input.name.length > 255) {
|
|
26
|
+
throw new ServiceError("name must be 255 characters or fewer", 400);
|
|
27
|
+
}
|
|
28
|
+
if (!input.slug?.trim()) {
|
|
29
|
+
throw new ServiceError("slug is required", 400);
|
|
30
|
+
}
|
|
31
|
+
if (input.slug.length > 100) {
|
|
32
|
+
throw new ServiceError("slug must be 100 characters or fewer", 400);
|
|
33
|
+
}
|
|
34
|
+
if (!SLUG_RE.test(input.slug)) {
|
|
35
|
+
throw new ServiceError("slug must be lowercase alphanumeric with hyphens only", 400);
|
|
36
|
+
}
|
|
37
|
+
if (input.description !== undefined && input.description.length > 10000) {
|
|
38
|
+
throw new ServiceError("description must be 10000 characters or fewer", 400);
|
|
39
|
+
}
|
|
40
|
+
if (input.status !== undefined && !(PROJECT_STATUSES as readonly string[]).includes(input.status)) {
|
|
41
|
+
throw new ServiceError(`status must be one of: ${PROJECT_STATUSES.join(", ")}`, 400);
|
|
42
|
+
}
|
|
43
|
+
if (this.repo.findBySlug(input.slug)) {
|
|
44
|
+
throw new ServiceError("slug already exists", 409);
|
|
45
|
+
}
|
|
46
|
+
return this.repo.create(input);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
update(slug: string, input: UpdateProjectInput): Project | null {
|
|
50
|
+
if (input.name !== undefined && !input.name.trim()) {
|
|
51
|
+
throw new ServiceError("name cannot be empty", 400);
|
|
52
|
+
}
|
|
53
|
+
if (input.name !== undefined && input.name.length > 255) {
|
|
54
|
+
throw new ServiceError("name must be 255 characters or fewer", 400);
|
|
55
|
+
}
|
|
56
|
+
if (input.description !== undefined && input.description.length > 10000) {
|
|
57
|
+
throw new ServiceError("description must be 10000 characters or fewer", 400);
|
|
58
|
+
}
|
|
59
|
+
if (input.status !== undefined && !(PROJECT_STATUSES as readonly string[]).includes(input.status)) {
|
|
60
|
+
throw new ServiceError(`status must be one of: ${PROJECT_STATUSES.join(", ")}`, 400);
|
|
61
|
+
}
|
|
62
|
+
return this.repo.update(slug, input);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
delete(slug: string): boolean {
|
|
66
|
+
return this.repo.delete(slug);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Task } from "../entities";
|
|
2
|
+
import type { CreateTaskInput, UpdateTaskInput } from "../inputs";
|
|
3
|
+
import type { ITaskService } from "../services";
|
|
4
|
+
import { ServiceError } from "../errors";
|
|
5
|
+
import type { TaskRepository } from "../repositories/tasks";
|
|
6
|
+
import type { ProjectRepository } from "../repositories/projects";
|
|
7
|
+
import { TASK_STATUSES } from "../statuses";
|
|
8
|
+
|
|
9
|
+
export class TaskService implements ITaskService {
|
|
10
|
+
constructor(
|
|
11
|
+
private taskRepo: TaskRepository,
|
|
12
|
+
private projectRepo: ProjectRepository
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
findByProjectSlug(projectSlug: string): Task[] {
|
|
16
|
+
const project = this.projectRepo.findBySlug(projectSlug);
|
|
17
|
+
if (!project) {
|
|
18
|
+
throw new ServiceError("project not found", 404);
|
|
19
|
+
}
|
|
20
|
+
return this.taskRepo.findByProject(project.id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
create(projectSlug: string, input: CreateTaskInput): Task {
|
|
24
|
+
const project = this.projectRepo.findBySlug(projectSlug);
|
|
25
|
+
if (!project) {
|
|
26
|
+
throw new ServiceError("project not found", 404);
|
|
27
|
+
}
|
|
28
|
+
if (!input.title?.trim()) {
|
|
29
|
+
throw new ServiceError("title is required", 400);
|
|
30
|
+
}
|
|
31
|
+
if (input.title.length > 500) {
|
|
32
|
+
throw new ServiceError("title must be 500 characters or fewer", 400);
|
|
33
|
+
}
|
|
34
|
+
if (input.description !== undefined && input.description.length > 10000) {
|
|
35
|
+
throw new ServiceError("description must be 10000 characters or fewer", 400);
|
|
36
|
+
}
|
|
37
|
+
if (input.status !== undefined && !(TASK_STATUSES as readonly string[]).includes(input.status)) {
|
|
38
|
+
throw new ServiceError(`status must be one of: ${TASK_STATUSES.join(", ")}`, 400);
|
|
39
|
+
}
|
|
40
|
+
return this.taskRepo.create(project.id, input);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
update(id: string, input: UpdateTaskInput): Task | null {
|
|
44
|
+
if (input.title !== undefined && !input.title.trim()) {
|
|
45
|
+
throw new ServiceError("title cannot be empty", 400);
|
|
46
|
+
}
|
|
47
|
+
if (input.title !== undefined && input.title.length > 500) {
|
|
48
|
+
throw new ServiceError("title must be 500 characters or fewer", 400);
|
|
49
|
+
}
|
|
50
|
+
if (input.description !== undefined && input.description.length > 10000) {
|
|
51
|
+
throw new ServiceError("description must be 10000 characters or fewer", 400);
|
|
52
|
+
}
|
|
53
|
+
if (input.status !== undefined && !(TASK_STATUSES as readonly string[]).includes(input.status)) {
|
|
54
|
+
throw new ServiceError(`status must be one of: ${TASK_STATUSES.join(", ")}`, 400);
|
|
55
|
+
}
|
|
56
|
+
return this.taskRepo.update(id, input);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
delete(id: string): boolean {
|
|
60
|
+
return this.taskRepo.delete(id);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/services.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Project, Task } from "./entities";
|
|
2
|
+
import type {
|
|
3
|
+
CreateProjectInput,
|
|
4
|
+
UpdateProjectInput,
|
|
5
|
+
CreateTaskInput,
|
|
6
|
+
UpdateTaskInput,
|
|
7
|
+
} from "./inputs";
|
|
8
|
+
|
|
9
|
+
export interface IProjectService {
|
|
10
|
+
findAll(): Project[];
|
|
11
|
+
findBySlug(slug: string): Project | null;
|
|
12
|
+
create(input: CreateProjectInput): Project;
|
|
13
|
+
update(slug: string, input: UpdateProjectInput): Project | null;
|
|
14
|
+
delete(slug: string): boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ITaskService {
|
|
18
|
+
findByProjectSlug(projectSlug: string): Task[];
|
|
19
|
+
create(projectSlug: string, input: CreateTaskInput): Task;
|
|
20
|
+
update(id: string, input: UpdateTaskInput): Task | null;
|
|
21
|
+
delete(id: string): boolean;
|
|
22
|
+
}
|
package/src/statuses.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const PROJECT_STATUSES = ["active", "paused", "completed", "archived"] as const;
|
|
2
|
+
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
|
3
|
+
|
|
4
|
+
export const TASK_STATUSES = ["todo", "in_progress", "done"] as const;
|
|
5
|
+
export type TaskStatus = (typeof TASK_STATUSES)[number];
|