449 lines
10 KiB
TypeScript
449 lines
10 KiB
TypeScript
import type { Database } from "bun:sqlite";
|
|
|
|
interface TaskFilters {
|
|
orgId?: string | null;
|
|
projectId?: string | null;
|
|
status?: string | null;
|
|
priority?: string | null;
|
|
assigneeUserId?: string | null;
|
|
}
|
|
|
|
function toCamelCaseTask(row: Record<string, unknown>) {
|
|
return {
|
|
id: row.id,
|
|
orgId: row.org_id,
|
|
projectId: row.project_id,
|
|
assigneeUserId: row.assignee_user_id,
|
|
title: row.title,
|
|
description: row.description,
|
|
status: row.status,
|
|
priority: row.priority,
|
|
storyPoints: row.story_points,
|
|
dueDate: row.due_date,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
projectName: row.project_name,
|
|
projectKey: row.project_key,
|
|
assigneeName: row.assignee_name,
|
|
};
|
|
}
|
|
|
|
function getUser(db: Database, userId: string) {
|
|
return db
|
|
.query(`
|
|
SELECT
|
|
id,
|
|
org_id AS orgId,
|
|
full_name AS fullName,
|
|
email,
|
|
role,
|
|
timezone
|
|
FROM users
|
|
WHERE id = ?
|
|
`)
|
|
.get(userId);
|
|
}
|
|
|
|
function getProject(db: Database, projectId: string) {
|
|
return db
|
|
.query(`
|
|
SELECT
|
|
p.id,
|
|
p.org_id AS orgId,
|
|
p.key,
|
|
p.name,
|
|
p.status,
|
|
p.owner_user_id AS ownerUserId,
|
|
p.due_date AS dueDate,
|
|
u.full_name AS ownerName,
|
|
COUNT(t.id) AS taskCount
|
|
FROM projects p
|
|
LEFT JOIN users u ON u.id = p.owner_user_id
|
|
LEFT JOIN tasks t ON t.project_id = p.id
|
|
WHERE p.id = ?
|
|
GROUP BY p.id
|
|
`)
|
|
.get(projectId);
|
|
}
|
|
|
|
export function listOrganizations(db: Database) {
|
|
return db
|
|
.query(`
|
|
SELECT
|
|
o.id,
|
|
o.slug,
|
|
o.name,
|
|
o.plan,
|
|
o.industry,
|
|
o.created_at AS createdAt,
|
|
COUNT(DISTINCT p.id) AS projectCount,
|
|
COUNT(DISTINCT t.id) AS taskCount,
|
|
COUNT(DISTINCT CASE WHEN t.status != 'done' THEN t.id END) AS openTaskCount
|
|
FROM organizations o
|
|
LEFT JOIN projects p ON p.org_id = o.id
|
|
LEFT JOIN tasks t ON t.org_id = o.id
|
|
GROUP BY o.id
|
|
ORDER BY o.name
|
|
`)
|
|
.all();
|
|
}
|
|
|
|
export function getOrganizationSummary(db: Database, orgId: string) {
|
|
const org = db
|
|
.query(`
|
|
SELECT
|
|
o.id,
|
|
o.slug,
|
|
o.name,
|
|
o.plan,
|
|
o.industry,
|
|
o.created_at AS createdAt,
|
|
COUNT(DISTINCT u.id) AS userCount,
|
|
COUNT(DISTINCT p.id) AS projectCount,
|
|
COUNT(DISTINCT t.id) AS taskCount
|
|
FROM organizations o
|
|
LEFT JOIN users u ON u.org_id = o.id
|
|
LEFT JOIN projects p ON p.org_id = o.id
|
|
LEFT JOIN tasks t ON t.org_id = o.id
|
|
WHERE o.id = ?
|
|
GROUP BY o.id
|
|
`)
|
|
.get(orgId);
|
|
|
|
if (!org) {
|
|
return null;
|
|
}
|
|
|
|
const statusBreakdown = db
|
|
.query(`
|
|
SELECT status, COUNT(*) AS count
|
|
FROM tasks
|
|
WHERE org_id = ?
|
|
GROUP BY status
|
|
ORDER BY count DESC, status ASC
|
|
`)
|
|
.all(orgId);
|
|
|
|
return {
|
|
...org,
|
|
statusBreakdown,
|
|
};
|
|
}
|
|
|
|
export interface CreateOrganizationInput {
|
|
slug: string;
|
|
name: string;
|
|
plan: string;
|
|
industry: string;
|
|
}
|
|
|
|
export function createOrganization(db: Database, input: CreateOrganizationInput) {
|
|
const id = `org_${Math.random().toString(36).slice(2, 10)}`;
|
|
const now = new Date().toISOString();
|
|
|
|
db.query(`
|
|
INSERT INTO organizations (id, slug, name, plan, industry, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`).run(id, input.slug, input.name, input.plan, input.industry, now);
|
|
|
|
return getOrganizationSummary(db, id);
|
|
}
|
|
|
|
export function listUsers(db: Database, orgId?: string | null) {
|
|
if (orgId) {
|
|
return db
|
|
.query(`
|
|
SELECT
|
|
id,
|
|
org_id AS orgId,
|
|
full_name AS fullName,
|
|
email,
|
|
role,
|
|
timezone
|
|
FROM users
|
|
WHERE org_id = ?
|
|
ORDER BY full_name
|
|
`)
|
|
.all(orgId);
|
|
}
|
|
|
|
return db
|
|
.query(`
|
|
SELECT
|
|
id,
|
|
org_id AS orgId,
|
|
full_name AS fullName,
|
|
email,
|
|
role,
|
|
timezone
|
|
FROM users
|
|
ORDER BY org_id, full_name
|
|
`)
|
|
.all();
|
|
}
|
|
|
|
export interface CreateUserInput {
|
|
orgId: string;
|
|
fullName: string;
|
|
email: string;
|
|
role: string;
|
|
timezone: string;
|
|
}
|
|
|
|
export function createUser(db: Database, input: CreateUserInput) {
|
|
const id = `user_${Math.random().toString(36).slice(2, 10)}`;
|
|
db.query(`
|
|
INSERT INTO users (id, org_id, full_name, email, role, timezone)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`).run(id, input.orgId, input.fullName, input.email, input.role, input.timezone);
|
|
|
|
return getUser(db, id);
|
|
}
|
|
|
|
export function listProjects(db: Database, orgId?: string | null) {
|
|
const sql = `
|
|
SELECT
|
|
p.id,
|
|
p.org_id AS orgId,
|
|
p.key,
|
|
p.name,
|
|
p.status,
|
|
p.owner_user_id AS ownerUserId,
|
|
p.due_date AS dueDate,
|
|
u.full_name AS ownerName,
|
|
COUNT(t.id) AS taskCount
|
|
FROM projects p
|
|
LEFT JOIN users u ON u.id = p.owner_user_id
|
|
LEFT JOIN tasks t ON t.project_id = p.id
|
|
${orgId ? "WHERE p.org_id = ?" : ""}
|
|
GROUP BY p.id
|
|
ORDER BY p.name
|
|
`;
|
|
|
|
return orgId ? db.query(sql).all(orgId) : db.query(sql).all();
|
|
}
|
|
|
|
export interface CreateProjectInput {
|
|
orgId: string;
|
|
key: string;
|
|
name: string;
|
|
status: string;
|
|
ownerUserId: string;
|
|
dueDate?: string | null;
|
|
}
|
|
|
|
export function createProject(db: Database, input: CreateProjectInput) {
|
|
const id = `proj_${Math.random().toString(36).slice(2, 10)}`;
|
|
db.query(`
|
|
INSERT INTO projects (id, org_id, key, name, status, owner_user_id, due_date)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
id,
|
|
input.orgId,
|
|
input.key,
|
|
input.name,
|
|
input.status,
|
|
input.ownerUserId,
|
|
input.dueDate ?? null,
|
|
);
|
|
|
|
return getProject(db, id);
|
|
}
|
|
|
|
export function listTasks(db: Database, filters: TaskFilters = {}) {
|
|
const conditions: string[] = [];
|
|
const values: string[] = [];
|
|
|
|
if (filters.orgId) {
|
|
conditions.push("t.org_id = ?");
|
|
values.push(filters.orgId);
|
|
}
|
|
if (filters.projectId) {
|
|
conditions.push("t.project_id = ?");
|
|
values.push(filters.projectId);
|
|
}
|
|
if (filters.status) {
|
|
conditions.push("t.status = ?");
|
|
values.push(filters.status);
|
|
}
|
|
if (filters.priority) {
|
|
conditions.push("t.priority = ?");
|
|
values.push(filters.priority);
|
|
}
|
|
if (filters.assigneeUserId) {
|
|
conditions.push("t.assignee_user_id = ?");
|
|
values.push(filters.assigneeUserId);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
const rows = db
|
|
.query(`
|
|
SELECT
|
|
t.*,
|
|
p.name AS project_name,
|
|
p.key AS project_key,
|
|
u.full_name AS assignee_name
|
|
FROM tasks t
|
|
JOIN projects p ON p.id = t.project_id
|
|
LEFT JOIN users u ON u.id = t.assignee_user_id
|
|
${whereClause}
|
|
ORDER BY
|
|
CASE t.priority
|
|
WHEN 'urgent' THEN 1
|
|
WHEN 'high' THEN 2
|
|
WHEN 'medium' THEN 3
|
|
ELSE 4
|
|
END,
|
|
t.due_date ASC,
|
|
t.updated_at DESC
|
|
`)
|
|
.all(...values) as Record<string, unknown>[];
|
|
|
|
return rows.map(toCamelCaseTask);
|
|
}
|
|
|
|
export function getTaskDetail(db: Database, taskId: string) {
|
|
const row = db
|
|
.query(`
|
|
SELECT
|
|
t.*,
|
|
p.name AS project_name,
|
|
p.key AS project_key,
|
|
u.full_name AS assignee_name
|
|
FROM tasks t
|
|
JOIN projects p ON p.id = t.project_id
|
|
LEFT JOIN users u ON u.id = t.assignee_user_id
|
|
WHERE t.id = ?
|
|
`)
|
|
.get(taskId) as Record<string, unknown> | null;
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
const labels = db
|
|
.query(`
|
|
SELECT l.id, l.name, l.color
|
|
FROM task_labels tl
|
|
JOIN labels l ON l.id = tl.label_id
|
|
WHERE tl.task_id = ?
|
|
ORDER BY l.name
|
|
`)
|
|
.all(taskId);
|
|
|
|
const comments = db
|
|
.query(`
|
|
SELECT
|
|
c.id,
|
|
c.body,
|
|
c.created_at AS createdAt,
|
|
c.author_user_id AS authorUserId,
|
|
u.full_name AS authorName
|
|
FROM comments c
|
|
JOIN users u ON u.id = c.author_user_id
|
|
WHERE c.task_id = ?
|
|
ORDER BY c.created_at ASC
|
|
`)
|
|
.all(taskId);
|
|
|
|
return {
|
|
...toCamelCaseTask(row),
|
|
labels,
|
|
comments,
|
|
};
|
|
}
|
|
|
|
export interface CreateTaskInput {
|
|
orgId: string;
|
|
projectId: string;
|
|
assigneeUserId?: string | null;
|
|
title: string;
|
|
description?: string;
|
|
status?: string;
|
|
priority?: string;
|
|
storyPoints?: number | null;
|
|
dueDate?: string | null;
|
|
}
|
|
|
|
export function createTask(db: Database, input: CreateTaskInput) {
|
|
const now = new Date().toISOString();
|
|
const id = `task_${Math.random().toString(36).slice(2, 10)}`;
|
|
|
|
db.query(`
|
|
INSERT INTO tasks (
|
|
id, org_id, project_id, assignee_user_id, title, description, status, priority, story_points, due_date, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
id,
|
|
input.orgId,
|
|
input.projectId,
|
|
input.assigneeUserId ?? null,
|
|
input.title,
|
|
input.description ?? "",
|
|
input.status ?? "todo",
|
|
input.priority ?? "medium",
|
|
input.storyPoints ?? null,
|
|
input.dueDate ?? null,
|
|
now,
|
|
now,
|
|
);
|
|
|
|
return getTaskDetail(db, id);
|
|
}
|
|
|
|
export interface UpdateTaskInput {
|
|
title?: string;
|
|
description?: string;
|
|
status?: string;
|
|
priority?: string;
|
|
assigneeUserId?: string | null;
|
|
storyPoints?: number | null;
|
|
dueDate?: string | null;
|
|
}
|
|
|
|
export function updateTask(db: Database, taskId: string, input: UpdateTaskInput) {
|
|
const existing = db.query("SELECT id FROM tasks WHERE id = ?").get(taskId);
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const fields: string[] = [];
|
|
const values: Array<string | number | null> = [];
|
|
|
|
if (input.title !== undefined) {
|
|
fields.push("title = ?");
|
|
values.push(input.title);
|
|
}
|
|
if (input.description !== undefined) {
|
|
fields.push("description = ?");
|
|
values.push(input.description);
|
|
}
|
|
if (input.status !== undefined) {
|
|
fields.push("status = ?");
|
|
values.push(input.status);
|
|
}
|
|
if (input.priority !== undefined) {
|
|
fields.push("priority = ?");
|
|
values.push(input.priority);
|
|
}
|
|
if (input.assigneeUserId !== undefined) {
|
|
fields.push("assignee_user_id = ?");
|
|
values.push(input.assigneeUserId);
|
|
}
|
|
if (input.storyPoints !== undefined) {
|
|
fields.push("story_points = ?");
|
|
values.push(input.storyPoints);
|
|
}
|
|
if (input.dueDate !== undefined) {
|
|
fields.push("due_date = ?");
|
|
values.push(input.dueDate);
|
|
}
|
|
|
|
fields.push("updated_at = ?");
|
|
values.push(new Date().toISOString());
|
|
values.push(taskId);
|
|
|
|
db.query(`UPDATE tasks SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
|
return getTaskDetail(db, taskId);
|
|
}
|