import { randomUUID } from "node:crypto"; import { and, asc, desc, eq, gte, inArray, isNull, like, or, sql, } from "drizzle-orm"; import type { PluginDatabase } from "@termix/plugin-sdk/backend"; export interface SnippetRecord { id: number; userId: string; name: string; content: string; description: string | null; folder: string | null; order: number; syncId: string | null; createdAt: string; updatedAt: string; hostFilter: string | null; isNote: boolean; } export interface SnippetFolderRecord { id: number; userId: string; name: string; color: string | null; icon: string | null; syncId: string | null; createdAt: string; updatedAt: string; } export interface RenameSnippetFolderResult { status: "renamed" | "missing" | "conflict"; } export interface SnippetReorderUpdate { id: number; order: number; folder?: string; } export interface NewSnippetInput { name: string; content: string; description?: string | null; folder?: string | null; order?: number | null; hostFilter?: unknown; isNote?: boolean; } export interface SnippetUpdateInput { name?: string; content?: string; description?: string | null; folder?: string | null; order?: number; hostFilter?: unknown; isNote?: boolean; } export interface UpdateSnippetResult { existing: SnippetRecord; updated: SnippetRecord; } export interface SnippetBulkImportResult { snippetsImported: number; snippetsSkipped: number; snippetsUpdated: number; foldersImported: number; foldersSkipped: number; failed: number; errors: string[]; } interface BulkImportSnippetInput { name?: unknown; content?: unknown; description?: string | null; folder?: string | null; order?: unknown; hostFilter?: string | null; } interface BulkImportFolderInput { name?: unknown; color?: string | null; icon?: string | null; } export interface RbacAccessListItem { id: number; targetType: "user" | "role"; userId: string | null; roleId: number | null; username: string | null; roleName: string | null; roleDisplayName: string | null; grantedBy: string; grantedByUsername: string | null; permissionLevel: string; expiresAt: string | null; createdAt: string; } export type SnippetAccessTarget = | { targetType: "user"; targetUserId: string } | { targetType: "role"; targetRoleId: number }; export type UpsertSnippetAccessInput = SnippetAccessTarget & { snippetId: number; grantedBy: string; expiresAt: string | null; }; export interface RbacSharedSnippet { id: number; name: string; content: string; description: string | null; folder: string | null; ownerUsername: string; permissionLevel: string; expiresAt: string | null; } export interface RbacVisibleSharedSnippet extends RbacSharedSnippet { userId: string; order: number; createdAt: string; updatedAt: string; isNote: boolean; } export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet { hostFilter: string | null; } /* eslint-disable @typescript-eslint/no-explicit-any */ // The tables come from ctx.db.define/ctx.db.refs, which the SDK hands back // untyped, and the drizzle handle is the server's own. Typed at this module's // edge instead. type Table = any; type Drizzle = any; /* eslint-enable @typescript-eslint/no-explicit-any */ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } function toAccessListItem( row: Omit, ): RbacAccessListItem { return { ...row, targetType: row.userId ? "user" : "role" }; } export type SnippetRepository = ReturnType; /** * Snippet, folder and sharing storage. Written without RETURNING so it runs * the same on all three engines: an insert or update is read back by id/sync * id rather than relying on `.returning()`. */ export function createSnippetRepository( db: PluginDatabase, snippets: Table, folders: Table, access: Table, ) { const client = () => db.client(); const refs = () => db.refs<{ users: Table; roles: Table; userRoles: Table }>(); async function findOwnedById( userId: string, snippetId: number, ): Promise { const drizzle = await client(); const rows = await drizzle .select() .from(snippets) .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) .limit(1); return rows[0] ?? null; } async function findFolderByName( userId: string, name: string, ): Promise { const drizzle = await client(); const rows = await drizzle .select() .from(folders) .where(and(eq(folders.userId, userId), eq(folders.name, name))) .limit(1); return rows[0] ?? null; } async function maxOrderForFolder( userId: string, folder: string | null, ): Promise { const drizzle = await client(); const result = await drizzle .select({ maxOrder: sql`MAX(${snippets.order})` }) .from(snippets) .where( and( eq(snippets.userId, userId), folder ? eq(snippets.folder, folder) : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, ), ); return result[0]?.maxOrder ?? -1; } async function nextOrderForFolder( userId: string, folder: string, ): Promise { return (await maxOrderForFolder(userId, folder || null)) + 1; } async function findByNameAndFolder( userId: string, name: string, folder: string | null, ): Promise { const drizzle = await client(); const rows = await drizzle .select() .from(snippets) .where( and( eq(snippets.userId, userId), eq(snippets.name, name), folder ? eq(snippets.folder, folder) : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, ), ) .limit(1); return rows[0] ?? null; } function userOrRoleAccessFilter(userId: string, roleIds: number[]) { if (roleIds.length === 0) return eq(access.userId, userId); return or(eq(access.userId, userId), inArray(access.roleId, roleIds)); } async function findSnippetAccess( snippetId: number, target: SnippetAccessTarget, ) { const drizzle = await client(); const rows = await drizzle .select() .from(access) .where( and( eq(access.snippetId, snippetId), target.targetType === "user" ? eq(access.userId, target.targetUserId) : eq(access.roleId, target.targetRoleId), ), ) .limit(1); return rows[0] ?? null; } return { findOwnedById, async listFolders(userId: string): Promise { const drizzle = await client(); return drizzle .select() .from(folders) .where(eq(folders.userId, userId)) .orderBy(asc(folders.name)); }, async listSnippetsForExport(userId: string): Promise { const drizzle = await client(); return drizzle .select() .from(snippets) .where(eq(snippets.userId, userId)) .orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order)); }, async listFoldersForExport(userId: string): Promise { const drizzle = await client(); return drizzle .select() .from(folders) .where(eq(folders.userId, userId)) .orderBy(asc(folders.name)); }, async listOwnedSnippetsInFolder( userId: string, folder: string, ): Promise { const drizzle = await client(); return drizzle .select() .from(snippets) .where( and( eq(snippets.userId, userId), or( eq(snippets.folder, folder), like(snippets.folder, `${folder} / %`), ), ), ); }, async listOwnedSnippets(userId: string): Promise { const drizzle = await client(); return drizzle .select() .from(snippets) .where(eq(snippets.userId, userId)) .orderBy( sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`, asc(snippets.folder), asc(snippets.order), sql`${snippets.updatedAt} DESC`, ); }, async reorderSnippets( userId: string, updates: SnippetReorderUpdate[], ): Promise { const drizzle = await client(); for (const update of updates) { const { id, order, folder } = update; if (!id || order === undefined) continue; const updateFields: Partial<{ order: number; folder: string | null }> = { order, }; if (folder !== undefined) updateFields.folder = folder?.trim() || null; await drizzle .update(snippets) .set(updateFields) .where(and(eq(snippets.id, id), eq(snippets.userId, userId))); } await db.persist(); }, async createSnippet( userId: string, input: NewSnippetInput, ): Promise { const folderValue = input.folder?.trim() || ""; const order = input.order === undefined || input.order === null ? await nextOrderForFolder(userId, folderValue) : input.order; const syncId = randomUUID(); const drizzle = await client(); await drizzle.insert(snippets).values({ syncId, userId, name: input.name.trim(), content: input.content.trim(), description: input.description?.trim() || null, folder: input.folder?.trim() || null, order, hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null, isNote: input.isNote ?? false, }); await db.persist(); const rows = await drizzle .select() .from(snippets) .where(eq(snippets.syncId, syncId)) .limit(1); return rows[0]; }, async updateSnippet( userId: string, snippetId: number, input: SnippetUpdateInput, ): Promise { const existing = await findOwnedById(userId, snippetId); if (!existing) return null; const updateFields: Record = { updatedAt: sql`CURRENT_TIMESTAMP`, }; if (input.name !== undefined) updateFields.name = input.name.trim(); if (input.content !== undefined) updateFields.content = input.content.trim(); if (input.description !== undefined) updateFields.description = input.description?.trim() || null; if (input.folder !== undefined) updateFields.folder = input.folder?.trim() || null; if (input.order !== undefined) updateFields.order = input.order; if (input.hostFilter !== undefined) updateFields.hostFilter = input.hostFilter ? JSON.stringify(input.hostFilter) : null; if (input.isNote !== undefined) updateFields.isNote = input.isNote; const drizzle = await client(); await drizzle .update(snippets) .set(updateFields) .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))); await db.persist(); const updated = await findOwnedById(userId, snippetId); return { existing, updated: updated as SnippetRecord }; }, async deleteSnippet( userId: string, snippetId: number, ): Promise { const existing = await findOwnedById(userId, snippetId); if (!existing) return null; const drizzle = await client(); await drizzle .delete(snippets) .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))); await db.persist(); return existing; }, async bulkImport( userId: string, snippetsToImport: unknown[] | undefined, foldersToImport: unknown[] | undefined, overwrite: boolean, ): Promise { const results: SnippetBulkImportResult = { snippetsImported: 0, snippetsSkipped: 0, snippetsUpdated: 0, foldersImported: 0, foldersSkipped: 0, failed: 0, errors: [], }; const drizzle = await client(); let changed = false; if (Array.isArray(foldersToImport)) { for (const rawFolder of foldersToImport) { const folder = rawFolder as BulkImportFolderInput; if (!isNonEmptyString(folder.name)) { results.failed++; results.errors.push(`Folder missing name`); continue; } const existing = await findFolderByName(userId, folder.name); if (existing) { results.foldersSkipped++; continue; } await drizzle.insert(folders).values({ syncId: randomUUID(), userId, name: folder.name.trim(), color: folder.color?.trim() || null, icon: folder.icon?.trim() || null, }); changed = true; results.foldersImported++; } } if (Array.isArray(snippetsToImport)) { for (let i = 0; i < snippetsToImport.length; i++) { const snippet = snippetsToImport[i] as BulkImportSnippetInput; if ( !isNonEmptyString(snippet.name) || !isNonEmptyString(snippet.content) ) { results.failed++; results.errors.push( `Snippet ${i + 1}: name and content are required`, ); continue; } const folderVal = snippet.folder?.trim() || null; const existing = await findByNameAndFolder( userId, snippet.name.trim(), folderVal, ); if (existing) { if (!overwrite) { results.snippetsSkipped++; continue; } await drizzle .update(snippets) .set({ content: snippet.content.trim(), description: snippet.description?.trim() || null, folder: folderVal, order: typeof snippet.order === "number" ? snippet.order : existing.order, hostFilter: snippet.hostFilter || null, updatedAt: sql`CURRENT_TIMESTAMP`, }) .where( and(eq(snippets.id, existing.id), eq(snippets.userId, userId)), ); changed = true; results.snippetsUpdated++; continue; } const maxOrder = await maxOrderForFolder(userId, folderVal); await drizzle.insert(snippets).values({ syncId: randomUUID(), userId, name: snippet.name.trim(), content: snippet.content.trim(), description: snippet.description?.trim() || null, folder: folderVal, order: typeof snippet.order === "number" ? snippet.order : maxOrder + 1, hostFilter: snippet.hostFilter || null, }); changed = true; results.snippetsImported++; } } if (changed) await db.persist(); return results; }, async createFolder( userId: string, name: string, color: string | null | undefined, icon: string | null | undefined, ): Promise { const existing = await findFolderByName(userId, name); if (existing) return null; const syncId = randomUUID(); const drizzle = await client(); await drizzle.insert(folders).values({ syncId, userId, name: name.trim(), color: color?.trim() || null, icon: icon?.trim() || null, }); await db.persist(); const rows = await drizzle .select() .from(folders) .where(eq(folders.syncId, syncId)) .limit(1); return rows[0] ?? null; }, async updateFolderMetadata( userId: string, name: string, color: string | null | undefined, icon: string | null | undefined, ): Promise { const existing = await findFolderByName(userId, name); if (!existing) return null; const updateFields: Record = { updatedAt: sql`CURRENT_TIMESTAMP`, }; if (color !== undefined) updateFields.color = color?.trim() || null; if (icon !== undefined) updateFields.icon = icon?.trim() || null; const drizzle = await client(); await drizzle .update(folders) .set(updateFields) .where(and(eq(folders.userId, userId), eq(folders.name, name))); await db.persist(); return findFolderByName(userId, name); }, async renameFolder( userId: string, oldName: string, newName: string, ): Promise { const existing = await findFolderByName(userId, oldName); if (!existing) return { status: "missing" }; const nameExists = await findFolderByName(userId, newName); if (nameExists) return { status: "conflict" }; const drizzle = await client(); await drizzle .update(folders) .set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) .where(and(eq(folders.userId, userId), eq(folders.name, oldName))); await drizzle .update(snippets) .set({ folder: newName }) .where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName))); await db.persist(); return { status: "renamed" }; }, async deleteFolder( userId: string, name: string, ): Promise<{ syncId: string | null } | null> { const drizzle = await client(); await drizzle .update(snippets) .set({ folder: null }) .where(and(eq(snippets.userId, userId), eq(snippets.folder, name))); const existing = await findFolderByName(userId, name); if (!existing) { await db.persist(); return null; } await drizzle .delete(folders) .where(and(eq(folders.userId, userId), eq(folders.name, name))); await db.persist(); return { syncId: existing.syncId }; }, /** Wipes a user's snippets and folders, for the password-reset data-wipe flow. */ async deleteByUserId(userId: string): Promise { const drizzle = await client(); await drizzle.delete(snippets).where(eq(snippets.userId, userId)); await drizzle.delete(folders).where(eq(folders.userId, userId)); await db.persist(); }, // Sharing (moved from core's RbacAccessRepository) async listSnippetAccess(snippetId: number): Promise { const drizzle = await client(); const { users, roles } = await refs(); const rows = await drizzle .select({ id: access.id, userId: access.userId, roleId: access.roleId, username: users.username, roleName: roles.name, roleDisplayName: roles.displayName, grantedBy: access.grantedBy, grantedByUsername: sql< string | null >`(SELECT username FROM users WHERE id = ${access.grantedBy})`, permissionLevel: access.permissionLevel, expiresAt: access.expiresAt, createdAt: access.createdAt, }) .from(access) .leftJoin(users, eq(access.userId, users.id)) .leftJoin(roles, eq(access.roleId, roles.id)) .where(eq(access.snippetId, snippetId)) .orderBy(desc(access.createdAt)); return rows.map(toAccessListItem); }, async upsertSnippetAccess( input: UpsertSnippetAccessInput, ): Promise<{ id: number; created: boolean }> { const existing = await findSnippetAccess(input.snippetId, input); const drizzle = await client(); if (existing) { await drizzle .update(access) .set({ expiresAt: input.expiresAt }) .where(eq(access.id, existing.id)); await db.persist(); return { id: existing.id, created: false }; } const now = new Date().toISOString(); await drizzle.insert(access).values({ snippetId: input.snippetId, userId: input.targetType === "user" ? input.targetUserId : null, roleId: input.targetType === "role" ? input.targetRoleId : null, grantedBy: input.grantedBy, permissionLevel: "view", expiresAt: input.expiresAt, createdAt: now, }); await db.persist(); const created = await findSnippetAccess(input.snippetId, input); return { id: created!.id, created: true }; }, async revokeSnippetAccess( accessId: number, snippetId: number, ): Promise { const drizzle = await client(); await drizzle .delete(access) .where(and(eq(access.id, accessId), eq(access.snippetId, snippetId))); await db.persist(); }, async listSharedSnippets( userId: string, roleIds: number[], now = new Date().toISOString(), ): Promise { const drizzle = await client(); const { users } = await refs(); const directShared = await drizzle .select({ id: snippets.id, name: snippets.name, content: snippets.content, description: snippets.description, folder: snippets.folder, ownerUsername: users.username, permissionLevel: access.permissionLevel, expiresAt: access.expiresAt, }) .from(access) .innerJoin(snippets, eq(access.snippetId, snippets.id)) .innerJoin(users, eq(snippets.userId, users.id)) .where( and( eq(access.userId, userId), or(isNull(access.expiresAt), gte(access.expiresAt, now)), ), ); if (roleIds.length === 0) return directShared; const directIds = new Set(directShared.map((s: { id: number }) => s.id)); const roleShared = await drizzle .select({ id: snippets.id, name: snippets.name, content: snippets.content, description: snippets.description, folder: snippets.folder, ownerUsername: users.username, permissionLevel: access.permissionLevel, expiresAt: access.expiresAt, }) .from(access) .innerJoin(snippets, eq(access.snippetId, snippets.id)) .innerJoin(users, eq(snippets.userId, users.id)) .where( and( or(isNull(access.expiresAt), gte(access.expiresAt, now)), inArray(access.roleId, roleIds), ), ); return [ ...directShared, ...roleShared.filter((s: { id: number }) => !directIds.has(s.id)), ]; }, async listVisibleSharedSnippets( userId: string, roleIds: number[], now = new Date().toISOString(), ): Promise { const drizzle = await client(); const { users } = await refs(); return drizzle .select({ id: snippets.id, userId: snippets.userId, name: snippets.name, content: snippets.content, description: snippets.description, folder: snippets.folder, order: snippets.order, createdAt: snippets.createdAt, updatedAt: snippets.updatedAt, isNote: snippets.isNote, ownerUsername: users.username, permissionLevel: access.permissionLevel, expiresAt: access.expiresAt, }) .from(access) .innerJoin(snippets, eq(access.snippetId, snippets.id)) .innerJoin(users, eq(snippets.userId, users.id)) .where( and( userOrRoleAccessFilter(userId, roleIds), or(isNull(access.expiresAt), gte(access.expiresAt, now)), ), ); }, async findAccessibleSharedSnippet( snippetId: number, userId: string, roleIds: number[], now = new Date().toISOString(), ): Promise { const drizzle = await client(); const { users } = await refs(); const rows = await drizzle .select({ id: snippets.id, userId: snippets.userId, name: snippets.name, content: snippets.content, description: snippets.description, folder: snippets.folder, order: snippets.order, createdAt: snippets.createdAt, updatedAt: snippets.updatedAt, hostFilter: snippets.hostFilter, isNote: snippets.isNote, ownerUsername: users.username, permissionLevel: access.permissionLevel, expiresAt: access.expiresAt, }) .from(access) .innerJoin(snippets, eq(access.snippetId, snippets.id)) .innerJoin(users, eq(snippets.userId, users.id)) .where( and( eq(access.snippetId, snippetId), userOrRoleAccessFilter(userId, roleIds), or(isNull(access.expiresAt), gte(access.expiresAt, now)), ), ) .limit(1); return rows[0] ?? null; }, /** A snippet's sync id, for a host setting that names it over sync. */ async findSyncIdById(snippetId: number): Promise { const drizzle = await client(); const rows = await drizzle .select({ syncId: snippets.syncId }) .from(snippets) .where(eq(snippets.id, snippetId)) .limit(1); return (rows[0]?.syncId as string | null | undefined) ?? null; }, async findIdBySyncId(syncId: string): Promise { const drizzle = await client(); const rows = await drizzle .select({ id: snippets.id }) .from(snippets) .where(eq(snippets.syncId, syncId)) .limit(1); return (rows[0]?.id as number | undefined) ?? null; }, /** A user's role ids, for the sharing filters above. */ async listUserRoleIds(userId: string): Promise { const drizzle = await client(); const { userRoles } = await refs(); const rows = await drizzle .select({ roleId: userRoles.roleId }) .from(userRoles) .where(eq(userRoles.userId, userId)); return rows.map((r: { roleId: number }) => r.roleId); }, }; }