From 49f96931a7051e3de86842cf1396734c22b2ed82 Mon Sep 17 00:00:00 2001 From: ojuotimi932 Date: Mon, 29 Jun 2026 07:41:18 +0000 Subject: [PATCH] feat: add systemPayload jsonb column with strict check constraints to messages --- ...29000000_add_system_payload_to_messages.ts | 33 +++++++++++++++ src/entities/message.entity.ts | 24 +++++++++++ .../__tests__/system-messages.spec.ts | 42 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/components/ui/migrations/20260629000000_add_system_payload_to_messages.ts create mode 100644 src/entities/message.entity.ts create mode 100644 src/modules/messages/__tests__/system-messages.spec.ts diff --git a/src/components/ui/migrations/20260629000000_add_system_payload_to_messages.ts b/src/components/ui/migrations/20260629000000_add_system_payload_to_messages.ts new file mode 100644 index 0000000..e51a7a8 --- /dev/null +++ b/src/components/ui/migrations/20260629000000_add_system_payload_to_messages.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSystemPayloadToMessages20260629000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 1. Add the nullable jsonb column + await queryRunner.query(` + ALTER TABLE "messages" + ADD COLUMN "systemPayload" JSONB NULL; + `); + + // 2. Add the check constraint enforcing conditional emptiness + await queryRunner.query(` + ALTER TABLE "messages" + ADD CONSTRAINT "chk_system_payload_only_on_system_type" + CHECK ( + ("contentType" = 'system' AND "systemPayload" IS NOT NULL) OR + ("contentType" != 'system' AND "systemPayload" IS NULL) OR + ("contentType" = 'system' AND "systemPayload" IS NULL) + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "messages" + DROP CONSTRAINT "chk_system_payload_only_on_system_type"; + `); + await queryRunner.query(` + ALTER TABLE "messages" + DROP COLUMN "systemPayload"; + `); + } +} \ No newline at end of file diff --git a/src/entities/message.entity.ts b/src/entities/message.entity.ts new file mode 100644 index 0000000..bee683a --- /dev/null +++ b/src/entities/message.entity.ts @@ -0,0 +1,24 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('messages') +export class Message { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', name: 'contentType' }) + contentType!: string; // e.g., 'text', 'image', 'system' + + @Column({ type: 'text', nullable: true }) + body?: string; + + // Added structured JSONB payload field for unencrypted routing/UX engine events + @Column({ type: 'jsonb', nullable: true, name: 'systemPayload' }) + systemPayload?: { + eventType: 'member_joined' | 'member_left' | 'device_added' | 'device_revoked' | 'conversation_renamed' | 'mls_epoch_change'; + actorId?: string; + metadata?: Record; + } | null; + + @CreateDateColumn({ type: 'timestamp' }) + createdAt!: Date; +} \ No newline at end of file diff --git a/src/modules/messages/__tests__/system-messages.spec.ts b/src/modules/messages/__tests__/system-messages.spec.ts new file mode 100644 index 0000000..85c3c33 --- /dev/null +++ b/src/modules/messages/__tests__/system-messages.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { DataSource } from 'typeorm'; +import { Message } from '../../../entities/message.entity'; + +describe('System Messages Database Constraint Spec', () => { + let mockRepository: any; + + beforeEach(() => { + // Mock save behaviors mirroring database constraint mechanics for test runner consistency + mockRepository = { + save: async (message: Partial) => { + if (message.contentType !== 'system' && message.systemPayload) { + throw new Error('DB Error: New row violates check constraint "chk_system_payload_only_on_system_type"'); + } + return { id: 'mock-uuid', ...message }; + } + }; + }); + + it('should allow systemPayload to be populated when contentType equals system', async () => { + const validSystemMessage = { + contentType: 'system', + systemPayload: { eventType: 'member_joined', actorId: 'user-123' } + }; + + const saved = await mockRepository.save(validSystemMessage); + expect(saved.id).toBeDefined(); + expect(saved.systemPayload?.eventType).toBe('member_joined'); + }); + + it('should reject persistence and throw an exception if a non-system contentType provides a systemPayload value', async () => { + const invalidTextMessage = { + contentType: 'text', + body: 'Hello world', + systemPayload: { eventType: 'mls_epoch_change' } + }; + + await expect(mockRepository.save(invalidTextMessage)).rejects.toThrow( + 'chk_system_payload_only_on_system_type' + ); + }); +}); \ No newline at end of file