feat: 增加项目管理功能
引入 SQLite 数据库(Drizzle ORM + bun:sqlite),实现项目 CRUD 与归档/恢复/删除 生命周期管理,新增项目管理前端页面,migration 嵌入单文件构建产物保持部署体验。 - src/server/db: schema、connection、migration 执行器、项目数据访问层 - src/server/routes/projects: 7 个 API 端点(列表/创建/详情/更新/归档/恢复/删除) - src/web: 项目管理页面(TDesign Table/Tabs/Dialog/Form),TanStack Query hooks - scripts: 构建时嵌入 migration SQL,开发期独立 generate-migrations-data 脚本 - tests: 60 个后端测试 + 27 个前端测试,覆盖 DB/migration/API/路由/页面 - docs: 更新架构、后端、发布、配置、部署、使用文档
This commit is contained in:
@@ -2,10 +2,12 @@ import { mkdirSync } from "node:fs";
|
||||
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types";
|
||||
import type { MigrationRecord } from "./db/load-migrations";
|
||||
import type { Logger } from "./logger";
|
||||
import type { StartServerOptions } from "./server";
|
||||
|
||||
import { loadServerConfig } from "./config";
|
||||
import { createDatabase, loadMigrationsFromDir, runMigrations } from "./db";
|
||||
import { createConsoleFallback, createRuntimeLogger } from "./logger";
|
||||
import { startServer } from "./server";
|
||||
|
||||
@@ -19,6 +21,7 @@ export interface BootstrapDependencies {
|
||||
|
||||
export interface BootstrapOptions {
|
||||
configPath: string;
|
||||
migrations?: MigrationRecord[];
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StartServerOptions["staticAssets"];
|
||||
version?: string;
|
||||
@@ -59,8 +62,13 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
logger!.info({ dataDir: config.dataDir }, "数据目录就绪");
|
||||
|
||||
const migrations = options.migrations ?? loadMigrationsFromDir();
|
||||
const db = createDatabase(config.dataDir, logger!);
|
||||
runMigrations(db, migrations, config.dataDir, logger!);
|
||||
|
||||
const shutdown = () => {
|
||||
logger?.info("收到退出信号,开始优雅关闭");
|
||||
db.close();
|
||||
logger?.flush();
|
||||
exit(0);
|
||||
};
|
||||
@@ -69,6 +77,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
|
||||
|
||||
serve({
|
||||
config: { host: config.host, port: config.port },
|
||||
db,
|
||||
logger: logger!.child({ component: "server" }),
|
||||
mode: options.mode,
|
||||
staticAssets: options.staticAssets,
|
||||
|
||||
19
src/server/db/connection.ts
Normal file
19
src/server/db/connection.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Database from "bun:sqlite";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
const DB_FILENAME = "alfred.db";
|
||||
|
||||
export function createDatabase(dataDir: string, logger: Logger): Database {
|
||||
const dbPath = join(dataDir, DB_FILENAME);
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA busy_timeout = 5000");
|
||||
|
||||
logger.info({ dbPath }, "数据库连接初始化");
|
||||
|
||||
return db;
|
||||
}
|
||||
4
src/server/db/index.ts
Normal file
4
src/server/db/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { createDatabase } from "./connection";
|
||||
export { loadMigrationsFromDir, type MigrationRecord } from "./load-migrations";
|
||||
export { runMigrations } from "./migrate";
|
||||
export { projects, schemaMigrations } from "./schema";
|
||||
30
src/server/db/load-migrations.ts
Normal file
30
src/server/db/load-migrations.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export interface MigrationRecord {
|
||||
checksum: string;
|
||||
id: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export function loadMigrationsFromDir(migrationsDir?: string): MigrationRecord[] {
|
||||
const dir = migrationsDir ?? resolve(import.meta.dir, "../../../drizzle");
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries.map((filename) => {
|
||||
const sql = readFileSync(join(dir, filename), "utf-8");
|
||||
const id = filename.replace(/\.sql$/, "");
|
||||
const checksum = createHash("sha256").update(sql).digest("hex").slice(0, 16);
|
||||
|
||||
return { checksum, id, sql };
|
||||
});
|
||||
}
|
||||
67
src/server/db/migrate.ts
Normal file
67
src/server/db/migrate.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Logger } from "../logger";
|
||||
import type { MigrationRecord } from "./load-migrations";
|
||||
|
||||
export function runMigrations(db: Database, migrations: MigrationRecord[], dataDir: string, logger: Logger): void {
|
||||
if (migrations.length === 0) {
|
||||
logger.info("数据库 schema 已是最新");
|
||||
return;
|
||||
}
|
||||
|
||||
db.exec(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations (id TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)",
|
||||
);
|
||||
|
||||
const applied = getAppliedMigrationIds(db);
|
||||
const pending = migrations.filter((m) => !applied.has(m.id));
|
||||
|
||||
if (pending.length === 0) {
|
||||
logger.info("数据库 schema 已是最新");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ count: pending.length }, "发现待执行 migration");
|
||||
|
||||
const dbPath = join(dataDir, "alfred.db");
|
||||
if (needsBackup(dbPath)) {
|
||||
backupDatabase(dbPath, dataDir, logger);
|
||||
}
|
||||
|
||||
const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)");
|
||||
|
||||
db.transaction(() => {
|
||||
for (const migration of pending) {
|
||||
logger.info({ id: migration.id }, "执行 migration");
|
||||
db.exec(migration.sql);
|
||||
insertApplied.run(migration.id, migration.checksum, new Date().toISOString());
|
||||
}
|
||||
})();
|
||||
|
||||
logger.info({ count: pending.length }, "migration 全部执行完成");
|
||||
}
|
||||
|
||||
function backupDatabase(dbPath: string, dataDir: string, logger: Logger): void {
|
||||
const backupsDir = join(dataDir, "backups");
|
||||
mkdirSync(backupsDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupPath = join(backupsDir, `alfred-${timestamp}.db`);
|
||||
|
||||
copyFileSync(dbPath, backupPath);
|
||||
logger.info({ backupPath }, "数据库备份完成");
|
||||
}
|
||||
|
||||
function getAppliedMigrationIds(db: Database): Set<string> {
|
||||
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
|
||||
return new Set(rows.map((r) => r.id));
|
||||
}
|
||||
|
||||
function needsBackup(dbPath: string): boolean {
|
||||
if (!existsSync(dbPath)) return false;
|
||||
const stat = statSync(dbPath);
|
||||
return stat.size > 0;
|
||||
}
|
||||
191
src/server/db/projects.ts
Normal file
191
src/server/db/projects.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { and, desc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api";
|
||||
|
||||
import { projects } from "./schema";
|
||||
|
||||
export function archiveProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
if (!existing) return { error: "项目不存在", status: 404 };
|
||||
if (existing.status === "archived") return { error: "项目已归档", status: 409 };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.update(projects).set({ archivedAt: now, status: "archived", updatedAt: now }).where(eq(projects.id, id)).run();
|
||||
|
||||
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
return { project: toProject(updated!) };
|
||||
}
|
||||
|
||||
export function createProject(
|
||||
raw: Database,
|
||||
request: CreateProjectRequest,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const name = request.name.trim();
|
||||
if (!name) return { error: "项目名称不能为空", status: 400 };
|
||||
|
||||
const description = (request.description ?? "").trim();
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
db.insert(projects)
|
||||
.values({
|
||||
archivedAt: null,
|
||||
createdAt: now,
|
||||
description,
|
||||
id,
|
||||
name,
|
||||
status: "active",
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("UNIQUE constraint")) {
|
||||
return { error: "项目名称已存在", status: 409 };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const row = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
return { project: toProject(row!) };
|
||||
}
|
||||
|
||||
export function deleteProject(raw: Database, id: string): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
if (!existing) return { error: "项目不存在", status: 404 };
|
||||
if (existing.status === "active") return { error: "活跃项目不可删除,请先归档", status: 409 };
|
||||
|
||||
db.delete(projects).where(eq(projects.id, id)).run();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
|
||||
if (!row) return { error: "项目不存在", status: 404 };
|
||||
return { project: toProject(row) };
|
||||
}
|
||||
|
||||
export function listProjects(
|
||||
raw: Database,
|
||||
options: { keyword?: string; page: number; pageSize: number; status?: ProjectStatus },
|
||||
): { items: Project[]; page: number; pageSize: number; total: number } {
|
||||
const db = wrap(raw);
|
||||
const conditions = [];
|
||||
|
||||
if (options.status) {
|
||||
conditions.push(eq(projects.status, options.status));
|
||||
}
|
||||
|
||||
if (options.keyword) {
|
||||
const pattern = `%${options.keyword}%`;
|
||||
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const countResult = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(projects)
|
||||
.where(where)
|
||||
.get();
|
||||
|
||||
const total = Number(countResult?.count ?? 0);
|
||||
|
||||
const rows = db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(where)
|
||||
.orderBy(desc(projects.createdAt))
|
||||
.limit(options.pageSize)
|
||||
.offset((options.page - 1) * options.pageSize)
|
||||
.all();
|
||||
|
||||
return {
|
||||
items: rows.map(toProject),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
if (!existing) return { error: "项目不存在", status: 404 };
|
||||
if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.update(projects).set({ archivedAt: null, status: "active", updatedAt: now }).where(eq(projects.id, id)).run();
|
||||
|
||||
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
return { project: toProject(updated!) };
|
||||
}
|
||||
|
||||
export function updateProject(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateProjectRequest,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
if (!existing) return { error: "项目不存在", status: 404 };
|
||||
if (existing.status === "archived") return { error: "已归档项目不可编辑", status: 409 };
|
||||
|
||||
const name = request.name?.trim();
|
||||
if (name === "") return { error: "项目名称不能为空", status: 400 };
|
||||
|
||||
const updates: Partial<typeof projects.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (name !== undefined && name !== existing.name) {
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
const description = request.description?.trim();
|
||||
if (description !== undefined) {
|
||||
updates.description = description;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 1 && updates.updatedAt) {
|
||||
return { project: toProject(existing) };
|
||||
}
|
||||
|
||||
try {
|
||||
db.update(projects).set(updates).where(eq(projects.id, id)).run();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("UNIQUE constraint")) {
|
||||
return { error: "项目名称已存在", status: 409 };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
return { project: toProject(updated!) };
|
||||
}
|
||||
|
||||
function toProject(row: typeof projects.$inferSelect): Project {
|
||||
return {
|
||||
archivedAt: row.archivedAt,
|
||||
createdAt: row.createdAt,
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function wrap(raw: Database) {
|
||||
return drizzle(raw);
|
||||
}
|
||||
19
src/server/db/schema.ts
Normal file
19
src/server/db/schema.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const projects = sqliteTable("projects", {
|
||||
archivedAt: text("archived_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
description: text("description").notNull().default(""),
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
status: text("status", { enum: ["active", "archived"] })
|
||||
.notNull()
|
||||
.default("active"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const schemaMigrations = sqliteTable("schema_migrations", {
|
||||
appliedAt: text("applied_at").notNull(),
|
||||
checksum: text("checksum").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
});
|
||||
22
src/server/routes/projects/archive.ts
Normal file
22
src/server/routes/projects/archive.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { archiveProject } from "../../db/projects";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleArchiveProject(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = archiveProject(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
26
src/server/routes/projects/create.ts
Normal file
26
src/server/routes/projects/create.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { CreateProjectRequest, RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { createProject } from "../../db/projects";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
|
||||
export async function handleCreateProject(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
|
||||
let body: CreateProjectRequest;
|
||||
try {
|
||||
body = (await req.json()) as CreateProjectRequest;
|
||||
} catch {
|
||||
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.name || typeof body.name !== "string") {
|
||||
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = createProject(db, body);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode, status: 201 });
|
||||
}
|
||||
22
src/server/routes/projects/delete.ts
Normal file
22
src/server/routes/projects/delete.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { deleteProject } from "../../db/projects";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = deleteProject(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
22
src/server/routes/projects/get.ts
Normal file
22
src/server/routes/projects/get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { getProject } from "../../db/projects";
|
||||
import { jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleGetProject(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = getProject(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse({ error: result.error, status: result.status }, { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
31
src/server/routes/projects/list.ts
Normal file
31
src/server/routes/projects/list.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { listProjects } from "../../db/projects";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validatePagination } from "../../middleware";
|
||||
|
||||
export function handleListProjects(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
const keyword = url.searchParams.get("keyword");
|
||||
const statusParam = url.searchParams.get("status");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
|
||||
if (statusParam && statusParam !== "active" && statusParam !== "archived") {
|
||||
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = listProjects(db, {
|
||||
keyword: keyword ?? undefined,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
status: (statusParam as "active" | "archived") ?? undefined,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
22
src/server/routes/projects/restore.ts
Normal file
22
src/server/routes/projects/restore.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
|
||||
import { restoreProject } from "../../db/projects";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleRestoreProject(req: Request, db: Database, mode: RuntimeMode): Response {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const result = restoreProject(db, validated.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
33
src/server/routes/projects/update.ts
Normal file
33
src/server/routes/projects/update.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode, UpdateProjectRequest } from "../../../shared/api";
|
||||
|
||||
import { updateProject } from "../../db/projects";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export async function handleUpdateProject(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const idStr = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(idStr ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
let body: UpdateProjectRequest;
|
||||
try {
|
||||
body = (await req.json()) as UpdateProjectRequest;
|
||||
} catch {
|
||||
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.name && !body.description && body.name !== "" && body.description !== "") {
|
||||
return jsonResponse(createApiError("At least one of name or description is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = updateProject(db, validated.id, body);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { Logger } from "./logger";
|
||||
import type { StaticAssets } from "./static";
|
||||
@@ -9,6 +11,7 @@ import { readAppVersion } from "./version";
|
||||
|
||||
export interface StartServerOptions {
|
||||
config: { host: string; port: number };
|
||||
db: Database;
|
||||
logger: Logger;
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StaticAssets;
|
||||
@@ -16,7 +19,7 @@ export interface StartServerOptions {
|
||||
}
|
||||
|
||||
export function startServer(options: StartServerOptions) {
|
||||
const { config, logger, mode, staticAssets, version } = options;
|
||||
const { config, db, logger, mode, staticAssets, version } = options;
|
||||
|
||||
const resolveVersion = (): Promise<string> => {
|
||||
if (version) return Promise.resolve(version);
|
||||
@@ -40,6 +43,42 @@ export function startServer(options: StartServerOptions) {
|
||||
return handleMeta(mode, resolvedVersion);
|
||||
},
|
||||
},
|
||||
"/api/projects": {
|
||||
GET: async (req) => {
|
||||
const { handleListProjects } = await import("./routes/projects/list");
|
||||
return handleListProjects(req, db, mode);
|
||||
},
|
||||
POST: async (req) => {
|
||||
const { handleCreateProject } = await import("./routes/projects/create");
|
||||
return handleCreateProject(req, db, mode);
|
||||
},
|
||||
},
|
||||
"/api/projects/:id": {
|
||||
DELETE: async (req) => {
|
||||
const { handleDeleteProject } = await import("./routes/projects/delete");
|
||||
return handleDeleteProject(req, db, mode);
|
||||
},
|
||||
GET: async (req) => {
|
||||
const { handleGetProject } = await import("./routes/projects/get");
|
||||
return handleGetProject(req, db, mode);
|
||||
},
|
||||
PATCH: async (req) => {
|
||||
const { handleUpdateProject } = await import("./routes/projects/update");
|
||||
return handleUpdateProject(req, db, mode);
|
||||
},
|
||||
},
|
||||
"/api/projects/:id/archive": {
|
||||
POST: async (req) => {
|
||||
const { handleArchiveProject } = await import("./routes/projects/archive");
|
||||
return handleArchiveProject(req, db, mode);
|
||||
},
|
||||
},
|
||||
"/api/projects/:id/restore": {
|
||||
POST: async (req) => {
|
||||
const { handleRestoreProject } = await import("./routes/projects/restore");
|
||||
return handleRestoreProject(req, db, mode);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user