import { db } from '../db/client.js'; import { CHAT_CONVERSATION_TABLE, ensureChatConversationTables, } from './chat-room-service.js'; const CHAT_SHARE_TOKEN_ROOM_MAP_TABLE = 'chat_share_token_room_maps'; export type ChatShareTokenRoomMapItem = { tokenId: string; sessionId: string; rootRequestId: string; isDefault: boolean; sortOrder: number; createdByClientId: string | null; title: string; requestBadgeLabel: string | null; chatTypeId: string | null; lastChatTypeId: string | null; contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; linkContext: ChatShareRoomLinkContext | null; createdAt: string | null; updatedAt: string | null; conversationUpdatedAt: string | null; }; export type ChatShareRoomLinkContext = { kind: 'linked-session'; sourceSessionId: string; sourceRequestId: string; sourceTitle: string | null; sourceRequestPreview: string | null; sourceChatTypeLabel: string | null; linkedAt: string | null; }; function normalizeOptionalText(value: unknown) { if (typeof value !== 'string') { return null; } const normalized = value.trim(); return normalized || null; } function normalizeRequiredText(value: unknown) { if (typeof value !== 'string') { return ''; } return value.trim(); } function normalizeBoolean(value: unknown) { return value === true; } function normalizeInteger(value: unknown, fallback = 0) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return fallback; } return Math.trunc(parsed); } function normalizeDateTime(value: unknown) { if (value == null) { return null; } if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value.toISOString(); } if (typeof value === 'string') { const normalized = value.trim(); return normalized || null; } return null; } function parseChatShareRoomLinkContext(value: unknown) { if (typeof value !== 'string') { return null; } const normalized = value.trim(); if (!normalized) { return null; } try { const parsed = JSON.parse(normalized) as Record; if (parsed.kind !== 'linked-session') { return null; } const sourceSessionId = normalizeRequiredText(parsed.sourceSessionId); const sourceRequestId = normalizeRequiredText(parsed.sourceRequestId); if (!sourceSessionId || !sourceRequestId) { return null; } return { kind: 'linked-session', sourceSessionId, sourceRequestId, sourceTitle: normalizeOptionalText(parsed.sourceTitle), sourceRequestPreview: normalizeOptionalText(parsed.sourceRequestPreview), sourceChatTypeLabel: normalizeOptionalText(parsed.sourceChatTypeLabel), linkedAt: normalizeDateTime(parsed.linkedAt), } satisfies ChatShareRoomLinkContext; } catch { return null; } } function stringifyChatShareRoomLinkContext(value: ChatShareRoomLinkContext | null | undefined) { if (!value) { return null; } if (value.kind !== 'linked-session') { return null; } const sourceSessionId = normalizeRequiredText(value.sourceSessionId); const sourceRequestId = normalizeRequiredText(value.sourceRequestId); if (!sourceSessionId || !sourceRequestId) { return null; } return JSON.stringify({ kind: 'linked-session', sourceSessionId, sourceRequestId, sourceTitle: normalizeOptionalText(value.sourceTitle), sourceRequestPreview: normalizeOptionalText(value.sourceRequestPreview), sourceChatTypeLabel: normalizeOptionalText(value.sourceChatTypeLabel), linkedAt: normalizeDateTime(value.linkedAt), }); } function mapChatShareTokenRoomRow(row: Record): ChatShareTokenRoomMapItem { return { tokenId: normalizeRequiredText(row.shared_resource_token_id), sessionId: normalizeRequiredText(row.session_id), rootRequestId: normalizeRequiredText(row.root_request_id), isDefault: normalizeBoolean(row.is_default), sortOrder: normalizeInteger(row.sort_order), createdByClientId: normalizeOptionalText(row.created_by_client_id), title: normalizeRequiredText(row.title) || '공유 채팅방', requestBadgeLabel: normalizeOptionalText(row.request_badge_label), chatTypeId: normalizeOptionalText(row.chat_type_id), lastChatTypeId: normalizeOptionalText(row.last_chat_type_id), contextLabel: normalizeOptionalText(row.context_label), contextDescription: normalizeOptionalText(row.context_description), notifyOffline: normalizeBoolean(row.notify_offline), linkContext: parseChatShareRoomLinkContext(row.link_context_json), createdAt: normalizeDateTime(row.created_at), updatedAt: normalizeDateTime(row.updated_at), conversationUpdatedAt: normalizeDateTime(row.conversation_updated_at), }; } export async function ensureChatShareTokenRoomMapTable() { await ensureChatConversationTables(); const hasTable = await db.schema.hasTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE); if (!hasTable) { await db.schema.createTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, (table) => { table.increments('id').primary(); table.string('shared_resource_token_id', 120).notNullable().index(); table.string('session_id', 120).notNullable().index(); table.string('root_request_id', 120).notNullable(); table.boolean('is_default').notNullable().defaultTo(false); table.integer('sort_order').notNullable().defaultTo(0); table.string('created_by_client_id', 120).nullable(); table.timestamp('archived_at', { useTz: true }).nullable().index(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['shared_resource_token_id', 'session_id']); }); } const requiredColumns: Array<[string, (table: any) => void]> = [ ['shared_resource_token_id', (table) => table.string('shared_resource_token_id', 120).notNullable().index()], ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['root_request_id', (table) => table.string('root_request_id', 120).notNullable().defaultTo('')], ['is_default', (table) => table.boolean('is_default').notNullable().defaultTo(false)], ['sort_order', (table) => table.integer('sort_order').notNullable().defaultTo(0)], ['created_by_client_id', (table) => table.string('created_by_client_id', 120).nullable()], ['link_context_json', (table) => table.text('link_context_json').nullable()], ['archived_at', (table) => table.timestamp('archived_at', { useTz: true }).nullable().index()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredColumns) { const hasColumn = await db.schema.hasColumn(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, (table) => { createColumn(table); }); } } } export async function listChatShareTokenRoomMaps(tokenId: string) { const normalizedTokenId = tokenId.trim(); if (!normalizedTokenId) { return [] as ChatShareTokenRoomMapItem[]; } await ensureChatShareTokenRoomMapTable(); const rows = await db(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`) .leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id') .select( 'room_map.shared_resource_token_id', 'room_map.session_id', 'room_map.root_request_id', 'room_map.is_default', 'room_map.sort_order', 'room_map.created_by_client_id', 'room_map.created_at', 'room_map.updated_at', 'conversation.title', 'conversation.request_badge_label', 'conversation.chat_type_id', 'conversation.last_chat_type_id', 'conversation.context_label', 'conversation.context_description', 'conversation.notify_offline', 'room_map.link_context_json', 'conversation.updated_at as conversation_updated_at', ) .where({ 'room_map.shared_resource_token_id': normalizedTokenId }) .whereNull('room_map.archived_at') .orderBy('room_map.is_default', 'desc') .orderBy('room_map.sort_order', 'asc') .orderBy('room_map.created_at', 'asc'); return rows.map((row) => mapChatShareTokenRoomRow(row)); } export async function getChatShareTokenRoomMap(tokenId: string, sessionId: string) { const normalizedTokenId = tokenId.trim(); const normalizedSessionId = sessionId.trim(); if (!normalizedTokenId || !normalizedSessionId) { return null; } const rooms = await listChatShareTokenRoomMaps(normalizedTokenId); return rooms.find((item) => item.sessionId === normalizedSessionId) ?? null; } export async function upsertChatShareTokenRoomMap(args: { tokenId: string; sessionId: string; rootRequestId: string; isDefault?: boolean; sortOrder?: number | null; createdByClientId?: string | null; linkContext?: ChatShareRoomLinkContext | null; }) { const normalizedTokenId = args.tokenId.trim(); const normalizedSessionId = args.sessionId.trim(); const normalizedRootRequestId = args.rootRequestId.trim(); if (!normalizedTokenId || !normalizedSessionId || !normalizedRootRequestId) { return null; } await ensureChatShareTokenRoomMapTable(); await db.transaction(async (trx) => { const current = await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) .where({ shared_resource_token_id: normalizedTokenId, session_id: normalizedSessionId, }) .whereNull('archived_at') .first(); const maxSortOrderRow = await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) .where({ shared_resource_token_id: normalizedTokenId }) .whereNull('archived_at') .max<{ max_sort_order?: number | string | null }>('sort_order as max_sort_order') .first(); const nextSortOrder = args.sortOrder != null ? Math.max(0, Math.trunc(Number(args.sortOrder) || 0)) : Math.max(0, normalizeInteger(maxSortOrderRow?.max_sort_order) + (current ? 0 : 1)); if (args.isDefault === true) { await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) .where({ shared_resource_token_id: normalizedTokenId }) .whereNull('archived_at') .update({ is_default: false, updated_at: db.fn.now(), }); } const payload = { shared_resource_token_id: normalizedTokenId, session_id: normalizedSessionId, root_request_id: normalizedRootRequestId, is_default: args.isDefault === true, sort_order: nextSortOrder, created_by_client_id: normalizeOptionalText(args.createdByClientId), link_context_json: args.linkContext === undefined ? (current?.link_context_json ?? null) : stringifyChatShareRoomLinkContext(args.linkContext), archived_at: null, updated_at: db.fn.now(), }; if (current) { await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) .where({ shared_resource_token_id: normalizedTokenId, session_id: normalizedSessionId, }) .update(payload); return; } await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE).insert({ ...payload, created_at: db.fn.now(), }); }); return getChatShareTokenRoomMap(normalizedTokenId, normalizedSessionId); } export async function ensureDefaultChatShareTokenRoomMap(args: { tokenId: string; sessionId: string; rootRequestId: string; createdByClientId?: string | null; }) { const normalizedTokenId = args.tokenId.trim(); const normalizedSessionId = args.sessionId.trim(); const normalizedRootRequestId = args.rootRequestId.trim(); if (!normalizedTokenId || !normalizedSessionId || !normalizedRootRequestId) { return []; } const existing = await getChatShareTokenRoomMap(normalizedTokenId, normalizedSessionId); if (!existing) { await upsertChatShareTokenRoomMap({ tokenId: normalizedTokenId, sessionId: normalizedSessionId, rootRequestId: normalizedRootRequestId, isDefault: true, createdByClientId: args.createdByClientId ?? null, }); } const rooms = await listChatShareTokenRoomMaps(normalizedTokenId); if (rooms.some((item) => item.isDefault)) { return rooms; } await upsertChatShareTokenRoomMap({ tokenId: normalizedTokenId, sessionId: normalizedSessionId, rootRequestId: normalizedRootRequestId, isDefault: true, createdByClientId: args.createdByClientId ?? null, }); return listChatShareTokenRoomMaps(normalizedTokenId); } export async function resolveChatShareTokenRoomSessionIds(tokenId: string) { const rooms = await listChatShareTokenRoomMaps(tokenId); return rooms.map((item) => item.sessionId).filter(Boolean); } export async function archiveChatShareTokenRoomMap(tokenId: string, sessionId: string) { const normalizedTokenId = tokenId.trim(); const normalizedSessionId = sessionId.trim(); if (!normalizedTokenId || !normalizedSessionId) { return { archived: false, archivedRoom: null, nextDefaultRoom: null, } as const; } await ensureChatShareTokenRoomMapTable(); return db.transaction(async (trx) => { const current = await trx(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`) .leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id') .select( 'room_map.shared_resource_token_id', 'room_map.session_id', 'room_map.root_request_id', 'room_map.is_default', 'room_map.sort_order', 'room_map.created_by_client_id', 'room_map.created_at', 'room_map.updated_at', 'conversation.title', 'conversation.request_badge_label', 'conversation.chat_type_id', 'conversation.last_chat_type_id', 'conversation.context_label', 'conversation.context_description', 'conversation.notify_offline', 'conversation.updated_at as conversation_updated_at', ) .where({ 'room_map.shared_resource_token_id': normalizedTokenId, 'room_map.session_id': normalizedSessionId, }) .whereNull('room_map.archived_at') .first(); if (!current) { return { archived: false, archivedRoom: null, nextDefaultRoom: null, } as const; } await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) .where({ shared_resource_token_id: normalizedTokenId, session_id: normalizedSessionId, }) .whereNull('archived_at') .update({ archived_at: db.fn.now(), updated_at: db.fn.now(), }); let nextDefaultRoom: ChatShareTokenRoomMapItem | null = null; if (current.is_default) { const nextDefaultRow = await trx(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`) .leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id') .select( 'room_map.shared_resource_token_id', 'room_map.session_id', 'room_map.root_request_id', 'room_map.is_default', 'room_map.sort_order', 'room_map.created_by_client_id', 'room_map.created_at', 'room_map.updated_at', 'conversation.title', 'conversation.request_badge_label', 'conversation.chat_type_id', 'conversation.last_chat_type_id', 'conversation.context_label', 'conversation.context_description', 'conversation.notify_offline', 'conversation.updated_at as conversation_updated_at', ) .where({ 'room_map.shared_resource_token_id': normalizedTokenId }) .whereNull('room_map.archived_at') .orderBy('room_map.sort_order', 'asc') .orderBy('room_map.created_at', 'asc') .first(); if (nextDefaultRow) { await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE) .where({ shared_resource_token_id: normalizedTokenId, session_id: nextDefaultRow.session_id, }) .whereNull('archived_at') .update({ is_default: true, updated_at: db.fn.now(), }); nextDefaultRoom = mapChatShareTokenRoomRow({ ...nextDefaultRow, is_default: true, }); } } return { archived: true, archivedRoom: mapChatShareTokenRoomRow(current), nextDefaultRoom, } as const; }); }