// Phase 7 UX — per-user deletion modes, per-user chat state, folders + pins. // // All of this is per-user *view* state (Telegram semantics): hiding a message // or clearing a chat never mutates the shared message rows; it records what // this user no longer sees. Folders and pins are per-user and server-stored so // they sync across devices via the personal channel. /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ exports.up = (pgm) => { // "Delete for me": tombstone per (user, message). History queries anti-join. pgm.createTable('message_hidden', { user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, message_id: { type: 'uuid', notNull: true, references: 'messages', onDelete: 'CASCADE' }, created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, }); pgm.addConstraint('message_hidden', 'message_hidden_pkey', { primaryKey: ['user_id', 'message_id'], }); // "Clear history" / "delete chat" for me. Messages with seq <= cleared_up_to_seq // are invisible to this member. hidden_at removes the chat from the list until // new activity arrives (last_message_at > hidden_at brings it back). pgm.addColumns('conversation_members', { cleared_up_to_seq: { type: 'bigint', notNull: true, default: 0 }, hidden_at: { type: 'timestamptz' }, }); // Manual chat folders (per user), ordered by position. pgm.createTable('chat_folders', { id: { type: 'uuid', notNull: true, default: pgm.func('gen_random_uuid()'), primaryKey: true }, user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, title: { type: 'text', notNull: true }, position: { type: 'integer', notNull: true, default: 0 }, created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, }); pgm.createIndex('chat_folders', 'user_id'); pgm.createTable('chat_folder_items', { folder_id: { type: 'uuid', notNull: true, references: 'chat_folders', onDelete: 'CASCADE' }, conversation_id: { type: 'uuid', notNull: true, references: 'conversations', onDelete: 'CASCADE', }, created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, }); pgm.addConstraint('chat_folder_items', 'chat_folder_items_pkey', { primaryKey: ['folder_id', 'conversation_id'], }); // Pins are scoped: folder_id NULL = pinned in the "All chats" tab. Pinning in // one folder deliberately does not pin anywhere else. pgm.createTable('chat_pins', { user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, conversation_id: { type: 'uuid', notNull: true, references: 'conversations', onDelete: 'CASCADE', }, folder_id: { type: 'uuid', references: 'chat_folders', onDelete: 'CASCADE' }, pinned_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, }); // Uniqueness needs two partial indexes because folder_id is nullable. pgm.createIndex('chat_pins', ['user_id', 'conversation_id'], { name: 'chat_pins_all_scope_unique', unique: true, where: 'folder_id IS NULL', }); pgm.createIndex('chat_pins', ['user_id', 'conversation_id', 'folder_id'], { name: 'chat_pins_folder_scope_unique', unique: true, where: 'folder_id IS NOT NULL', }); pgm.createIndex('chat_pins', 'user_id'); }; /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ exports.down = (pgm) => { pgm.dropTable('chat_pins'); pgm.dropTable('chat_folder_items'); pgm.dropTable('chat_folders'); pgm.dropColumns('conversation_members', ['cleared_up_to_seq', 'hidden_at']); pgm.dropTable('message_hidden'); };