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.
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
import type { D3roSupabaseClient, DictionaryEntry } from '@d3ro/api-client'
|
|
|
|
export type DictionaryCategory = DictionaryEntry['category']
|
|
export type DictionaryFilter = DictionaryCategory | 'all'
|
|
|
|
export interface DictionaryCursor {
|
|
updatedAt: string
|
|
id: string
|
|
}
|
|
|
|
export interface DictionaryDraft {
|
|
word: string
|
|
pronunciation: string | null
|
|
category: DictionaryCategory
|
|
}
|
|
|
|
export interface DictionaryPageResult {
|
|
entries: DictionaryEntry[]
|
|
nextCursor: DictionaryCursor | null
|
|
total: number
|
|
}
|
|
|
|
export type DictionaryClientErrorCode =
|
|
| 'auth'
|
|
| 'conflict'
|
|
| 'duplicate'
|
|
| 'network'
|
|
| 'validation'
|
|
| 'server'
|
|
|
|
export class DictionaryClientError extends Error {
|
|
constructor(
|
|
public readonly code: DictionaryClientErrorCode,
|
|
message: string
|
|
) {
|
|
super(message)
|
|
this.name = 'DictionaryClientError'
|
|
}
|
|
}
|
|
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
const CATEGORIES: readonly DictionaryCategory[] = ['user', 'technical', 'auto']
|
|
const MAX_WORD_LENGTH = 120
|
|
const MAX_PRONUNCIATION_LENGTH = 200
|
|
const MAX_PAGE_SIZE = 50
|
|
|
|
function requireUuid(value: string, code: 'auth' | 'validation', message: string): void {
|
|
if (!UUID_PATTERN.test(value)) throw new DictionaryClientError(code, message)
|
|
}
|
|
|
|
function normalizeText(value: string): string {
|
|
return value.trim().replace(/\s+/g, ' ')
|
|
}
|
|
|
|
export function sanitizeDictionarySearch(value: string): string {
|
|
return value
|
|
.trim()
|
|
.slice(0, 100)
|
|
.replace(/[%,()._'"\\]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
export function normalizeDictionaryDraft(draft: DictionaryDraft): DictionaryDraft {
|
|
const word = normalizeText(draft.word)
|
|
const pronunciation = normalizeText(draft.pronunciation ?? '') || null
|
|
if (word.length === 0 || word.length > MAX_WORD_LENGTH) {
|
|
throw new DictionaryClientError('validation', `단어는 1-${MAX_WORD_LENGTH}자로 입력해 주세요.`)
|
|
}
|
|
if (pronunciation !== null && pronunciation.length > MAX_PRONUNCIATION_LENGTH) {
|
|
throw new DictionaryClientError('validation', `발음은 ${MAX_PRONUNCIATION_LENGTH}자 이하여야 합니다.`)
|
|
}
|
|
if (!CATEGORIES.includes(draft.category)) {
|
|
throw new DictionaryClientError('validation', '사전 분류가 올바르지 않습니다.')
|
|
}
|
|
return { word, pronunciation, category: draft.category }
|
|
}
|
|
|
|
function mapDictionaryError(error: unknown): DictionaryClientError {
|
|
if (error instanceof DictionaryClientError) return error
|
|
const candidate = error as { code?: unknown; message?: unknown }
|
|
const databaseCode = typeof candidate?.code === 'string' ? candidate.code : ''
|
|
const message = typeof candidate?.message === 'string' ? candidate.message : 'Dictionary request failed'
|
|
const normalized = message.toLowerCase()
|
|
if (databaseCode === '23505') return new DictionaryClientError('duplicate', message)
|
|
if (
|
|
error instanceof TypeError
|
|
|| normalized.includes('failed to fetch')
|
|
|| normalized.includes('network request failed')
|
|
|| normalized.includes('networkerror')
|
|
) {
|
|
return new DictionaryClientError('network', message)
|
|
}
|
|
if (databaseCode === 'PGRST301' || databaseCode === '42501' || normalized.includes('jwt')) {
|
|
return new DictionaryClientError('auth', message)
|
|
}
|
|
if (databaseCode.startsWith('22')) return new DictionaryClientError('validation', message)
|
|
return new DictionaryClientError('server', message)
|
|
}
|
|
|
|
function assertOwned(userId: string, entry: DictionaryEntry): void {
|
|
requireUuid(userId, 'auth', 'Authenticated user is invalid')
|
|
requireUuid(entry.id, 'validation', 'Dictionary entry id is invalid')
|
|
if (entry.user_id !== userId) {
|
|
throw new DictionaryClientError('auth', 'Dictionary ownership could not be verified')
|
|
}
|
|
}
|
|
|
|
function escapeLikePattern(value: string): string {
|
|
return value.replace(/[\\%_]/g, (character) => `\\${character}`)
|
|
}
|
|
|
|
async function findDuplicate(
|
|
client: D3roSupabaseClient,
|
|
userId: string,
|
|
draft: DictionaryDraft,
|
|
excludingId?: string
|
|
): Promise<boolean> {
|
|
let query = client
|
|
.from('dictionary')
|
|
.select('id')
|
|
.eq('user_id', userId)
|
|
.eq('category', draft.category)
|
|
.ilike('word', escapeLikePattern(draft.word))
|
|
.limit(1)
|
|
if (excludingId) query = query.neq('id', excludingId)
|
|
const { data, error } = await query.maybeSingle()
|
|
if (error) throw error
|
|
return data !== null
|
|
}
|
|
|
|
export async function listDictionaryPage(
|
|
client: D3roSupabaseClient,
|
|
options: {
|
|
userId: string
|
|
search: string
|
|
category: DictionaryFilter
|
|
pageSize: number
|
|
cursor: DictionaryCursor | null
|
|
}
|
|
): Promise<DictionaryPageResult> {
|
|
requireUuid(options.userId, 'auth', 'Authenticated user is invalid')
|
|
if (options.category !== 'all' && !CATEGORIES.includes(options.category)) {
|
|
throw new DictionaryClientError('validation', 'Dictionary category is invalid')
|
|
}
|
|
if (options.cursor) {
|
|
requireUuid(options.cursor.id, 'validation', 'Dictionary cursor id is invalid')
|
|
if (!Number.isFinite(Date.parse(options.cursor.updatedAt))) {
|
|
throw new DictionaryClientError('validation', 'Dictionary cursor date is invalid')
|
|
}
|
|
}
|
|
const pageSize = Math.max(1, Math.min(options.pageSize, MAX_PAGE_SIZE))
|
|
|
|
try {
|
|
let query = client
|
|
.from('dictionary')
|
|
.select('*', { count: 'exact' })
|
|
.eq('user_id', options.userId)
|
|
.order('updated_at', { ascending: false })
|
|
.order('id', { ascending: false })
|
|
.limit(pageSize + 1)
|
|
if (options.category !== 'all') query = query.eq('category', options.category)
|
|
const search = sanitizeDictionarySearch(options.search)
|
|
if (search) {
|
|
const pattern = `%${search}%`
|
|
query = query.or(`word.ilike.${pattern},pronunciation.ilike.${pattern}`)
|
|
}
|
|
if (options.cursor) {
|
|
query = query.or(
|
|
`updated_at.lt.${options.cursor.updatedAt},and(updated_at.eq.${options.cursor.updatedAt},id.lt.${options.cursor.id})`
|
|
)
|
|
}
|
|
const { data, error, count } = await query
|
|
if (error) throw error
|
|
const rows = data ?? []
|
|
for (const row of rows) assertOwned(options.userId, row)
|
|
const hasMore = rows.length > pageSize
|
|
const entries = hasMore ? rows.slice(0, pageSize) : rows
|
|
const last = entries.at(-1)
|
|
return {
|
|
entries,
|
|
nextCursor: hasMore && last ? { updatedAt: last.updated_at, id: last.id } : null,
|
|
total: count ?? entries.length
|
|
}
|
|
} catch (error) {
|
|
throw mapDictionaryError(error)
|
|
}
|
|
}
|
|
|
|
export async function createDictionaryEntry(
|
|
client: D3roSupabaseClient,
|
|
userId: string,
|
|
draft: DictionaryDraft
|
|
): Promise<DictionaryEntry> {
|
|
requireUuid(userId, 'auth', 'Authenticated user is invalid')
|
|
const normalized = normalizeDictionaryDraft(draft)
|
|
try {
|
|
if (await findDuplicate(client, userId, normalized)) {
|
|
throw new DictionaryClientError('duplicate', 'Duplicate dictionary entry')
|
|
}
|
|
const { data, error } = await client
|
|
.from('dictionary')
|
|
.insert({ user_id: userId, ...normalized })
|
|
.select('*')
|
|
.single()
|
|
if (error) throw error
|
|
assertOwned(userId, data)
|
|
return data
|
|
} catch (error) {
|
|
throw mapDictionaryError(error)
|
|
}
|
|
}
|
|
|
|
export async function updateDictionaryEntry(
|
|
client: D3roSupabaseClient,
|
|
userId: string,
|
|
current: DictionaryEntry,
|
|
draft: DictionaryDraft
|
|
): Promise<DictionaryEntry> {
|
|
assertOwned(userId, current)
|
|
const normalized = normalizeDictionaryDraft(draft)
|
|
try {
|
|
if (await findDuplicate(client, userId, normalized, current.id)) {
|
|
throw new DictionaryClientError('duplicate', 'Duplicate dictionary entry')
|
|
}
|
|
const { data, error } = await client
|
|
.from('dictionary')
|
|
.update({
|
|
word: normalized.word,
|
|
pronunciation: normalized.pronunciation,
|
|
category: normalized.category
|
|
})
|
|
.eq('user_id', userId)
|
|
.eq('id', current.id)
|
|
.eq('updated_at', current.updated_at)
|
|
.select('*')
|
|
.maybeSingle()
|
|
if (error) throw error
|
|
if (!data) throw new DictionaryClientError('conflict', 'Dictionary entry changed')
|
|
assertOwned(userId, data)
|
|
return data
|
|
} catch (error) {
|
|
throw mapDictionaryError(error)
|
|
}
|
|
}
|
|
|
|
export async function deleteDictionaryEntry(
|
|
client: D3roSupabaseClient,
|
|
userId: string,
|
|
current: DictionaryEntry
|
|
): Promise<void> {
|
|
assertOwned(userId, current)
|
|
try {
|
|
const { data, error } = await client
|
|
.from('dictionary')
|
|
.delete()
|
|
.eq('user_id', userId)
|
|
.eq('id', current.id)
|
|
.eq('updated_at', current.updated_at)
|
|
.select('id')
|
|
.maybeSingle()
|
|
if (error) throw error
|
|
if (!data) throw new DictionaryClientError('conflict', 'Dictionary entry changed')
|
|
} catch (error) {
|
|
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
|
|
}
|