396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
|
|
type LegacyMessageRow = {
|
|
id: string;
|
|
reactions: string | null;
|
|
};
|
|
|
|
type LegacyRoomRow = {
|
|
id: string;
|
|
channels: string | null;
|
|
members: string | null;
|
|
};
|
|
|
|
type ChannelType = 'text' | 'voice';
|
|
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
|
|
|
|
type LegacyReaction = {
|
|
id?: unknown;
|
|
oderId?: unknown;
|
|
userId?: unknown;
|
|
emoji?: unknown;
|
|
timestamp?: unknown;
|
|
};
|
|
|
|
type LegacyRoomChannel = {
|
|
id?: unknown;
|
|
name?: unknown;
|
|
type?: unknown;
|
|
position?: unknown;
|
|
};
|
|
|
|
type LegacyRoomMember = {
|
|
id?: unknown;
|
|
oderId?: unknown;
|
|
username?: unknown;
|
|
displayName?: unknown;
|
|
avatarUrl?: unknown;
|
|
role?: unknown;
|
|
joinedAt?: unknown;
|
|
lastSeenAt?: unknown;
|
|
};
|
|
|
|
function parseArray<T>(raw: string | null): T[] {
|
|
try {
|
|
const parsed = JSON.parse(raw || '[]');
|
|
|
|
return Array.isArray(parsed) ? parsed as T[] : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function isFiniteNumber(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value);
|
|
}
|
|
|
|
function normalizeChannelName(name: string): string {
|
|
return name.trim().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function channelNameKey(type: ChannelType, name: string): string {
|
|
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
|
}
|
|
|
|
function memberKey(member: { id?: string; oderId?: string }): string {
|
|
return member.oderId?.trim() || member.id?.trim() || '';
|
|
}
|
|
|
|
function fallbackDisplayName(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
|
return member.displayName || member.username || member.oderId || member.id || 'User';
|
|
}
|
|
|
|
function fallbackUsername(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
|
const base = fallbackDisplayName(member)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, '_');
|
|
|
|
return base || member.oderId || member.id || 'user';
|
|
}
|
|
|
|
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
|
|
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
|
|
? value
|
|
: 'member';
|
|
}
|
|
|
|
function mergeRoomMemberRole(
|
|
existingRole: RoomMemberRole,
|
|
incomingRole: RoomMemberRole,
|
|
preferIncoming: boolean
|
|
): RoomMemberRole {
|
|
if (existingRole === incomingRole) {
|
|
return existingRole;
|
|
}
|
|
|
|
if (incomingRole === 'member' && existingRole !== 'member') {
|
|
return existingRole;
|
|
}
|
|
|
|
if (existingRole === 'member' && incomingRole !== 'member') {
|
|
return incomingRole;
|
|
}
|
|
|
|
return preferIncoming ? incomingRole : existingRole;
|
|
}
|
|
|
|
function compareRoomMembers(
|
|
firstMember: {
|
|
id: string;
|
|
oderId?: string;
|
|
displayName: string;
|
|
},
|
|
secondMember: {
|
|
id: string;
|
|
oderId?: string;
|
|
displayName: string;
|
|
}
|
|
): number {
|
|
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
|
|
|
|
if (displayNameCompare !== 0) {
|
|
return displayNameCompare;
|
|
}
|
|
|
|
return memberKey(firstMember).localeCompare(memberKey(secondMember));
|
|
}
|
|
|
|
function normalizeMessageReactions(messageId: string, raw: string | null) {
|
|
const reactions = parseArray<LegacyReaction>(raw);
|
|
const seen = new Set<string>();
|
|
|
|
return reactions.flatMap((reaction) => {
|
|
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
|
|
const userId = typeof reaction.userId === 'string' ? reaction.userId : '';
|
|
const dedupeKey = `${userId}:${emoji}`;
|
|
|
|
if (!emoji || seen.has(dedupeKey)) {
|
|
return [];
|
|
}
|
|
|
|
seen.add(dedupeKey);
|
|
|
|
return [{
|
|
id: typeof reaction.id === 'string' && reaction.id.trim() ? reaction.id : randomUUID(),
|
|
messageId,
|
|
oderId: typeof reaction.oderId === 'string' ? reaction.oderId : null,
|
|
userId: userId || null,
|
|
emoji,
|
|
timestamp: isFiniteNumber(reaction.timestamp) ? reaction.timestamp : 0
|
|
}];
|
|
});
|
|
}
|
|
|
|
function normalizeRoomChannels(raw: string | null) {
|
|
const channels = parseArray<LegacyRoomChannel>(raw);
|
|
const seenIds = new Set<string>();
|
|
const seenNames = new Set<string>();
|
|
|
|
return channels.flatMap((channel, index) => {
|
|
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
|
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
|
|
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
|
const position = isFiniteNumber(channel.position) ? channel.position : index;
|
|
const nameKey = type ? channelNameKey(type, name) : '';
|
|
|
|
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
|
return [];
|
|
}
|
|
|
|
seenIds.add(id);
|
|
seenNames.add(nameKey);
|
|
|
|
return [{
|
|
channelId: id,
|
|
name,
|
|
type,
|
|
position
|
|
}];
|
|
});
|
|
}
|
|
|
|
function normalizeRoomMembers(raw: string | null, now = Date.now()) {
|
|
const members = parseArray<LegacyRoomMember>(raw);
|
|
const membersByKey = new Map<string, {
|
|
id: string;
|
|
oderId?: string;
|
|
username: string;
|
|
displayName: string;
|
|
avatarUrl?: string;
|
|
role: RoomMemberRole;
|
|
joinedAt: number;
|
|
lastSeenAt: number;
|
|
}>();
|
|
|
|
for (const rawMember of members) {
|
|
const normalizedId = typeof rawMember.id === 'string' ? rawMember.id.trim() : '';
|
|
const normalizedOderId = typeof rawMember.oderId === 'string' ? rawMember.oderId.trim() : '';
|
|
const key = normalizedOderId || normalizedId;
|
|
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
|
|
const lastSeenAt = isFiniteNumber(rawMember.lastSeenAt)
|
|
? rawMember.lastSeenAt
|
|
: isFiniteNumber(rawMember.joinedAt)
|
|
? rawMember.joinedAt
|
|
: now;
|
|
const joinedAt = isFiniteNumber(rawMember.joinedAt) ? rawMember.joinedAt : lastSeenAt;
|
|
const username = typeof rawMember.username === 'string' ? rawMember.username.trim() : '';
|
|
const displayName = typeof rawMember.displayName === 'string' ? rawMember.displayName.trim() : '';
|
|
const avatarUrl = typeof rawMember.avatarUrl === 'string' ? rawMember.avatarUrl.trim() : '';
|
|
const nextMember = {
|
|
id: normalizedId || key,
|
|
oderId: normalizedOderId || undefined,
|
|
username: username || fallbackUsername({ id: normalizedId || key, oderId: normalizedOderId || undefined, displayName }),
|
|
displayName: displayName || fallbackDisplayName({ id: normalizedId || key, oderId: normalizedOderId || undefined, username }),
|
|
avatarUrl: avatarUrl || undefined,
|
|
role: normalizeRoomMemberRole(rawMember.role),
|
|
joinedAt,
|
|
lastSeenAt
|
|
};
|
|
const existingMember = membersByKey.get(key);
|
|
|
|
if (!existingMember) {
|
|
membersByKey.set(key, nextMember);
|
|
continue;
|
|
}
|
|
|
|
const preferIncoming = nextMember.lastSeenAt >= existingMember.lastSeenAt;
|
|
|
|
membersByKey.set(key, {
|
|
id: existingMember.id || nextMember.id,
|
|
oderId: nextMember.oderId || existingMember.oderId,
|
|
username: preferIncoming
|
|
? (nextMember.username || existingMember.username)
|
|
: (existingMember.username || nextMember.username),
|
|
displayName: preferIncoming
|
|
? (nextMember.displayName || existingMember.displayName)
|
|
: (existingMember.displayName || nextMember.displayName),
|
|
avatarUrl: preferIncoming
|
|
? (nextMember.avatarUrl || existingMember.avatarUrl)
|
|
: (existingMember.avatarUrl || nextMember.avatarUrl),
|
|
role: mergeRoomMemberRole(existingMember.role, nextMember.role, preferIncoming),
|
|
joinedAt: Math.min(existingMember.joinedAt, nextMember.joinedAt),
|
|
lastSeenAt: Math.max(existingMember.lastSeenAt, nextMember.lastSeenAt)
|
|
});
|
|
}
|
|
|
|
return Array.from(membersByKey.values()).sort(compareRoomMembers);
|
|
}
|
|
|
|
export class NormalizeArrayColumns1000000000003 implements MigrationInterface {
|
|
name = 'NormalizeArrayColumns1000000000003';
|
|
|
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
await queryRunner.query(`
|
|
CREATE TABLE IF NOT EXISTS "room_channels" (
|
|
"roomId" TEXT NOT NULL,
|
|
"channelId" TEXT NOT NULL,
|
|
"name" TEXT NOT NULL,
|
|
"type" TEXT NOT NULL,
|
|
"position" INTEGER NOT NULL,
|
|
PRIMARY KEY ("roomId", "channelId")
|
|
)
|
|
`);
|
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channels_roomId" ON "room_channels" ("roomId")`);
|
|
|
|
await queryRunner.query(`
|
|
CREATE TABLE IF NOT EXISTS "room_members" (
|
|
"roomId" TEXT NOT NULL,
|
|
"memberKey" TEXT NOT NULL,
|
|
"id" TEXT NOT NULL,
|
|
"oderId" TEXT,
|
|
"username" TEXT NOT NULL,
|
|
"displayName" TEXT NOT NULL,
|
|
"avatarUrl" TEXT,
|
|
"role" TEXT NOT NULL,
|
|
"joinedAt" INTEGER NOT NULL,
|
|
"lastSeenAt" INTEGER NOT NULL,
|
|
PRIMARY KEY ("roomId", "memberKey")
|
|
)
|
|
`);
|
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_members_roomId" ON "room_members" ("roomId")`);
|
|
|
|
const messageRows = await queryRunner.query(`SELECT "id", "reactions" FROM "messages"`) as LegacyMessageRow[];
|
|
|
|
for (const row of messageRows) {
|
|
const reactions = normalizeMessageReactions(row.id, row.reactions);
|
|
|
|
for (const reaction of reactions) {
|
|
const existing = await queryRunner.query(
|
|
`SELECT 1 FROM "reactions" WHERE "messageId" = ? AND "userId" IS ? AND "emoji" = ? LIMIT 1`,
|
|
[reaction.messageId, reaction.userId, reaction.emoji]
|
|
) as Array<{ 1: number }>;
|
|
|
|
if (existing.length > 0) {
|
|
continue;
|
|
}
|
|
|
|
await queryRunner.query(
|
|
`INSERT INTO "reactions" ("id", "messageId", "oderId", "userId", "emoji", "timestamp") VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp]
|
|
);
|
|
}
|
|
}
|
|
|
|
const roomRows = await queryRunner.query(`SELECT "id", "channels", "members" FROM "rooms"`) as LegacyRoomRow[];
|
|
|
|
for (const row of roomRows) {
|
|
for (const channel of normalizeRoomChannels(row.channels)) {
|
|
await queryRunner.query(
|
|
`INSERT OR REPLACE INTO "room_channels" ("roomId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
|
|
[row.id, channel.channelId, channel.name, channel.type, channel.position]
|
|
);
|
|
}
|
|
|
|
for (const member of normalizeRoomMembers(row.members)) {
|
|
await queryRunner.query(
|
|
`INSERT OR REPLACE INTO "room_members" ("roomId", "memberKey", "id", "oderId", "username", "displayName", "avatarUrl", "role", "joinedAt", "lastSeenAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
row.id,
|
|
memberKey(member),
|
|
member.id,
|
|
member.oderId ?? null,
|
|
member.username,
|
|
member.displayName,
|
|
member.avatarUrl ?? null,
|
|
member.role,
|
|
member.joinedAt,
|
|
member.lastSeenAt
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
await queryRunner.query(`
|
|
CREATE TABLE "messages_next" (
|
|
"id" TEXT PRIMARY KEY NOT NULL,
|
|
"roomId" TEXT NOT NULL,
|
|
"channelId" TEXT,
|
|
"senderId" TEXT NOT NULL,
|
|
"senderName" TEXT NOT NULL,
|
|
"content" TEXT NOT NULL,
|
|
"timestamp" INTEGER NOT NULL,
|
|
"editedAt" INTEGER,
|
|
"isDeleted" INTEGER NOT NULL DEFAULT 0,
|
|
"replyToId" TEXT
|
|
)
|
|
`);
|
|
await queryRunner.query(`
|
|
INSERT INTO "messages_next" ("id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId")
|
|
SELECT "id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId"
|
|
FROM "messages"
|
|
`);
|
|
await queryRunner.query(`DROP TABLE "messages"`);
|
|
await queryRunner.query(`ALTER TABLE "messages_next" RENAME TO "messages"`);
|
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
|
|
|
|
await queryRunner.query(`
|
|
CREATE TABLE "rooms_next" (
|
|
"id" TEXT PRIMARY KEY NOT NULL,
|
|
"name" TEXT NOT NULL,
|
|
"description" TEXT,
|
|
"topic" TEXT,
|
|
"hostId" TEXT NOT NULL,
|
|
"password" TEXT,
|
|
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
|
"createdAt" INTEGER NOT NULL,
|
|
"userCount" INTEGER NOT NULL DEFAULT 0,
|
|
"maxUsers" INTEGER,
|
|
"icon" TEXT,
|
|
"iconUpdatedAt" INTEGER,
|
|
"permissions" TEXT,
|
|
"hasPassword" INTEGER NOT NULL DEFAULT 0,
|
|
"sourceId" TEXT,
|
|
"sourceName" TEXT,
|
|
"sourceUrl" TEXT
|
|
)
|
|
`);
|
|
await queryRunner.query(`
|
|
INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl")
|
|
SELECT "id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl"
|
|
FROM "rooms"
|
|
`);
|
|
await queryRunner.query(`DROP TABLE "rooms"`);
|
|
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
|
|
}
|
|
|
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`);
|
|
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
|
|
}
|
|
} |