From 710ab06333336f010c6918a77f45332e2567bcc2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 11:50:14 +0200 Subject: [PATCH] feat(db): retention schema for account deletion (migration 00007) Additive/expand-contract: new append-only retained_identities table (the legal dossier of every detached credential, keyed by account_id, TTL'd by detached_at) plus nullable accounts columns last_login_at/last_login_ip (cold-load stamp) and deleted_at/deleted_display_name (tombstone + retained real name). Regenerates the go-jet models for accounts + retained_identities only. --- .../postgres/jet/backend/model/accounts.go | 4 + .../jet/backend/model/retained_identities.go | 24 +++++ .../postgres/jet/backend/table/accounts.go | 16 ++- .../jet/backend/table/retained_identities.go | 99 +++++++++++++++++++ .../jet/backend/table/table_use_schema.go | 1 + .../00007_account_deletion_retention.sql | 47 +++++++++ 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 backend/internal/postgres/jet/backend/model/retained_identities.go create mode 100644 backend/internal/postgres/jet/backend/table/retained_identities.go create mode 100644 backend/internal/postgres/migrations/00007_account_deletion_retention.sql diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go index c69445d..b091195 100644 --- a/backend/internal/postgres/jet/backend/model/accounts.go +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -32,4 +32,8 @@ type Accounts struct { MergedAt *time.Time FlaggedHighRateAt *time.Time VariantPreferences pq.StringArray + LastLoginAt *time.Time + LastLoginIP *string + DeletedAt *time.Time + DeletedDisplayName *string } diff --git a/backend/internal/postgres/jet/backend/model/retained_identities.go b/backend/internal/postgres/jet/backend/model/retained_identities.go new file mode 100644 index 0000000..15d0e36 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/retained_identities.go @@ -0,0 +1,24 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type RetainedIdentities struct { + RetainedID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + Kind string + ExternalID string + Confirmed bool + LinkedAt time.Time + DetachedAt time.Time + Reason string +} diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go index 3cafd35..8ca2ca5 100644 --- a/backend/internal/postgres/jet/backend/table/accounts.go +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -35,6 +35,10 @@ type accountsTable struct { MergedAt postgres.ColumnTimestampz FlaggedHighRateAt postgres.ColumnTimestampz VariantPreferences postgres.ColumnStringArray + LastLoginAt postgres.ColumnTimestampz + LastLoginIP postgres.ColumnString + DeletedAt postgres.ColumnTimestampz + DeletedDisplayName postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -94,8 +98,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { MergedAtColumn = postgres.TimestampzColumn("merged_at") FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at") VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences") - allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} - mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} + LastLoginAtColumn = postgres.TimestampzColumn("last_login_at") + LastLoginIPColumn = postgres.StringColumn("last_login_ip") + DeletedAtColumn = postgres.TimestampzColumn("deleted_at") + DeletedDisplayNameColumn = postgres.StringColumn("deleted_display_name") + allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn} + mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn} defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn} ) @@ -121,6 +129,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { MergedAt: MergedAtColumn, FlaggedHighRateAt: FlaggedHighRateAtColumn, VariantPreferences: VariantPreferencesColumn, + LastLoginAt: LastLoginAtColumn, + LastLoginIP: LastLoginIPColumn, + DeletedAt: DeletedAtColumn, + DeletedDisplayName: DeletedDisplayNameColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/jet/backend/table/retained_identities.go b/backend/internal/postgres/jet/backend/table/retained_identities.go new file mode 100644 index 0000000..17851eb --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/retained_identities.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var RetainedIdentities = newRetainedIdentitiesTable("backend", "retained_identities", "") + +type retainedIdentitiesTable struct { + postgres.Table + + // Columns + RetainedID postgres.ColumnString + AccountID postgres.ColumnString + Kind postgres.ColumnString + ExternalID postgres.ColumnString + Confirmed postgres.ColumnBool + LinkedAt postgres.ColumnTimestampz + DetachedAt postgres.ColumnTimestampz + Reason postgres.ColumnString + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type RetainedIdentitiesTable struct { + retainedIdentitiesTable + + EXCLUDED retainedIdentitiesTable +} + +// AS creates new RetainedIdentitiesTable with assigned alias +func (a RetainedIdentitiesTable) AS(alias string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new RetainedIdentitiesTable with assigned schema name +func (a RetainedIdentitiesTable) FromSchema(schemaName string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new RetainedIdentitiesTable with assigned table prefix +func (a RetainedIdentitiesTable) WithPrefix(prefix string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new RetainedIdentitiesTable with assigned table suffix +func (a RetainedIdentitiesTable) WithSuffix(suffix string) *RetainedIdentitiesTable { + return newRetainedIdentitiesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newRetainedIdentitiesTable(schemaName, tableName, alias string) *RetainedIdentitiesTable { + return &RetainedIdentitiesTable{ + retainedIdentitiesTable: newRetainedIdentitiesTableImpl(schemaName, tableName, alias), + EXCLUDED: newRetainedIdentitiesTableImpl("", "excluded", ""), + } +} + +func newRetainedIdentitiesTableImpl(schemaName, tableName, alias string) retainedIdentitiesTable { + var ( + RetainedIDColumn = postgres.StringColumn("retained_id") + AccountIDColumn = postgres.StringColumn("account_id") + KindColumn = postgres.StringColumn("kind") + ExternalIDColumn = postgres.StringColumn("external_id") + ConfirmedColumn = postgres.BoolColumn("confirmed") + LinkedAtColumn = postgres.TimestampzColumn("linked_at") + DetachedAtColumn = postgres.TimestampzColumn("detached_at") + ReasonColumn = postgres.StringColumn("reason") + allColumns = postgres.ColumnList{RetainedIDColumn, AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn} + defaultColumns = postgres.ColumnList{ConfirmedColumn, DetachedAtColumn} + ) + + return retainedIdentitiesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + RetainedID: RetainedIDColumn, + AccountID: AccountIDColumn, + Kind: KindColumn, + ExternalID: ExternalIDColumn, + Confirmed: ConfirmedColumn, + LinkedAt: LinkedAtColumn, + DetachedAt: DetachedAtColumn, + Reason: ReasonColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 19113ed..540bcec 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -34,6 +34,7 @@ func UseSchema(schema string) { GameSetupDraws = GameSetupDraws.FromSchema(schema) Games = Games.FromSchema(schema) Identities = Identities.FromSchema(schema) + RetainedIdentities = RetainedIdentities.FromSchema(schema) Sessions = Sessions.FromSchema(schema) SuspensionReasons = SuspensionReasons.FromSchema(schema) } diff --git a/backend/internal/postgres/migrations/00007_account_deletion_retention.sql b/backend/internal/postgres/migrations/00007_account_deletion_retention.sql new file mode 100644 index 0000000..9cfa130 --- /dev/null +++ b/backend/internal/postgres/migrations/00007_account_deletion_retention.sql @@ -0,0 +1,47 @@ +-- Account deletion as legal retention (not erasure). Two additive pieces. +-- +-- retained_identities is an append-only journal of every credential detached from an +-- account — on unlink, email change, or account deletion (reason). It preserves the legal +-- dossier (which email/vk/tg was linked, and when) while the live identities row is +-- removed, so the (kind, external_id) frees for a new account to reuse. No unique +-- constraint and no kind CHECK: the same credential may recur across accounts and events, +-- and the log stays robust to future identity kinds. detached_at drives the retention TTL. +-- +-- accounts gains: last_login_at / last_login_ip (stamped on a cold app-load, throttled), +-- deleted_at (the tombstone marker), and deleted_display_name (the real name retained for +-- the admin dossier after the live display_name is scrubbed to the anonymised label). +-- +-- Expand-contract: everything is additive (a new table plus nullable columns), so a +-- backend image rollback stays DB-safe — older code simply ignores them. The accounts +-- table shape changes, so its generated go-jet model is regenerated. + +-- +goose Up +CREATE TABLE backend.retained_identities ( + retained_id uuid NOT NULL, + account_id uuid NOT NULL, + kind text NOT NULL, + external_id text NOT NULL, + confirmed boolean DEFAULT false NOT NULL, + linked_at timestamp with time zone NOT NULL, + detached_at timestamp with time zone DEFAULT now() NOT NULL, + reason text NOT NULL, + CONSTRAINT retained_identities_pkey PRIMARY KEY (retained_id), + CONSTRAINT retained_identities_reason_chk + CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text]))) +); +CREATE INDEX retained_identities_account_id_idx ON backend.retained_identities (account_id); +CREATE INDEX retained_identities_detached_at_idx ON backend.retained_identities (detached_at); + +ALTER TABLE backend.accounts + ADD COLUMN last_login_at timestamp with time zone, + ADD COLUMN last_login_ip text, + ADD COLUMN deleted_at timestamp with time zone, + ADD COLUMN deleted_display_name text; + +-- +goose Down +ALTER TABLE backend.accounts + DROP COLUMN last_login_at, + DROP COLUMN last_login_ip, + DROP COLUMN deleted_at, + DROP COLUMN deleted_display_name; +DROP TABLE backend.retained_identities;