diff --git a/apps/backend/drizzle/0007_user_devices.sql b/apps/backend/drizzle/0007_user_devices.sql new file mode 100644 index 0000000..a98bc1a --- /dev/null +++ b/apps/backend/drizzle/0007_user_devices.sql @@ -0,0 +1,17 @@ +CREATE TYPE "public"."device_platform" AS ENUM('web', 'ios', 'android');--> statement-breakpoint +CREATE TABLE "user_devices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "device_id" text NOT NULL, + "device_name" text NOT NULL, + "platform" "device_platform" NOT NULL, + "identity_public_key" text NOT NULL, + "registration_id" integer, + "last_seen_at" timestamp, + "revoked_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_devices" ADD CONSTRAINT "user_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "user_devices_user_id_device_id_unique" ON "user_devices" USING btree ("user_id","device_id");--> statement-breakpoint +CREATE INDEX "user_devices_user_id_active_idx" ON "user_devices" USING btree ("user_id") WHERE "revoked_at" IS NULL; diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 8fea85e..a58ae36 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1780560000000, "tag": "0006_add_conversation_avatar_url", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1782345600000, + "tag": "0007_user_devices", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 419c59f..b49c0f7 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -1,4 +1,14 @@ -import { pgTable, text, timestamp, uuid, boolean, pgEnum, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + text, + timestamp, + uuid, + boolean, + integer, + pgEnum, + index, + uniqueIndex, +} from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; export const users = pgTable('users', { @@ -91,6 +101,41 @@ export const tokenTransfers = pgTable('token_transfers', { createdAt: timestamp('created_at').notNull().defaultNow(), }); +// ─── User devices (#153) ────────────────────────────────────────────────────── +// +// Device identity registry for end-to-end encryption. Each row is one device a +// user has registered, holding its long-term identity public key. A device is +// never hard-deleted — revoking sets `revokedAt` so historical sessions stay +// auditable. `(userId, deviceId)` is unique so a client re-registering the same +// device upserts instead of duplicating, and the partial index keeps lookups of +// a user's *active* devices fast. + +export const devicePlatformEnum = pgEnum('device_platform', ['web', 'ios', 'android']); + +export const userDevices = pgTable( + 'user_devices', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + deviceId: text('device_id').notNull(), + deviceName: text('device_name').notNull(), + platform: devicePlatformEnum('platform').notNull(), + identityPublicKey: text('identity_public_key').notNull(), + registrationId: integer('registration_id'), + lastSeenAt: timestamp('last_seen_at'), + revokedAt: timestamp('revoked_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('user_devices_user_id_device_id_unique').on(table.userId, table.deviceId), + index('user_devices_user_id_active_idx') + .on(table.userId) + .where(sql`${table.revokedAt} IS NULL`), + ], +); + // ─── Relations ──────────────────────────────────────────────────────────────── export const usersRelations = relations(users, ({ many }) => ({ @@ -98,6 +143,7 @@ export const usersRelations = relations(users, ({ many }) => ({ memberships: many(conversationMembers), messages: many(messages), transfers: many(tokenTransfers), + devices: many(userDevices), })); export const walletsRelations = relations(wallets, ({ one }) => ({ @@ -137,6 +183,10 @@ export const tokenTransfersRelations = relations(tokenTransfers, ({ one }) => ({ }), })); +export const userDevicesRelations = relations(userDevices, ({ one }) => ({ + user: one(users, { fields: [userDevices.userId], references: [users.id] }), +})); + // ─── Types ──────────────────────────────────────────────────────────────────── export type User = typeof users.$inferSelect; @@ -150,3 +200,5 @@ export type Message = typeof messages.$inferSelect; export type NewMessage = typeof messages.$inferInsert; export type TokenTransfer = typeof tokenTransfers.$inferSelect; export type NewTokenTransfer = typeof tokenTransfers.$inferInsert; +export type UserDevice = typeof userDevices.$inferSelect; +export type NewUserDevice = typeof userDevices.$inferInsert;