diff --git a/new-client/.claude/settings.local.json b/new-client/.claude/settings.local.json new file mode 100644 index 0000000..1c53390 --- /dev/null +++ b/new-client/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(python -c \"import sys,json; p=json.load\\(sys.stdin\\); deps={**p.get\\('dependencies',{}\\), **p.get\\('devDependencies',{}\\)}; [print\\(k,v\\) for k in sorted\\(deps\\) if any\\(x in k for x in ['mediasoup','socket','vueuse','vue']\\)]\")" + ] + } +} diff --git a/new-client/src/app/bootstrap/mediasoup.ts b/new-client/src/app/bootstrap/mediasoup.ts new file mode 100644 index 0000000..ae5bf6b --- /dev/null +++ b/new-client/src/app/bootstrap/mediasoup.ts @@ -0,0 +1,137 @@ +import { useMediasoup } from '@shared/composables/use-mediasoup' +import { useSignaling } from '@shared/composables/use-signaling' +import { Device } from 'mediasoup-client' +import { markRaw, watch } from 'vue' + +const ICE_SERVERS: RTCIceServer[] = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun.l.google.com:5349' }, + { urls: 'stun:stun1.l.google.com:3478' }, + { urls: 'stun:stun1.l.google.com:5349' }, + { urls: 'stun:stun2.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:5349' }, + { urls: 'stun:stun3.l.google.com:3478' }, + { urls: 'stun:stun3.l.google.com:5349' }, + { urls: 'stun:stun4.l.google.com:19302' }, + { urls: 'stun:stun4.l.google.com:5349' }, +] + +export default function () { + const { socket } = useSignaling() + const ms = useMediasoup() + + watch(socket, (socket) => { + if (!socket) + return + + socket.on('initialized', async (initData) => { + ms.device.value = new Device() + await ms.device.value.load({ routerRtpCapabilities: initData.rtpCapabilities }) + + const sendInfo = await socket.emitWithAck('create-transport', { producing: true, consuming: false }) + const sendTransport = ms.device.value.createSendTransport({ + ...sendInfo, + iceServers: [...ICE_SERVERS, ...(sendInfo.iceServers ?? [])], + }) + ms.sendTransport.value = sendTransport + await ms.transports.add({ + id: sendTransport.id, + ref: markRaw(sendTransport), + }) + + sendTransport.on('connect', async ({ dtlsParameters }, callback, errback) => { + try { + await socket.emitWithAck('connect-transport', { transportId: sendTransport.id, dtlsParameters }) + callback() + } + catch (error) { + if (error instanceof Error) + errback(error) + } + }) + + sendTransport.on('produce', async ({ kind, rtpParameters, appData }, callback, errback) => { + try { + const { id } = await socket.emitWithAck('produce', { + transportId: sendTransport.id, + kind, + rtpParameters, + appData, + }) + callback({ id }) + } + catch (error) { + if (error instanceof Error) + errback(error) + } + }) + + const recvInfo = await socket.emitWithAck('create-transport', { producing: false, consuming: true }) + const recvTransport = ms.device.value.createRecvTransport({ + ...recvInfo, + iceServers: [...ICE_SERVERS, ...(recvInfo.iceServers ?? [])], + }) + ms.recvTransport.value = recvTransport + await ms.transports.add({ + id: recvTransport.id, + ref: markRaw(recvTransport), + }) + + recvTransport.on('connect', async ({ dtlsParameters }, callback, errback) => { + try { + await socket.emitWithAck('connect-transport', { transportId: recvTransport.id, dtlsParameters }) + callback() + } + catch (error) { + if (error instanceof Error) + errback(error) + } + }) + }) + + socket.on('new-consumer', async ({ id, producerId, kind, rtpParameters, socketId, appData, producerPaused }, cb) => { + const rt = ms.recvTransport.value + if (!rt) + return + + const consumer = await rt.consume({ + id, + producerId, + kind, + rtpParameters, + streamId: `${socketId}-${appData.source ?? 'stream'}`, + appData: { ...appData, socketId }, + }) + + if (producerPaused) + consumer.pause() + + await ms.consumers.add({ + id: consumer.id, + paused: consumer.paused, + kind: consumer.kind, + appData: consumer.appData as Record, + track: consumer.track, + ref: markRaw(consumer), + }) + + cb() + }) + + socket.on('consumer-closed', ({ consumerId }) => { + ms.consumers.get(consumerId)?.ref.close() + }) + + socket.on('consumer-paused', ({ consumerId }) => { + ms.consumers.get(consumerId)?.ref.pause() + }) + + socket.on('consumer-resumed', ({ consumerId }) => { + ms.consumers.get(consumerId)?.ref.resume() + }) + + socket.on('disconnect', () => { + ms.clearAll() + }) + }, { immediate: true }) +} diff --git a/new-client/src/app/bootstrap/signaling.ts b/new-client/src/app/bootstrap/signaling.ts new file mode 100644 index 0000000..6dbf940 --- /dev/null +++ b/new-client/src/app/bootstrap/signaling.ts @@ -0,0 +1,50 @@ +import type { ChatMessage } from '@shared/api/generated-chad-api.ts' +import { useAuth } from '@shared/composables/use-auth.ts' +import { useClients } from '@shared/composables/use-clients' +import { useEventBus } from '@shared/composables/use-event-bus.ts' +import { useSignaling } from '@shared/composables/use-signaling' +import { watch, watchEffect } from 'vue' + +export default function () { + const { authorized } = useAuth() + const signaling = useSignaling() + const { addClient, updateClient, removeClient, clear: clearClients } = useClients() + const eventBus = useEventBus() + + watch(signaling.connected, (connected) => { + if (!connected) + return + + const socket = signaling.socket.value! + + socket.on('initialized', async ({ clients }) => { + addClient(...clients) + }) + + socket.on('new-client', (client) => { + addClient(client) + }) + + socket.on('client-switched-channel', (client) => { + updateClient(client.socketId, client) + }) + + socket.on('client-disconnected', (socketId) => { + removeClient(socketId) + }) + + socket.on('disconnect', () => { + clearClients() + }) + + socket.on('chat:new-message', (message: ChatMessage) => { + eventBus.emit('chat:new-message', message) + }) + }) + + watchEffect(() => { + if (authorized.value) { + signaling.connect() + } + }) +} diff --git a/new-client/src/shared/composables/use-media-controls.ts b/new-client/src/shared/composables/use-media-controls.ts new file mode 100644 index 0000000..5813728 --- /dev/null +++ b/new-client/src/shared/composables/use-media-controls.ts @@ -0,0 +1,74 @@ +import { createGlobalState } from '@vueuse/core' +import { computed, ref, watch } from 'vue' +import { useMediasoup } from './use-mediasoup' +import { useProducers } from './use-producers' + +export const useMediaControls = createGlobalState(() => { + const { producers } = useMediasoup() + const { enableMic, enableVideo, disableVideo, enableShare, disableShare, pauseProducer, resumeProducer } = useProducers() + + const micProducer = computed(() => producers.values.value.find(p => p.appData.source === 'mic')) + const cameraProducer = computed(() => producers.values.value.find(p => p.appData.source === 'camera')) + const shareProducer = computed(() => producers.values.value.find(p => p.appData.source === 'share')) + + const isSoundEnabled = ref(true) + + const isMicEnabled = computed(() => isSoundEnabled.value && !!micProducer.value && !micProducer.value.paused) + const isCameraEnabled = computed(() => !!cameraProducer.value && !cameraProducer.value.paused) + const isShareEnabled = computed(() => !!shareProducer.value) + + watch(isSoundEnabled, async (enabled) => { + if (!enabled) { + if (micProducer.value && !micProducer.value.paused) + await pauseProducer(micProducer.value.id) + } + else { + if (micProducer.value?.paused) + await resumeProducer(micProducer.value.id) + } + }) + + async function toggleMic() { + if (!isSoundEnabled.value) + return + + if (isMicEnabled.value) { + await pauseProducer(micProducer.value!.id) + } + else if (micProducer.value?.paused) { + await resumeProducer(micProducer.value.id) + } + else { + await enableMic() + } + } + + function toggleSound() { + isSoundEnabled.value = !isSoundEnabled.value + } + + async function toggleCamera() { + if (isCameraEnabled.value) + await disableVideo() + else + await enableVideo() + } + + async function toggleShare() { + if (isShareEnabled.value) + await disableShare() + else + await enableShare() + } + + return { + isMicEnabled, + isSoundEnabled, + isCameraEnabled, + isShareEnabled, + toggleMic, + toggleSound, + toggleCamera, + toggleShare, + } +}) diff --git a/new-client/src/shared/composables/use-mediasoup.ts b/new-client/src/shared/composables/use-mediasoup.ts new file mode 100644 index 0000000..8f6c1ce --- /dev/null +++ b/new-client/src/shared/composables/use-mediasoup.ts @@ -0,0 +1,119 @@ +import type { Consumer, Device, Producer, Transport } from 'mediasoup-client/types' +import type { Ref } from 'vue' +import { createEventHook, createGlobalState } from '@vueuse/core' +import { computed, ref, shallowRef } from 'vue' + +interface Snapshot { + id: string + ref: T + [key: string]: any +} + +export interface ProducerSnapshot extends Snapshot { + paused: boolean + kind: string + appData: Record +} + +export interface ConsumerSnapshot extends Snapshot { + paused: boolean + kind: string + appData: Record + track: MediaStreamTrack +} + +export interface TransportSnapshot extends Snapshot { +} + +function createEntityStore() { + const store = ref(new Map()) as unknown as Ref> + + const onAddEvent = createEventHook() + const onRemoveEvent = createEventHook() + + const values = computed(() => Array.from(store.value.values())) + + async function add(entity: T) { + store.value.set(entity.id, entity) + // eslint-disable-next-line ts/ban-ts-comment + // @ts-expect-error + await onAddEvent.trigger(entity) + } + + async function remove(id: string) { + store.value.delete(id) + await onRemoveEvent.trigger(id) + } + + function get(id: string) { + return store.value.get(id) + } + + function clear() { + store.value.clear() + } + + return { + store, + values, + onAdd: onAddEvent.on, + onRemove: onRemoveEvent.on, + add, + remove, + get, + clear, + } +} + +export const useMediasoup = createGlobalState(() => { + const device = shallowRef() + const sendTransport = shallowRef() + const recvTransport = shallowRef() + + const producers = createEntityStore() + producers.onAdd((producer) => { + producer.ref.observer.on('pause', () => { + producers.store.value.set(producer.id, { ...producer, paused: true }) + }) + producer.ref.observer.on('resume', () => { + producers.store.value.set(producer.id, { ...producer, paused: false }) + }) + producer.ref.observer.on('close', () => producers.remove(producer.id)) + }) + + const consumers = createEntityStore() + consumers.onAdd((consumer) => { + consumer.ref.on('trackended', () => consumer.ref.close()) + consumer.ref.observer.on('pause', () => { + consumers.store.value.set(consumer.id, { ...consumer, paused: true }) + }) + consumer.ref.observer.on('resume', () => { + consumers.store.value.set(consumer.id, { ...consumer, paused: false }) + }) + consumer.ref.observer.on('close', () => consumers.remove(consumer.id)) + }) + + const transports = createEntityStore() + transports.onAdd((transport) => { + transport.ref.observer.on('close', () => transports.remove(transport.id)) + }) + + function clearAll() { + device.value = undefined + sendTransport.value = undefined + recvTransport.value = undefined + producers.clear() + consumers.clear() + transports.clear() + } + + return { + device, + sendTransport, + recvTransport, + producers, + consumers, + transports, + clearAll, + } +}) diff --git a/new-client/src/shared/composables/use-producers.ts b/new-client/src/shared/composables/use-producers.ts new file mode 100644 index 0000000..ebccc66 --- /dev/null +++ b/new-client/src/shared/composables/use-producers.ts @@ -0,0 +1,164 @@ +import type { MediaKind, ProducerOptions } from 'mediasoup-client/types' +import { createGlobalState } from '@vueuse/core' +import { markRaw } from 'vue' +import { useMediasoup } from './use-mediasoup' +import { useSignaling } from './use-signaling' + +export const useProducers = createGlobalState(() => { + const { socket } = useSignaling() + const { device, sendTransport, producers } = useMediasoup() + + async function createProducer(options: ProducerOptions) { + if (!sendTransport.value || !device.value || !options.track) + return + + if (!device.value.canProduce(options.track.kind as MediaKind)) + return + + const producer = await sendTransport.value.produce({ disableTrackOnPause: true, ...options }) + + producer.observer.on('trackended', () => disableProducer(producer.id)) + + await producers.add({ + id: producer.id, + paused: producer.paused, + kind: producer.kind, + appData: producer.appData as Record, + ref: markRaw(producer), + }) + } + + async function disableProducer(producerId: string) { + const snap = producers.get(producerId) + if (!snap) + return + + try { + snap.ref.close() + await socket.value?.emitWithAck('close-producer', { producerId }) + } + catch {} + } + + async function pauseProducer(producerId: string) { + const snap = producers.get(producerId) + if (!snap || snap.paused) + return + + try { + snap.ref.pause() + await socket.value?.emitWithAck('pause-producer', { producerId }) + } + catch { + snap.ref.resume() + } + } + + async function resumeProducer(producerId: string) { + const snap = producers.get(producerId) + if (!snap || !snap.paused) + return + + try { + snap.ref.resume() + await socket.value?.emitWithAck('resume-producer', { producerId }) + } + catch { + snap.ref.pause() + } + } + + async function enableMic() { + for (const snap of producers.store.value.values()) { + if (snap.kind === 'audio') + return + } + + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const track = stream.getAudioTracks()[0] + if (!track) + return + + await createProducer({ + track, + streamId: 'mic-video', + codecOptions: { opusStereo: true, opusDtx: true }, + appData: { source: 'mic' }, + }) + } + + async function disableMic() { + for (const snap of producers.store.value.values()) { + if (snap.kind === 'audio') + await disableProducer(snap.id) + } + } + + async function enableVideo() { + for (const snap of producers.store.value.values()) { + if (snap.kind === 'video' && snap.appData.source !== 'share') + return + } + + const stream = await navigator.mediaDevices.getUserMedia({ + video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 60 } }, + }) + const track = stream.getVideoTracks()[0] + if (!track) + return + + await createProducer({ + track, + streamId: 'mic-video', + appData: { source: 'camera' }, + }) + } + + async function disableVideo() { + for (const snap of producers.store.value.values()) { + if (snap.kind === 'video' && snap.appData.source !== 'share') + await disableProducer(snap.id) + } + } + + async function enableShare() { + for (const snap of producers.store.value.values()) { + if (snap.appData.source === 'share') + return + } + + const stream = await navigator.mediaDevices.getDisplayMedia({ + audio: false, + video: { displaySurface: 'monitor' }, + }) + const track = stream.getVideoTracks()[0] + if (!track) + return + + await createProducer({ + track, + streamId: 'share', + zeroRtpOnPause: true, + appData: { source: 'share' }, + }) + } + + async function disableShare() { + for (const snap of producers.store.value.values()) { + if (snap.appData.source === 'share') + await disableProducer(snap.id) + } + } + + return { + enableMic, + disableMic, + enableVideo, + disableVideo, + enableShare, + disableShare, + pauseProducer, + resumeProducer, + disableProducer, + } +}) diff --git a/server/prisma/migrations/20260514110827_username_as_key/migration.sql b/server/prisma/migrations/20260514110827_username_as_key/migration.sql new file mode 100644 index 0000000..b458bf7 --- /dev/null +++ b/server/prisma/migrations/20260514110827_username_as_key/migration.sql @@ -0,0 +1,101 @@ +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; + +-- ===================== +-- Channel +-- ===================== +CREATE TABLE "new_Channel" ( + "id" TEXT NOT NULL PRIMARY KEY, + "ownerId" TEXT, + "ownerUsername" TEXT, + "name" TEXT NOT NULL, + "persistent" BOOLEAN NOT NULL, + CONSTRAINT "Channel_ownerUsername_fkey" FOREIGN KEY ("ownerUsername") REFERENCES "User" ("username") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Channel" ("id", "name", "ownerId", "ownerUsername", "persistent") +SELECT c."id", c."name", c."ownerId", + (SELECT u."username" FROM "User" u WHERE u."id" = c."ownerId"), + c."persistent" +FROM "Channel" c; +DROP TABLE "Channel"; +ALTER TABLE "new_Channel" RENAME TO "Channel"; + +-- ===================== +-- Message +-- ===================== +CREATE TABLE "new_Message" ( + "id" TEXT NOT NULL PRIMARY KEY, + "text" TEXT NOT NULL, + "senderId" TEXT, + "senderUsername" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Message_senderUsername_fkey" FOREIGN KEY ("senderUsername") REFERENCES "User" ("username") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Message" ("id", "text", "senderId", "senderUsername", "createdAt", "updatedAt") +SELECT m."id", m."text", m."senderId", + (SELECT u."username" FROM "User" u WHERE u."id" = m."senderId"), + m."createdAt", m."updatedAt" +FROM "Message" m; +DROP TABLE "Message"; +ALTER TABLE "new_Message" RENAME TO "Message"; + +-- ===================== +-- Session +-- ===================== +CREATE TABLE "new_Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "username" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + CONSTRAINT "Session_username_fkey" FOREIGN KEY ("username") REFERENCES "User" ("username") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Session" ("id", "userId", "username", "expiresAt") +SELECT s."id", s."userId", + (SELECT u."username" FROM "User" u WHERE u."id" = s."userId"), + s."expiresAt" +FROM "Session" s; +DROP TABLE "Session"; +ALTER TABLE "new_Session" RENAME TO "Session"; +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- ===================== +-- User +-- ===================== +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL PRIMARY KEY, + "password" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("createdAt", "displayName", "id", "password", "updatedAt", "username") +SELECT "createdAt", "displayName", "id", "password", "updatedAt", "username" +FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- ===================== +-- UserPreferences +-- ===================== +CREATE TABLE "new_UserPreferences" ( + "userId" TEXT NOT NULL, + "username" TEXT NOT NULL PRIMARY KEY, + "toggleInputHotkey" TEXT DEFAULT '', + "toggleOutputHotkey" TEXT DEFAULT '', + CONSTRAINT "UserPreferences_username_fkey" FOREIGN KEY ("username") REFERENCES "User" ("username") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_UserPreferences" ("userId", "username", "toggleInputHotkey", "toggleOutputHotkey") +SELECT up."userId", + (SELECT u."username" FROM "User" u WHERE u."id" = up."userId"), + up."toggleInputHotkey", up."toggleOutputHotkey" +FROM "UserPreferences" up; +DROP TABLE "UserPreferences"; +ALTER TABLE "new_UserPreferences" RENAME TO "UserPreferences"; +CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId"); +CREATE UNIQUE INDEX "UserPreferences_username_key" ON "UserPreferences"("username"); + +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; \ No newline at end of file diff --git a/server/prisma/migrations/20260514112214_remove_user_ids/migration.sql b/server/prisma/migrations/20260514112214_remove_user_ids/migration.sql new file mode 100644 index 0000000..a4491e0 --- /dev/null +++ b/server/prisma/migrations/20260514112214_remove_user_ids/migration.sql @@ -0,0 +1,67 @@ +/* + Warnings: + + - You are about to drop the column `ownerId` on the `Channel` table. All the data in the column will be lost. + - You are about to drop the column `senderId` on the `Message` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `Session` table. All the data in the column will be lost. + - You are about to drop the column `id` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `UserPreferences` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Channel" ( + "id" TEXT NOT NULL PRIMARY KEY, + "ownerUsername" TEXT, + "name" TEXT NOT NULL, + "persistent" BOOLEAN NOT NULL, + CONSTRAINT "Channel_ownerUsername_fkey" FOREIGN KEY ("ownerUsername") REFERENCES "User" ("username") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Channel" ("id", "name", "ownerUsername", "persistent") SELECT "id", "name", "ownerUsername", "persistent" FROM "Channel"; +DROP TABLE "Channel"; +ALTER TABLE "new_Channel" RENAME TO "Channel"; +CREATE TABLE "new_Message" ( + "id" TEXT NOT NULL PRIMARY KEY, + "text" TEXT NOT NULL, + "senderUsername" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Message_senderUsername_fkey" FOREIGN KEY ("senderUsername") REFERENCES "User" ("username") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Message" ("createdAt", "id", "senderUsername", "text", "updatedAt") SELECT "createdAt", "id", "senderUsername", "text", "updatedAt" FROM "Message"; +DROP TABLE "Message"; +ALTER TABLE "new_Message" RENAME TO "Message"; +CREATE TABLE "new_Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + CONSTRAINT "Session_username_fkey" FOREIGN KEY ("username") REFERENCES "User" ("username") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Session" ("expiresAt", "id", "username") SELECT "expiresAt", "id", "username" FROM "Session"; +DROP TABLE "Session"; +ALTER TABLE "new_Session" RENAME TO "Session"; +CREATE INDEX "Session_username_idx" ON "Session"("username"); +CREATE TABLE "new_User" ( + "username" TEXT NOT NULL PRIMARY KEY, + "password" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("createdAt", "displayName", "password", "updatedAt", "username") SELECT "createdAt", "displayName", "password", "updatedAt", "username" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); +CREATE TABLE "new_UserPreferences" ( + "username" TEXT NOT NULL PRIMARY KEY, + "toggleInputHotkey" TEXT DEFAULT '', + "toggleOutputHotkey" TEXT DEFAULT '', + CONSTRAINT "UserPreferences_username_fkey" FOREIGN KEY ("username") REFERENCES "User" ("username") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_UserPreferences" ("toggleInputHotkey", "toggleOutputHotkey", "username") SELECT "toggleInputHotkey", "toggleOutputHotkey", "username" FROM "UserPreferences"; +DROP TABLE "UserPreferences"; +ALTER TABLE "new_UserPreferences" RENAME TO "UserPreferences"; +CREATE UNIQUE INDEX "UserPreferences_username_key" ON "UserPreferences"("username"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/server/prisma/migrations/20260514140935_fix_sessions/migration.sql b/server/prisma/migrations/20260514140935_fix_sessions/migration.sql new file mode 100644 index 0000000..fb39c90 --- /dev/null +++ b/server/prisma/migrations/20260514140935_fix_sessions/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - You are about to drop the column `username` on the `Session` table. All the data in the column will be lost. + - Added the required column `userId` to the `Session` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("username") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Session" ("expiresAt", "id") SELECT "expiresAt", "id" FROM "Session"; +DROP TABLE "Session"; +ALTER TABLE "new_Session" RENAME TO "Session"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF;