diff --git a/apps/api/src/repository.ts b/apps/api/src/repository.ts index 61516a7..921a07b 100644 --- a/apps/api/src/repository.ts +++ b/apps/api/src/repository.ts @@ -1,35 +1,36 @@ import { randomUUID } from 'node:crypto'; import type Database from 'better-sqlite3'; -import { topicRevisionInputSchema, type TopicDetail, type TopicListItem, type TopicRevisionInput } from '@tech-library/contracts'; +import { topicRevisionInputSchema, type Language, type TopicDetail, type TopicListItem, type TopicRevisionInput } from '@tech-library/contracts'; const parse = (value: string): T => JSON.parse(value) as T; export class LibraryRepository { constructor(private readonly db: Database.Database) {} - listTopics({ q = '', domain, type, page = 1, pageSize = 24 }: { q?: string; domain?: string; type?: string; page?: number; pageSize?: number }) { + listTopics({ q = '', domain, type, page = 1, pageSize = 24, language = 'en', userId }: { q?: string; domain?: string; type?: string; page?: number; pageSize?: number; language?: Language; userId?: string }) { const params: unknown[] = []; let where = 'WHERE t.active_revision_id IS NOT NULL'; - if (q) { where += ' AND t.id IN (SELECT topic_id FROM topic_search WHERE topic_search MATCH ?)'; params.push(q.replace(/["']/g, '')); } - if (domain) { where += " AND json_extract(r.payload, '$.domains') LIKE ?"; params.push(`%${domain}%`); } - if (type) { where += " AND json_extract(r.payload, '$.type') = ?"; params.push(type); } - const base = `FROM topics t JOIN revisions r ON r.id = t.active_revision_id ${where}`; - const total = Number((this.db.prepare(`SELECT count(*) AS count ${base}`).get(...params) as { count: number }).count); - const rows = this.db.prepare(`SELECT t.id, t.slug, r.payload, COALESCE(r.published_at, r.reviewed_at, r.created_at) verified_at ${base} ORDER BY json_extract(r.payload, '$.title') LIMIT ? OFFSET ?`).all(...params, pageSize, (page - 1) * pageSize) as Array>; - return { items: rows.map(toListItem), total, page, pageSize }; + const localized = "CASE WHEN ?='en' THEN r.payload ELSE COALESCE(tr.payload,r.payload) END"; + if (q) { where += ` AND (${localized} LIKE ? OR t.id IN (SELECT topic_id FROM topic_search WHERE topic_search MATCH ?))`; params.push(language, `%${q}%`, q.replace(/["']/g, '')); } + if (domain) { where += ` AND json_extract(${localized}, '$.domains') LIKE ?`; params.push(language, `%${domain}%`); } + if (type) { where += ` AND json_extract(${localized}, '$.type') = ?`; params.push(language, type); } + const joins = `FROM topics t JOIN revisions r ON r.id = t.active_revision_id LEFT JOIN topic_translations tr ON tr.revision_id=r.id AND tr.language='da'`; + const total = Number((this.db.prepare(`SELECT count(*) AS count ${joins} ${where}`).get(...params) as { count: number }).count); + const rows = this.db.prepare(`SELECT t.id, t.slug, ${localized} payload, tr.id translation_id, COALESCE(r.published_at, r.reviewed_at, r.created_at) verified_at, CASE WHEN f.user_id IS NULL THEN 0 ELSE 1 END is_favorite ${joins} LEFT JOIN favorites f ON f.topic_id=t.id AND f.user_id=? ${where} ORDER BY json_extract(${localized}, '$.title') LIMIT ? OFFSET ?`).all(language, userId ?? '', ...params, language, pageSize, (page - 1) * pageSize) as Array>; + return { items: rows.map((row) => toListItem(row, language)), total, page, pageSize }; } - getTopic(slug: string): TopicDetail | null { - const row = this.db.prepare(`SELECT t.id, t.slug, r.id revision_id, r.number, r.payload, COALESCE(r.published_at, r.reviewed_at, r.created_at) verified_at FROM topics t JOIN revisions r ON r.id = t.active_revision_id WHERE t.slug = ?`).get(slug) as Record | undefined; + getTopic(slug: string, language: Language = 'en', userId?: string): TopicDetail | null { + const row = this.db.prepare(`SELECT t.id, t.slug, r.id revision_id, r.number, CASE WHEN ?='en' THEN r.payload ELSE COALESCE(tr.payload,r.payload) END payload, tr.id translation_id, CASE WHEN f.user_id IS NULL THEN 0 ELSE 1 END is_favorite, COALESCE(r.published_at, r.reviewed_at, r.created_at) verified_at FROM topics t JOIN revisions r ON r.id = t.active_revision_id LEFT JOIN topic_translations tr ON tr.revision_id=r.id AND tr.language=? LEFT JOIN favorites f ON f.topic_id=t.id AND f.user_id=? WHERE t.slug = ?`).get(language, language, userId ?? '', slug) as Record | undefined; if (!row) return null; - const item = toListItem(row as Record); + const item = toListItem(row as Record, language); const data = parse(String(row.payload)); const sourceRows = this.db.prepare(`SELECT s.* FROM sources s JOIN revision_sources rs ON rs.source_id=s.id WHERE rs.revision_id=? ORDER BY s.verified_at DESC`).all(row.revision_id) as Array>; const relationRows = this.db.prepare(`SELECT rel.type, t.id, t.slug, r.payload, COALESCE(r.published_at,r.created_at) verified_at FROM relations rel JOIN topics t ON t.id=rel.target_topic_id JOIN revisions r ON r.id=t.active_revision_id WHERE rel.source_topic_id=?`).all(row.id) as Array>; return { ...item, aliases: data.aliases, audience: data.audience, supportedVersions: data.supportedVersions, content: data.content, revisionId: String(row.revision_id), revisionNumber: Number(row.number), - relations: relationRows.map((relation) => ({ type: relation.type as TopicDetail['relations'][number]['type'], topic: toListItem(relation) })), + relations: relationRows.map((relation) => ({ type: relation.type as TopicDetail['relations'][number]['type'], topic: toListItem(relation, 'en') })), sources: sourceRows.map((source) => ({ id: source.id!, title: source.title!, url: source.url!, publisher: source.publisher!, type: source.source_type as TopicDetail['sources'][number]['type'], verifiedAt: source.verified_at!, claims: parse(source.claims!) })) }; } @@ -116,9 +117,9 @@ export class LibraryRepository { } } -function toListItem(row: Record): TopicListItem { +function toListItem(row: Record, language: Language): TopicListItem { const data = parse(row.payload!); - return { id: row.id!, slug: row.slug!, title: data.title, summary: data.summary, type: data.type, domains: data.domains, tags: data.tags, currentVersion: data.currentVersion, verifiedAt: row.verified_at! }; + return { id: row.id!, slug: row.slug!, title: data.title, summary: data.summary, type: data.type, domains: data.domains, tags: data.tags, currentVersion: data.currentVersion, verifiedAt: row.verified_at!, language: row.translation_id || language === 'en' ? language : 'en', sourceLanguage: 'en', translationFallback: language !== 'en' && !row.translation_id, isFavorite: Boolean(Number(row.is_favorite ?? 0)) }; } function countValues(values: string[]) { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6a4ee94..7272910 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -39,6 +39,8 @@ export type TopicSection = z.infer; export type TopicType = (typeof topicTypes)[number]; export type RelationType = (typeof relationTypes)[number]; export type SourceType = (typeof sourceTypes)[number]; +export const languages = ['en', 'da'] as const; +export type Language = (typeof languages)[number]; export interface TopicListItem { id: string; @@ -50,6 +52,10 @@ export interface TopicListItem { tags: string[]; currentVersion: string | null; verifiedAt: string; + language: Language; + sourceLanguage: 'en'; + translationFallback: boolean; + isFavorite?: boolean; } export interface TopicDetail extends TopicListItem { diff --git a/packages/database/src/migrations.ts b/packages/database/src/migrations.ts index bcf23e5..d7ab422 100644 --- a/packages/database/src/migrations.ts +++ b/packages/database/src/migrations.ts @@ -15,7 +15,13 @@ const migrations = [ CREATE VIRTUAL TABLE IF NOT EXISTS topic_search USING fts5(topic_id UNINDEXED, title, summary, aliases, content, tags, tokenize='porter unicode61'); CREATE INDEX IF NOT EXISTS revisions_topic_id ON revisions(topic_id); CREATE INDEX IF NOT EXISTS findings_status ON findings(status); - CREATE INDEX IF NOT EXISTS relations_source ON relations(source_topic_id);` + CREATE INDEX IF NOT EXISTS relations_source ON relations(source_topic_id);`, + `CREATE TABLE IF NOT EXISTS topic_translations (id TEXT PRIMARY KEY, revision_id TEXT NOT NULL REFERENCES revisions(id), language TEXT NOT NULL, payload TEXT NOT NULL, source_revision_id TEXT NOT NULL REFERENCES revisions(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(revision_id, language)); + CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS user_sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_token TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, topic_id)); + CREATE INDEX IF NOT EXISTS user_sessions_user ON user_sessions(user_id); + CREATE INDEX IF NOT EXISTS favorites_user ON favorites(user_id);` ]; export function migrate(sqlite: Database.Database): void { diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index fb3a259..38521aa 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -98,3 +98,34 @@ export const workerJobs = sqliteTable('worker_jobs', { completedAt: text('completed_at'), result: text('result', { mode: 'json' }) }); + +export const topicTranslations = sqliteTable('topic_translations', { + id: text('id').primaryKey(), + revisionId: text('revision_id').notNull().references(() => revisions.id), + language: text('language').notNull(), + payload: text('payload', { mode: 'json' }).notNull(), + sourceRevisionId: text('source_revision_id').notNull().references(() => revisions.id), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull() +}, (table) => [uniqueIndex('translation_revision_language').on(table.revisionId, table.language)]); + +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + username: text('username').notNull().unique(), + passwordHash: text('password_hash').notNull(), + createdAt: text('created_at').notNull() +}); + +export const userSessions = sqliteTable('user_sessions', { + id: text('id').primaryKey(), + userId: text('user_id').notNull().references(() => users.id), + csrfToken: text('csrf_token').notNull(), + expiresAt: text('expires_at').notNull(), + createdAt: text('created_at').notNull() +}); + +export const favorites = sqliteTable('favorites', { + userId: text('user_id').notNull().references(() => users.id), + topicId: text('topic_id').notNull().references(() => topics.id), + createdAt: text('created_at').notNull() +}, (table) => [uniqueIndex('favorite_user_topic').on(table.userId, table.topicId)]); diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index 600da61..24078b1 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -68,6 +68,7 @@ export function seedDatabase(sqlite: Database.Database): number { const insertSource = sqlite.prepare('INSERT OR IGNORE INTO sources(id, url, publisher, source_type, title, fetched_at, verified_at, fingerprint, claims) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); const linkSource = sqlite.prepare('INSERT OR IGNORE INTO revision_sources(revision_id, source_id) VALUES (?, ?)'); const indexSearch = sqlite.prepare('INSERT INTO topic_search(topic_id, title, summary, aliases, content, tags) VALUES (?, ?, ?, ?, ?, ?)'); + const insertTranslation = sqlite.prepare('INSERT OR IGNORE INTO topic_translations(id,revision_id,language,payload,source_revision_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)'); const countBefore = Number((sqlite.prepare('SELECT count(*) AS count FROM topics').get() as { count: number }).count); sqlite.transaction(() => { @@ -80,6 +81,7 @@ export function seedDatabase(sqlite: Database.Database): number { insertRevision.run(revisionId, topicId, 'approved', JSON.stringify(data), data.changeNote, now, now, now); insertSource.run(sourceId, item.url, item.publisher, 'documentation', `${item.title} official documentation`, now, now, createHash('sha256').update(item.url).digest('hex'), JSON.stringify(['Official definition and current technical guidance'])); linkSource.run(revisionId, sourceId); + insertTranslation.run(`translation_${item.slug}_da`, revisionId, 'da', JSON.stringify(danishPayload(data, item)), revisionId, now, now); if (!(sqlite.prepare('SELECT 1 FROM topic_search WHERE topic_id = ?').get(topicId))) { indexSearch.run(topicId, data.title, data.summary, data.aliases.join(' '), JSON.stringify(data.content), data.tags.join(' ')); } @@ -89,3 +91,24 @@ export function seedDatabase(sqlite: Database.Database): number { const countAfter = Number((sqlite.prepare('SELECT count(*) AS count FROM topics').get() as { count: number }).count); return countAfter - countBefore; } + +function danishPayload(source: TopicRevisionInput, topic: SeedTopic): TopicRevisionInput { + const domain = ({ 'Web platform': 'Webplatform', 'Frontend and UI': 'Frontend og UI', 'Backend and APIs': 'Backend og API’er', Data: 'Data', 'Mobile and desktop': 'Mobil og desktop', 'DevOps and cloud': 'DevOps og cloud', 'Testing and observability': 'Test og observability', 'Security and privacy': 'Sikkerhed og privacy' } as Record)[topic.domain] ?? topic.domain; + return { + ...source, domains: [domain], + summary: `${topic.title} er et centralt emne inden for ${domain.toLowerCase()}, med fokus på praktisk anvendelse, kompatibilitet og dokumenterede tekniske valg.`, + content: { + ...source.content, + introduction: `${topic.title} er en vigtig del af moderne softwareudvikling. Opslaget giver et praktisk overblik over teknologien, dens trade-offs og dens officielle kilder.`, + concepts: ['Kernemodel og terminologi', 'Økosystem og interoperabilitet', 'Livscyklus og vedligeholdelse'], + useCases: [`Vurdér ${topic.title} til nye og eksisterende systemer`, 'Forstå integrationsgrænser og driftsmæssig påvirkning'], + benefits: ['Underbygget af en officiel, sporbar primærkilde', 'Præsenteret i lag til læsere med forskellig erfaring'], + limitations: ['Præcise muligheder og support varierer efter version og platform'], + avoidWhen: ['Driftsomkostninger eller kompatibilitetskrav overstiger værdien for projektet'], + compatibility: 'Se den officielle dokumentation for aktuel platform- og versionskompatibilitet.', + requirements: ['Kontrollér understøttede versioner før anvendelse', 'Test i det planlagte produktionsmiljø'], + securityNotes: ['Gennemgå officiel sikkerhedsvejledning og supportpolitik før produktionsbrug.'] + }, + changeNote: 'Første dansk oversættelse baseret på den engelske kilderevision' + }; +}