Files
2026-09-27 14:21:31 +00:00

369 lines
18 KiB
SQL

-- ============================================================================
-- MijnBesluit - databaseschema voor MariaDB 10.6 of nieuwer
--
-- Aanmaken (in cmd, NIET in PowerShell - die kent "<" niet):
-- mysql -u root -p < 01_schema.sql
-- In PowerShell of HeidiSQL: zie docs/Website-testen.md.
--
-- Het script mag meerdere keren gedraaid worden; bestaande tabellen blijven ongemoeid.
--
-- De schemanaam staat hieronder één keer. Wil je een bestaande database
-- gebruiken, vervang dan `mijnbesluit` overal (of laat CREATE DATABASE weg
-- en zet er een USE <jouw naam>; voor in de plaats).
--
-- Opzet:
-- * Alles utf8mb4 en InnoDB, zodat namen met accenten en emoji goed gaan.
-- * Geheimen staan als VARBINARY in de tabel: versleuteld met AES-256-GCM,
-- nooit leesbaar. Zie docs/Beveiliging.md voor hoe de sleutels lopen.
-- * Datums worden in UTC bewaard (DATETIME, geen TIMESTAMP), zodat zomertijd
-- nooit een bezorgmoment kan verschuiven.
-- ============================================================================
CREATE DATABASE IF NOT EXISTS `mijnbesluit`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE `mijnbesluit`;
SET FOREIGN_KEY_CHECKS = 0;
-- ---------------------------------------------------------------- personen ---
-- Een profiel. Kan bestaan zonder account: dan is het aangemaakt door iemand
-- anders en wacht het op een uitnodiging.
CREATE TABLE IF NOT EXISTS `persons` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`first_name` VARCHAR(80) NOT NULL DEFAULT '',
`last_name` VARCHAR(80) NOT NULL DEFAULT '',
`nickname` VARCHAR(80) NULL,
`email` VARCHAR(255) NULL,
`mobile_number` VARCHAR(50) NULL,
`birth_date` DATE NULL,
`date_of_death` DATE NULL,
-- Wie het profiel aanmaakte (historie) en wie het nu beheert.
`created_by_user_id` BIGINT NULL,
`managed_by_user_id` BIGINT NULL,
-- Zodra dit gevuld is, is het profiel van de persoon zelf.
`claimed_by_user_id` BIGINT NULL,
`claimed_at` DATETIME(3) NULL,
-- Aantal meldingen dat nodig is om overlijden te bevestigen, en hoeveel dagen
-- de betrokkene daarna nog heeft om bezwaar te maken.
`required_death_declarations` INT NOT NULL DEFAULT 3,
`death_objection_days` INT NOT NULL DEFAULT 14,
`death_confirmed_at` DATETIME(3) NULL,
`death_objected_at` DATETIME(3) NULL,
-- Wie heeft gezegd dat deze aantekening niet over hem gaat. Zie db/17_aantekening_afgewezen.sql:
-- dan komt die vraag niet bij elke volgende keer opnieuw.
`link_declined_by_user_id` BIGINT NULL,
`link_declined_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
-- Met opzet NIET uniek. Twee mensen mogen allebei hun eigen aantekening over dezelfde
-- persoon hebben; ze worden pas één zodra die persoon zelf beide uitnodigingen aanneemt.
-- Zie docs/Identiteit.md en db/15_adres_niet_uniek.sql. Bij `users.email` is uniek juist
-- wél goed: dat is een account.
KEY `ix_persons_email` (`email`),
KEY `ix_persons_claimed_by` (`claimed_by_user_id`),
KEY `ix_persons_managed_by` (`managed_by_user_id`),
KEY `ix_persons_death` (`death_confirmed_at`)
) ENGINE=InnoDB;
-- ---------------------------------------------------------------- accounts ---
CREATE TABLE IF NOT EXISTS `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`person_id` BIGINT NOT NULL,
`email` VARCHAR(255) NOT NULL,
`security_stamp` CHAR(36) NOT NULL,
-- De datasleutel van deze gebruiker, versleuteld met de mastersleutel.
`wrapped_data_key` VARBINARY(255) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0, -- 0 actief, 1 geblokkeerd, 2 overleden
`email_confirmed_at` DATETIME(3) NULL,
`two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`failed_login_count` INT NOT NULL DEFAULT 0,
`locked_until` DATETIME(3) NULL,
`last_login_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_users_email` (`email`),
UNIQUE KEY `ux_users_person` (`person_id`),
CONSTRAINT `fk_users_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`)
) ENGINE=InnoDB;
-- `persons` en `users` verwijzen naar elkaar, dus deze twee sleutels kunnen pas gelegd worden
-- als beide tabellen bestaan. ALTER TABLE kent geen "IF NOT EXISTS" voor een constraint, dus
-- wordt er eerst gekeken of hij er al ligt: zo kun je dit script gerust nog een keer draaien.
SET @fk_claimed := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'persons'
AND CONSTRAINT_NAME = 'fk_persons_claimed_by');
SET @sql := IF(@fk_claimed = 0,
'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_claimed_by` FOREIGN KEY (`claimed_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_managed := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'persons'
AND CONSTRAINT_NAME = 'fk_persons_managed_by');
SET @sql := IF(@fk_managed = 0,
'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_managed_by` FOREIGN KEY (`managed_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------- families ---
CREATE TABLE IF NOT EXISTS `families` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(120) NOT NULL,
`owner_person_id` BIGINT NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `ix_families_owner` (`owner_person_id`),
CONSTRAINT `fk_families_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `family_members` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`family_id` BIGINT NOT NULL,
`person_id` BIGINT NOT NULL,
`relation` INT NOT NULL DEFAULT 0,
`relation_label` VARCHAR(50) NULL,
`added_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_family_member` (`family_id`, `person_id`),
KEY `ix_family_members_person` (`person_id`),
CONSTRAINT `fk_fm_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_fm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ------------------------------------------------------------ uitnodigingen ---
-- Alleen de hash van het token staat hier; het token zelf bestaat enkel in de e-mail.
CREATE TABLE IF NOT EXISTS `invitations` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`person_id` BIGINT NOT NULL,
`family_id` BIGINT NOT NULL,
`email` VARCHAR(255) NOT NULL,
`token_hash` VARBINARY(32) NOT NULL,
`status` INT NOT NULL DEFAULT 0,
`created_by_user_id` BIGINT NOT NULL,
`created_at` DATETIME(3) NOT NULL,
`sent_at` DATETIME(3) NULL,
`expires_at` DATETIME(3) NOT NULL,
`accepted_at` DATETIME(3) NULL,
`resend_count` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_invitations_token` (`token_hash`),
KEY `ix_invitations_person` (`person_id`, `status`),
CONSTRAINT `fk_inv_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_inv_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ------------------------------------------------------------------- kluis ---
-- Bijlagen: de bytes staan versleuteld buiten de database, hier alleen de verwijzing.
CREATE TABLE IF NOT EXISTS `vault_files` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`owner_person_id` BIGINT NOT NULL,
`file_name` VARCHAR(255) NOT NULL,
`content_type` VARCHAR(120) NOT NULL DEFAULT 'application/octet-stream',
`size_bytes` BIGINT NOT NULL DEFAULT 0,
`storage_key` VARCHAR(255) NOT NULL,
`nonce` VARBINARY(12) NOT NULL,
`tag` VARBINARY(16) NOT NULL,
`wrapped_content_key` VARBINARY(96) NOT NULL,
`sha256` VARBINARY(32) NOT NULL,
`created_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `ix_vault_files_owner` (`owner_person_id`),
CONSTRAINT `fk_vf_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Kluisitems. `title` en `category` zijn leesbaar zodat er gezocht kan worden;
-- de inhoud zit versleuteld in `ciphertext`.
CREATE TABLE IF NOT EXISTS `vault_items` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`owner_person_id` BIGINT NOT NULL,
`category` INT NOT NULL,
`title` VARCHAR(150) NOT NULL,
`ciphertext` LONGBLOB NOT NULL,
`nonce` VARBINARY(12) NOT NULL,
`tag` VARBINARY(16) NOT NULL,
`wrapped_content_key` VARBINARY(96) NOT NULL,
`release_policy` INT NOT NULL DEFAULT 0, -- 0 alleen ikzelf, 1 na overlijden, 2 op datum
`release_at` DATETIME(3) NULL,
`vault_file_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
`deleted_at` DATETIME(3) NULL,
PRIMARY KEY (`id`),
KEY `ix_vault_items_owner` (`owner_person_id`, `category`),
KEY `ix_vault_items_release` (`release_policy`, `release_at`),
CONSTRAINT `fk_vi_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_vi_file` FOREIGN KEY (`vault_file_id`) REFERENCES `vault_files` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB;
-- Toegang van een ander tot een item: dezelfde contentsleutel, ingepakt met
-- de datasleutel van die ander. Zonder rij hier is er geen toegang, punt.
CREATE TABLE IF NOT EXISTS `content_grants` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource_type` VARCHAR(30) NOT NULL, -- VaultItem | VaultFile | Message
`resource_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`wrapped_content_key` VARBINARY(96) NOT NULL,
`reason` VARCHAR(50) NOT NULL, -- ontvanger | nalatenschap | gedeeld
`granted_at` DATETIME(3) NOT NULL,
`revoked_at` DATETIME(3) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_grant` (`resource_type`, `resource_id`, `user_id`),
KEY `ix_grants_user` (`user_id`),
CONSTRAINT `fk_grant_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ---------------------------------------------------------------- berichten ---
CREATE TABLE IF NOT EXISTS `messages` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`from_person_id` BIGINT NOT NULL,
`to_person_id` BIGINT NOT NULL,
`kind` INT NOT NULL DEFAULT 0, -- 0 tekst, 1 video, 2 audio, 3 document
`subject` VARCHAR(200) NOT NULL,
`ciphertext` LONGBLOB NOT NULL,
`nonce` VARBINARY(12) NOT NULL,
`tag` VARBINARY(16) NOT NULL,
`wrapped_content_key` VARBINARY(96) NOT NULL,
`vault_file_id` BIGINT NULL,
`deliver_at` DATETIME(3) NULL,
`deliver_after_death` TINYINT(1) NOT NULL DEFAULT 0,
`status` INT NOT NULL DEFAULT 0, -- 0 concept, 1 gepland, 2 bezorgd, 3 gelezen, 4 ingetrokken
`delivered_at` DATETIME(3) NULL,
`read_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `ix_messages_to` (`to_person_id`, `status`),
KEY `ix_messages_from` (`from_person_id`),
-- De index waarop de bezorgtaak draait.
KEY `ix_messages_due` (`status`, `deliver_at`, `deliver_after_death`),
CONSTRAINT `fk_msg_from` FOREIGN KEY (`from_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_msg_to` FOREIGN KEY (`to_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_msg_file` FOREIGN KEY (`vault_file_id`) REFERENCES `vault_files` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB;
-- ------------------------------------------------------- overlijden & nalatenschap ---
-- Eén melding per gebruiker per profiel; dat dwingt de unieke sleutel af.
CREATE TABLE IF NOT EXISTS `death_declarations` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`person_id` BIGINT NOT NULL,
`declared_by_user_id` BIGINT NOT NULL,
`date_of_death` DATE NULL,
`note` VARCHAR(500) NULL,
`declared_at` DATETIME(3) NOT NULL,
`revoked_at` DATETIME(3) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_declaration` (`person_id`, `declared_by_user_id`),
CONSTRAINT `fk_dd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_dd_user` FOREIGN KEY (`declared_by_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `legacy_managers` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`person_id` BIGINT NOT NULL,
`manager_person_id` BIGINT NOT NULL,
`priority` INT NOT NULL DEFAULT 1,
`accepted_at` DATETIME(3) NULL,
`access_granted_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_legacy_manager` (`person_id`, `manager_person_id`),
CONSTRAINT `fk_lm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_lm_manager` FOREIGN KEY (`manager_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ------------------------------------------------------------------ sessies ---
CREATE TABLE IF NOT EXISTS `refresh_tokens` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`token_hash` VARBINARY(32) NOT NULL,
`device` VARCHAR(120) NULL,
`created_by_ip` VARCHAR(45) NULL,
`created_at` DATETIME(3) NOT NULL,
`expires_at` DATETIME(3) NOT NULL,
`revoked_at` DATETIME(3) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_refresh_token` (`token_hash`),
KEY `ix_refresh_user` (`user_id`, `expires_at`),
CONSTRAINT `fk_rt_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ---------------------------------------------------------------- sessies ---
-- Een aanmelding van de app. Staat in de database en niet in het geheugen van de SocketServer,
-- want anders logt elke herstart van de service iedereen uit - en dan is de vingerafdruk waarmee
-- je de app opent niets waard. Het token zelf staat er niet in, alleen de SHA-256 ervan.
CREATE TABLE IF NOT EXISTS `sessies` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`token_hash` VARBINARY(32) NOT NULL,
`user_id` BIGINT NOT NULL,
`person_id` BIGINT NOT NULL,
`toestel` VARCHAR(120) NULL,
`aangemaakt_op` DATETIME(3) NOT NULL,
`verloopt_op` DATETIME(3) NOT NULL,
`laatst_gezien` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_sessies_token` (`token_hash`),
KEY `ix_sessies_verloopt` (`verloopt_op`),
CONSTRAINT `fk_sessies_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ------------------------------------------------------------- inlogcodes ---
-- Er zijn geen wachtwoorden: je logt in met een code die per e-mail komt.
-- De code staat hier niet leesbaar in, alleen de hash ervan.
CREATE TABLE IF NOT EXISTS `login_codes` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`email` VARCHAR(255) NOT NULL,
`purpose` INT NOT NULL DEFAULT 0, -- 0 inloggen, 1 registreren
`code_hash` VARCHAR(255) NOT NULL,
`first_name` VARCHAR(80) NULL, -- alleen bij registreren
`last_name` VARCHAR(80) NULL,
`created_at` DATETIME(3) NOT NULL,
`expires_at` DATETIME(3) NOT NULL,
`consumed_at` DATETIME(3) NULL,
`attempts` INT NOT NULL DEFAULT 0,
`requested_from_ip` VARCHAR(45) NULL,
PRIMARY KEY (`id`),
-- De index waarop het opzoeken van een openstaande code draait.
KEY `ix_login_codes_open` (`email`, `purpose`, `expires_at`),
KEY `ix_login_codes_created` (`email`, `created_at`)
) ENGINE=InnoDB;
-- ----------------------------------------------------------------- logboek ---
-- Alleen toevoegen, nooit wijzigen. Bij een kluis wil je kunnen nagaan wie wat wanneer deed.
CREATE TABLE IF NOT EXISTS `audit_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NULL,
`action` INT NOT NULL,
`entity_type` VARCHAR(50) NULL,
`entity_id` BIGINT NULL,
`ip_address` VARCHAR(45) NULL,
`user_agent` VARCHAR(255) NULL,
`details` VARCHAR(1000) NULL,
`created_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `ix_audit_user_time` (`user_id`, `created_at`),
KEY `ix_audit_entity` (`entity_type`, `entity_id`)
) ENGINE=InnoDB;
SET FOREIGN_KEY_CHECKS = 1;