feat(web): manage dictionaries and teams from the console

The console could not create dictionary entries, and team pages showed a
static member list with no record of who changed what. Dictionary
management, a knowledge upload form, and a team activity feed are now
available, alongside a download center that links the published desktop
installer feed rather than repository-local paths that no deploy ships.

Red-team e2e coverage was added for the account and team flows touched here.
This commit is contained in:
Yun Chan 2026-09-16 23:24:27 +09:00
parent f6a29db95a
commit cfc58458a8
21 changed files with 985 additions and 125 deletions

View file

@ -265,3 +265,178 @@ export async function deleteDictionaryEntry(
throw mapDictionaryError(error)
}
}
export interface DictionaryImportResult {
imported: number
skipped: number
}
const CSV_HEADER = ['word', 'pronunciation', 'category', 'usageCount', 'createdAt', 'updatedAt']
function csvCell(value: unknown): string {
const text = value === null || value === undefined ? '' : String(value)
const escaped = text.replace(/"/g, '""')
const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped)
return needsQuotes ? `"${escaped}"` : escaped
}
function parseCsvRows(input: string): string[][] {
const rows: string[][] = []
let row: string[] = []
let field = ''
let inQuotes = false
for (let i = 0; i < input.length; i += 1) {
const char = input[i]
if (inQuotes) {
if (char === '"') {
if (input[i + 1] === '"') {
field += '"'
i += 1
} else {
inQuotes = false
}
} else {
field += char
}
continue
}
if (char === '"') {
inQuotes = true
} else if (char === ',') {
row.push(field)
field = ''
} else if (char === '\n') {
row.push(field)
rows.push(row)
row = []
field = ''
} else if (char !== '\r') {
field += char
}
}
if (field.length > 0 || row.length > 0) {
row.push(field)
rows.push(row)
}
return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0))
}
function coerceDraft(record: Record<string, unknown>): DictionaryDraft | null {
const word = typeof record.word === 'string' ? record.word : ''
const pronunciation = typeof record.pronunciation === 'string' && record.pronunciation.trim() !== ''
? record.pronunciation
: null
const category = typeof record.category === 'string' ? record.category : 'user'
try {
return normalizeDictionaryDraft({
word,
pronunciation,
category: category as DictionaryCategory
})
} catch {
return null
}
}
export function serializeDictionary(entries: DictionaryEntry[], format: 'json' | 'csv'): string {
if (format === 'json') {
return JSON.stringify(
{
entries: entries.map((entry) => ({
word: entry.word,
pronunciation: entry.pronunciation,
category: entry.category,
usageCount: entry.usage_count,
lastUsedAt: entry.last_used_at ? Date.parse(entry.last_used_at) : null,
createdAt: Date.parse(entry.created_at),
updatedAt: Date.parse(entry.updated_at)
}))
},
null,
2
)
}
const lines = [CSV_HEADER.join(',')]
for (const entry of entries) {
lines.push(
[
csvCell(entry.word),
csvCell(entry.pronunciation ?? ''),
csvCell(entry.category),
csvCell(entry.usage_count),
csvCell(Date.parse(entry.created_at)),
csvCell(Date.parse(entry.updated_at))
].join(',')
)
}
return `\uFEFF${lines.join('\r\n')}\r\n`
}
export function parseDictionaryFile(raw: string, format: 'json' | 'csv'): DictionaryDraft[] {
const drafts: DictionaryDraft[] = []
if (format === 'json') {
let data: unknown
try {
data = JSON.parse(raw)
} catch {
throw new DictionaryClientError('validation', '사전 JSON 형식이 올바르지 않습니다.')
}
const list = Array.isArray(data)
? data
: data && typeof data === 'object' && Array.isArray((data as { entries?: unknown }).entries)
? (data as { entries: unknown[] }).entries
: null
if (!list) throw new DictionaryClientError('validation', '사전 JSON에 entries 배열이 없습니다.')
for (const item of list) {
if (!item || typeof item !== 'object') continue
const draft = coerceDraft(item as Record<string, unknown>)
if (draft) drafts.push(draft)
}
} else {
const rows = parseCsvRows(raw.replace(/^\uFEFF/, ''))
if (rows.length === 0) throw new DictionaryClientError('validation', '빈 CSV 파일입니다.')
const header = rows[0].map((cell) => cell.trim())
if (!header.includes('word')) {
throw new DictionaryClientError('validation', 'CSV에 word 열이 없습니다.')
}
for (const cells of rows.slice(1)) {
const record: Record<string, unknown> = {}
header.forEach((key, index) => {
record[key] = cells[index] ?? ''
})
const draft = coerceDraft(record)
if (draft) drafts.push(draft)
}
}
if (drafts.length === 0) {
throw new DictionaryClientError('validation', '가져올 유효한 단어가 없습니다.')
}
return drafts
}
export async function importDictionaryFile(
client: D3roSupabaseClient,
userId: string,
raw: string,
format: 'json' | 'csv'
): Promise<DictionaryImportResult> {
requireUuid(userId, 'auth', 'Authenticated user is invalid')
const drafts = parseDictionaryFile(raw, format)
const result: DictionaryImportResult = { imported: 0, skipped: 0 }
for (const draft of drafts) {
try {
await createDictionaryEntry(client, userId, draft)
result.imported += 1
} catch (error) {
if (
error instanceof DictionaryClientError &&
(error.code === 'duplicate' || error.code === 'conflict')
) {
result.skipped += 1
continue
}
throw error
}
}
return result
}