515 lines
16 KiB
TypeScript
515 lines
16 KiB
TypeScript
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<string, unknown>;
|
|
|
|
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<string, unknown>): 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;
|
|
});
|
|
}
|