Files
opengroupware/repositories/BoardRepository.ts
Ken Yasue 2017585f84 feat(m6): board with threads/comments, markdown, tests, e2e
- 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
2026-06-25 01:28:08 +02:00

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;
}
}