- BoardRepository (thread/comment CRUD, soft-delete, search, pagination, pinned-first ordering) + BoardService (membership permission, author/admin edit/delete, comment->board_commented notification, board_posted/comment_added activity logs, category validation) - APIs: threads list/create/detail/edit/delete, comments create/edit/delete - MarkdownBody (react-markdown + remark-gfm + rehype-sanitize), ThreadForm, CommentForm, board list + thread detail screens, ProjectNav link - Unit tests (BoardRepository, BoardService) + e2e board.spec
266 lines
7.8 KiB
TypeScript
266 lines
7.8 KiB
TypeScript
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
|
import type { BoardCategory, BoardComment, BoardThread } from '@/lib/types';
|
|
|
|
const DEFAULT_PAGE_SIZE = 20;
|
|
|
|
export interface Paginated<T> {
|
|
items: T[];
|
|
total: number;
|
|
}
|
|
|
|
interface BoardThreadRow {
|
|
id: number;
|
|
project_id: number;
|
|
title: string;
|
|
body_md: string;
|
|
author_id: number;
|
|
category: string | null;
|
|
is_pinned: number;
|
|
is_important: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
deleted_at: string | null;
|
|
}
|
|
|
|
interface BoardCommentRow {
|
|
id: number;
|
|
thread_id: number;
|
|
author_id: number;
|
|
body_md: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
deleted_at: string | null;
|
|
}
|
|
|
|
function mapThread(row: BoardThreadRow): BoardThread {
|
|
return {
|
|
id: row.id,
|
|
projectId: row.project_id,
|
|
title: row.title,
|
|
bodyMd: row.body_md,
|
|
authorId: row.author_id,
|
|
category: row.category as BoardCategory | null,
|
|
isPinned: row.is_pinned,
|
|
isImportant: row.is_important,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
deletedAt: row.deleted_at,
|
|
};
|
|
}
|
|
|
|
function mapComment(row: BoardCommentRow): BoardComment {
|
|
return {
|
|
id: row.id,
|
|
threadId: row.thread_id,
|
|
authorId: row.author_id,
|
|
bodyMd: row.body_md,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
deletedAt: row.deleted_at,
|
|
};
|
|
}
|
|
|
|
export interface CreateThreadInput {
|
|
projectId: number;
|
|
title: string;
|
|
bodyMd: string;
|
|
authorId: number;
|
|
category: BoardCategory | null;
|
|
}
|
|
|
|
export interface UpdateThreadInput {
|
|
title?: string;
|
|
bodyMd?: string;
|
|
category?: BoardCategory | null;
|
|
isPinned?: number;
|
|
isImportant?: number;
|
|
}
|
|
|
|
export interface ListThreadsOptions {
|
|
page?: number;
|
|
pageSize?: number;
|
|
search?: string;
|
|
}
|
|
|
|
export class BoardRepository {
|
|
constructor(private readonly db: SqliteDatabase) {}
|
|
|
|
findThreads(
|
|
projectId: number,
|
|
opts: ListThreadsOptions = {}
|
|
): Paginated<BoardThread> {
|
|
const page = opts.page ?? 1;
|
|
const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
const offset = (page - 1) * pageSize;
|
|
const search = opts.search?.trim();
|
|
|
|
if (search) {
|
|
const like = `%${search}%`;
|
|
const items = this.db.query<BoardThreadRow>(
|
|
`SELECT * FROM board_threads
|
|
WHERE project_id = @projectId AND deleted_at IS NULL
|
|
AND (title LIKE @like OR body_md LIKE @like)
|
|
ORDER BY is_pinned DESC, created_at DESC, id DESC
|
|
LIMIT @pageSize OFFSET @offset`,
|
|
{ projectId, like, pageSize, offset }
|
|
);
|
|
const total = this.db.get<{ count: number }>(
|
|
`SELECT COUNT(*) AS count FROM board_threads
|
|
WHERE project_id = @projectId AND deleted_at IS NULL
|
|
AND (title LIKE @like OR body_md LIKE @like)`,
|
|
{ projectId, like }
|
|
);
|
|
return { items: items.map(mapThread), total: total?.count ?? 0 };
|
|
}
|
|
|
|
const items = this.db.query<BoardThreadRow>(
|
|
`SELECT * FROM board_threads
|
|
WHERE project_id = @projectId AND deleted_at IS NULL
|
|
ORDER BY is_pinned DESC, created_at DESC, id DESC
|
|
LIMIT @pageSize OFFSET @offset`,
|
|
{ projectId, pageSize, offset }
|
|
);
|
|
const total = this.db.get<{ count: number }>(
|
|
'SELECT COUNT(*) AS count FROM board_threads WHERE project_id = @projectId AND deleted_at IS NULL',
|
|
{ projectId }
|
|
);
|
|
return { items: items.map(mapThread), total: total?.count ?? 0 };
|
|
}
|
|
|
|
findThreadById(id: number): BoardThread | null {
|
|
const row = this.db.get<BoardThreadRow>(
|
|
'SELECT * FROM board_threads WHERE id = @id AND deleted_at IS NULL',
|
|
{ id }
|
|
);
|
|
return row ? mapThread(row) : null;
|
|
}
|
|
|
|
createThread(input: CreateThreadInput): BoardThread {
|
|
const now = new Date().toISOString();
|
|
const result = this.db.execute(
|
|
`INSERT INTO board_threads (project_id, title, body_md, author_id, category, is_pinned, is_important, created_at, updated_at, deleted_at)
|
|
VALUES (@projectId, @title, @bodyMd, @authorId, @category, 0, 0, @createdAt, @updatedAt, NULL)`,
|
|
{
|
|
projectId: input.projectId,
|
|
title: input.title,
|
|
bodyMd: input.bodyMd,
|
|
authorId: input.authorId,
|
|
category: input.category,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
);
|
|
const created = this.findThreadById(Number(result.lastInsertRowid));
|
|
if (!created) throw new Error('Failed to create thread');
|
|
return created;
|
|
}
|
|
|
|
updateThread(id: number, input: UpdateThreadInput): BoardThread | null {
|
|
const fields: string[] = ['updated_at = @updatedAt'];
|
|
const params: Record<string, unknown> = {
|
|
updatedAt: new Date().toISOString(),
|
|
id,
|
|
};
|
|
if (input.title !== undefined) {
|
|
fields.push('title = @title');
|
|
params.title = input.title;
|
|
}
|
|
if (input.bodyMd !== undefined) {
|
|
fields.push('body_md = @bodyMd');
|
|
params.bodyMd = input.bodyMd;
|
|
}
|
|
if (input.category !== undefined) {
|
|
fields.push('category = @category');
|
|
params.category = input.category;
|
|
}
|
|
if (input.isPinned !== undefined) {
|
|
fields.push('is_pinned = @isPinned');
|
|
params.isPinned = input.isPinned;
|
|
}
|
|
if (input.isImportant !== undefined) {
|
|
fields.push('is_important = @isImportant');
|
|
params.isImportant = input.isImportant;
|
|
}
|
|
this.db.execute(
|
|
`UPDATE board_threads SET ${fields.join(', ')} WHERE id = @id AND deleted_at IS NULL`,
|
|
params
|
|
);
|
|
return this.findThreadById(id);
|
|
}
|
|
|
|
deleteThread(id: number): boolean {
|
|
const result = this.db.execute(
|
|
'UPDATE board_threads SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL',
|
|
{ now: new Date().toISOString(), id }
|
|
);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
findCommentsByThread(
|
|
threadId: number,
|
|
page: number = 1,
|
|
pageSize: number = DEFAULT_PAGE_SIZE
|
|
): Paginated<BoardComment> {
|
|
const offset = (page - 1) * pageSize;
|
|
const items = this.db.query<BoardCommentRow>(
|
|
`SELECT * FROM board_comments
|
|
WHERE thread_id = @threadId AND deleted_at IS NULL
|
|
ORDER BY created_at ASC, id ASC
|
|
LIMIT @pageSize OFFSET @offset`,
|
|
{ threadId, pageSize, offset }
|
|
);
|
|
const total = this.db.get<{ count: number }>(
|
|
'SELECT COUNT(*) AS count FROM board_comments WHERE thread_id = @threadId AND deleted_at IS NULL',
|
|
{ threadId }
|
|
);
|
|
return { items: items.map(mapComment), total: total?.count ?? 0 };
|
|
}
|
|
|
|
findCommentById(id: number): BoardComment | null {
|
|
const row = this.db.get<BoardCommentRow>(
|
|
'SELECT * FROM board_comments WHERE id = @id AND deleted_at IS NULL',
|
|
{ id }
|
|
);
|
|
return row ? mapComment(row) : null;
|
|
}
|
|
|
|
createComment(input: {
|
|
threadId: number;
|
|
authorId: number;
|
|
bodyMd: string;
|
|
}): BoardComment {
|
|
const now = new Date().toISOString();
|
|
const result = this.db.execute(
|
|
`INSERT INTO board_comments (thread_id, author_id, body_md, created_at, updated_at, deleted_at)
|
|
VALUES (@threadId, @authorId, @bodyMd, @createdAt, @updatedAt, NULL)`,
|
|
{
|
|
threadId: input.threadId,
|
|
authorId: input.authorId,
|
|
bodyMd: input.bodyMd,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
);
|
|
const created = this.findCommentById(Number(result.lastInsertRowid));
|
|
if (!created) throw new Error('Failed to create comment');
|
|
return created;
|
|
}
|
|
|
|
updateComment(id: number, bodyMd: string): BoardComment | null {
|
|
this.db.execute(
|
|
`UPDATE board_comments SET body_md = @bodyMd, updated_at = @updatedAt
|
|
WHERE id = @id AND deleted_at IS NULL`,
|
|
{ bodyMd, updatedAt: new Date().toISOString(), id }
|
|
);
|
|
return this.findCommentById(id);
|
|
}
|
|
|
|
deleteComment(id: number): boolean {
|
|
const result = this.db.execute(
|
|
'UPDATE board_comments SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL',
|
|
{ now: new Date().toISOString(), id }
|
|
);
|
|
return result.changes > 0;
|
|
}
|
|
}
|