feat: add Materi section + AI agent RAG to GMW business flow

Backend:
- New materi module: schema (materi_documents table), repository, service
- ragClient: semantic + keyword search over materi docs, plus Discord
  archive via Qdrant, then LLM answer generation (RAG)
- Wire materiRouter into appRouter (list/detail/create/update/delete/chat)

Frontend:
- New types (MateriDocument, CreateMateriInput, RAG chat shapes)
- API client (SSR HTTP RPCLink + browser WS RPCLink)
- Routes: /materi list, /materi/[id] detail, /materi/new form,
  /materi/chat RAG chat UI
- Sidebar nav item 'Materi'

Migration: scripts/add-materi-documents.sql (CREATE TABLE IF NOT EXISTS)
This commit is contained in:
asepharyana
2026-08-20 15:57:04 +07:00
parent 6f20b0f146
commit 0aa893ab7d
16 changed files with 1228 additions and 0 deletions
@@ -0,0 +1,148 @@
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import {
type MateriDocument,
materiDocumentsTable,
} from "@/shared/database/schema.js";
import { getDatabase } from "@/shared/database/index.js";
import type { MateriQueryInput } from "./materi.schema.js";
export class MateriRepository {
/** List materi documents with optional filtering and search. */
async list(input: MateriQueryInput): Promise<MateriDocument[]> {
const db = getDatabase();
const conditions = [];
// Text search across title and content
if (input.search) {
const term = `%${input.search}%`;
conditions.push(
or(
ilike(materiDocumentsTable.title, term),
ilike(materiDocumentsTable.content, term),
),
);
}
// Category filter
if (input.category) {
conditions.push(eq(materiDocumentsTable.category, input.category));
}
// Owner filter
if (input.ownerId) {
conditions.push(eq(materiDocumentsTable.owner_user_id, input.ownerId));
}
// Only public (if requested)
if (input.onlyPublic) {
conditions.push(eq(materiDocumentsTable.is_public, true));
}
const whereClause =
conditions.length > 0 ? and(...conditions) : undefined;
const result = await db
.select()
.from(materiDocumentsTable)
.where(whereClause)
.orderBy(desc(materiDocumentsTable.created_at))
.limit(input.limit);
return result;
}
/** Get a single materi by id. */
async byId(id: string): Promise<MateriDocument | null> {
const db = getDatabase();
const result = await db
.select()
.from(materiDocumentsTable)
.where(eq(materiDocumentsTable.id, id))
.limit(1);
return result[0] ?? null;
}
/** Create a new materi document. */
async create(data: {
title: string;
description?: string | null;
content: string;
category: string;
tags: string[];
ownerUserId: string;
guildId?: string | null;
channelId?: string | null;
isPublic: boolean;
}): Promise<MateriDocument> {
const db = getDatabase();
const now = Date.now();
const result = await db
.insert(materiDocumentsTable)
.values({
title: data.title,
description: data.description ?? null,
content: data.content,
category: data.category,
tags: data.tags,
owner_user_id: data.ownerUserId,
guild_id: data.guildId ?? null,
channel_id: data.channelId ?? null,
is_public: data.isPublic,
view_count: 0,
created_at: now,
updated_at: now,
})
.returning();
return result[0]!;
}
/** Update an existing materi. */
async update(
id: string,
data: Partial<{
title: string;
description?: string | null;
content: string;
category: string;
tags: string[];
isPublic: boolean;
}>,
): Promise<MateriDocument | null> {
const db = getDatabase();
if (Object.keys(data).length === 0) return this.byId(id);
const result = await db
.update(materiDocumentsTable)
.set({
...data,
updated_at: Date.now(),
})
.where(eq(materiDocumentsTable.id, id))
.returning();
return result[0] ?? null;
}
/** Delete a materi. */
async delete(id: string): Promise<boolean> {
const db = getDatabase();
const result = await db
.delete(materiDocumentsTable)
.where(eq(materiDocumentsTable.id, id))
.returning({ deletedId: materiDocumentsTable.id });
return result.length > 0;
}
/** Increment view count (for analytics). */
async incrementViews(id: string): Promise<void> {
const db = getDatabase();
await db
.update(materiDocumentsTable)
.set({
view_count: sql`${materiDocumentsTable.view_count} + 1`,
})
.where(eq(materiDocumentsTable.id, id));
}
}
export const materiRepository = new MateriRepository();