diff --git a/src/migrations/1806000000000-add-consumer-privacy-preference-indexes.ts b/src/migrations/1806000000000-add-consumer-privacy-preference-indexes.ts new file mode 100644 index 00000000..0961c3fc --- /dev/null +++ b/src/migrations/1806000000000-add-consumer-privacy-preference-indexes.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Issue #1244 — Add database indexes to the consumer-privacy-preference entity. + * + * Common query paths that need coverage: + * - "get user preferences" → WHERE "userId" = $1 + * - "get tenant preferences list" → WHERE "tenantId" = $1 + * - "get specific user within tenant" → WHERE "tenantId" = $1 AND "userId" = $2 (composite) + * - "users who opted out of data sale" → WHERE "doNotSellMyPersonalInformation" = true + * - "recent CCPA requests" → ORDER BY "lastCcpRequestDate" DESC / range queries + * + * Indexes added: + * - IDX_consumer_privacy_preferences_user_id — per-user lookup (FK column) + * - IDX_consumer_privacy_preferences_tenant_id — per-tenant filtering + * - IDX_consumer_privacy_preferences_tenant_user — composite tenant+user (unique, covers most common multi-tenant query) + * - IDX_consumer_privacy_preferences_do_not_sell — do-not-sell opt-out flag filtering + * - IDX_consumer_privacy_preferences_last_request_date — last CCPA request sorting/range queries + */ +export class AddConsumerPrivacyPreferenceIndexes1806000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "consumer_privacy_preferences" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "version" integer NOT NULL DEFAULT 1, + "tenantId" character varying, + "userId" character varying NOT NULL, + "doNotSellMyPersonalInformation" boolean NOT NULL DEFAULT false, + "optOutOfDataSharing" boolean NOT NULL DEFAULT false, + "optOutOfMarketing" boolean NOT NULL DEFAULT false, + "limitUseOfSensitivePersonalInformation" boolean NOT NULL DEFAULT false, + "dataCategoryPreferences" jsonb, + "purposePreferences" jsonb, + "lastCcpRequestDate" TIMESTAMP, + "expiresAt" TIMESTAMP, + "source" character varying, + "identityVerified" boolean NOT NULL DEFAULT false, + "verificationMethod" character varying, + "notes" text, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_consumer_privacy_preferences" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_consumer_privacy_preferences_user_id" ON "consumer_privacy_preferences" ("userId")', + ); + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_consumer_privacy_preferences_tenant_id" ON "consumer_privacy_preferences" ("tenantId")', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX IF NOT EXISTS "IDX_consumer_privacy_preferences_tenant_user" ON "consumer_privacy_preferences" ("tenantId", "userId")', + ); + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_consumer_privacy_preferences_do_not_sell" ON "consumer_privacy_preferences" ("doNotSellMyPersonalInformation")', + ); + await queryRunner.query( + 'CREATE INDEX IF NOT EXISTS "IDX_consumer_privacy_preferences_last_request_date" ON "consumer_privacy_preferences" ("lastCcpRequestDate")', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX IF EXISTS "IDX_consumer_privacy_preferences_last_request_date"', + ); + await queryRunner.query('DROP INDEX IF EXISTS "IDX_consumer_privacy_preferences_do_not_sell"'); + await queryRunner.query('DROP INDEX IF EXISTS "IDX_consumer_privacy_preferences_tenant_user"'); + await queryRunner.query('DROP INDEX IF EXISTS "IDX_consumer_privacy_preferences_tenant_id"'); + await queryRunner.query('DROP INDEX IF EXISTS "IDX_consumer_privacy_preferences_user_id"'); + } +} diff --git a/src/modules/ccpa/entities/consumer-privacy-preference.entity.ts b/src/modules/ccpa/entities/consumer-privacy-preference.entity.ts new file mode 100644 index 00000000..32d9c805 --- /dev/null +++ b/src/modules/ccpa/entities/consumer-privacy-preference.entity.ts @@ -0,0 +1,82 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + VersionColumn, + Index, +} from 'typeorm'; +import { User } from '../../../users/entities/user.entity'; + +/** + * Represents a consumer's CCPA privacy preferences. + * Tracks opt-outs for data sale, marketing, and other CCPA rights. + */ +@Entity('consumer_privacy_preferences') +@Index('IDX_consumer_privacy_preferences_tenant_user', ['tenantId', 'userId'], { unique: true }) +export class ConsumerPrivacyPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @VersionColumn() + version: number; + + @Column({ nullable: true }) + @Index('IDX_consumer_privacy_preferences_tenant_id') + tenantId?: string; + + @Column() + @Index('IDX_consumer_privacy_preferences_user_id') + userId: string; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn() + user: User; + + @Column({ default: false }) + @Index('IDX_consumer_privacy_preferences_do_not_sell') + doNotSellMyPersonalInformation: boolean; + + @Column({ default: false }) + optOutOfDataSharing: boolean; + + @Column({ default: false }) + optOutOfMarketing: boolean; + + @Column({ default: false }) + limitUseOfSensitivePersonalInformation: boolean; + + @Column({ type: 'jsonb', nullable: true }) + dataCategoryPreferences: Record; + + @Column({ type: 'jsonb', nullable: true }) + purposePreferences: Record; + + @Column({ type: 'timestamp', nullable: true }) + @Index('IDX_consumer_privacy_preferences_last_request_date') + lastCcpRequestDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + expiresAt: Date; + + @Column({ type: 'varchar', nullable: true }) + source: string; + + @Column({ default: false }) + identityVerified: boolean; + + @Column({ type: 'varchar', nullable: true }) + verificationMethod: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +}