data holders: private tracker of every organisation holding my address, email, phone or IBAN
Some checks failed
Deploy production / deploy (push) Has been cancelled

dataholders schema (orgs + identity) with migration and personal_app grants,
idempotent CSV import (upsert on name+login, never wipes, keeps site edits and
ticks), owner-only /trackers/data-holders with search, filters, pagination,
inline edit/add and a moving checklist per identifier
This commit is contained in:
2026-10-25 16:09:28 +00:00
parent fd4a34b495
commit 772e699655
18 changed files with 30891 additions and 1 deletions

View File

@@ -0,0 +1,79 @@
import { boolean, check, date, index, integer, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { dataholdersSchema } from './schemas';
import { timestamps, timestamptz } from './helpers';
// Private: every organisation that holds data about Stan (owner only, see requireOwner).
// Whenever the address, email, phone or IBAN changes, set the new value in
// dataholders.identity and work down the orgs that still have the old one (the
// /trackers/data-holders checklist). Never stores passwords: `login` is a username or email.
export const dataholderOrgs = dataholdersSchema.table(
'orgs',
{
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
name: text('name').notNull(),
category: text('category').notNull().default('Other'),
/** Username or email used to log in. Never a password. */
login: text('login'),
/** Where the row came from, e.g. "Daisy checklist", "1Password". */
source: text('source'),
/** Websites/domains of the account (from 1Password). */
domains: text('domains'),
/** Stable import key (normalised name + login). Set by the importer, never by edits. */
importKey: text('import_key'),
holdsAddress: boolean('holds_address').notNull().default(false),
holdsEmail: boolean('holds_email').notNull().default(false),
holdsPhone: boolean('holds_phone').notNull().default(false),
holdsIban: boolean('holds_iban').notNull().default(false),
holdsBsn: boolean('holds_bsn').notNull().default(false),
holdsCard: boolean('holds_card').notNull().default(false),
holdsDob: boolean('holds_dob').notNull().default(false),
holdsIdDoc: boolean('holds_id_doc').notNull().default(false),
/** Free-text description of everything they hold (the "other" bucket). */
dataHeld: text('data_held'),
/** The address they have, as last seen (often an old one). */
addressOnFile: text('address_on_file'),
addressStatus: text('address_status').notNull().default('unknown'),
/** Gets the address from the BRP automatically (government bodies). */
followsBrp: boolean('follows_brp').notNull().default(false),
emailOnFile: text('email_on_file'),
phoneOnFile: text('phone_on_file'),
action: text('action').notNull().default('update'),
priority: text('priority').notNull().default('should'),
howToChange: text('how_to_change'),
link: text('link'),
evidence: text('evidence'),
notes: text('notes'),
addressUpdatedOn: date('address_updated_on', { mode: 'string' }),
emailUpdatedOn: date('email_updated_on', { mode: 'string' }),
phoneUpdatedOn: date('phone_updated_on', { mode: 'string' }),
ibanUpdatedOn: date('iban_updated_on', { mode: 'string' }),
lastChecked: date('last_checked', { mode: 'string' }),
/** Closed accounts stay in the list (archived), so their history is kept. */
archived: boolean('archived').notNull().default(false),
/** Set on any edit from the site; the importer then only fills empty fields. */
editedAt: timestamptz('edited_at'),
...timestamps
},
(t) => [
uniqueIndex('orgs_import_key_key').on(t.importKey),
index('orgs_category_idx').on(t.category),
check('orgs_name_check', sql`length(btrim(${t.name})) > 0`),
check('orgs_address_status_check', sql`${t.addressStatus} IN ('confirmed','likely','unknown')`),
check('orgs_action_check', sql`${t.action} IN ('update','close','check','none')`),
check('orgs_priority_check', sql`${t.priority} IN ('must','should','nice')`)
]
);
/** The current value of each identifier, and since when. Orgs ticked before `since` are due. */
export const dataholderIdentity = dataholdersSchema.table(
'identity',
{
kind: text('kind').primaryKey(),
value: text('value').notNull(),
since: date('since', { mode: 'string' }).notNull(),
...timestamps
},
(t) => [check('identity_kind_check', sql`${t.kind} IN ('address','email','phone','iban')`)]
);

View File

@@ -25,3 +25,4 @@ export * from './wildlife';
export * from './trains';
export * from './music';
export * from './hardware';
export * from './dataholders';

View File

@@ -23,3 +23,4 @@ export const hardwareSchema = pgSchema('hardware');
export const planesSchema = pgSchema('planes');
export const wildlifeSchema = pgSchema('wildlife');
export const trainsSchema = pgSchema('trains');
export const dataholdersSchema = pgSchema('dataholders');