Remove influencer-era documentation, unused components, boilerplate assets, and ghost tiktok cron job. Add supplier invoice management, document API routes, and PWA icons. Replace boilerplate README. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
164 lines
5.0 KiB
PL/PgSQL
164 lines
5.0 KiB
PL/PgSQL
-- Migration 23: Document Version Chain Hardening
|
|
-- Adds cryptographic hash chain + atomic versioning via RPC
|
|
-- Resolves: Gap 4 (no hash-chain) + Gap 5 (race condition in versioning)
|
|
|
|
-- =============================================================================
|
|
-- 1. New columns on document_attachments
|
|
-- =============================================================================
|
|
|
|
ALTER TABLE public.document_attachments
|
|
ADD COLUMN IF NOT EXISTS prev_version_hash text,
|
|
ADD COLUMN IF NOT EXISTS last_integrity_check_at timestamptz;
|
|
|
|
COMMENT ON COLUMN public.document_attachments.prev_version_hash IS
|
|
'SHA-256 hash of the previous version. NULL for version 1 (genesis). Creates tamper-evident chain.';
|
|
|
|
COMMENT ON COLUMN public.document_attachments.last_integrity_check_at IS
|
|
'Timestamp of the last batch integrity verification (set by cron job).';
|
|
|
|
-- Index for cron job: prioritize documents never checked or least recently checked
|
|
CREATE INDEX IF NOT EXISTS idx_doc_attachments_integrity_check
|
|
ON public.document_attachments (last_integrity_check_at NULLS FIRST)
|
|
WHERE is_current_version = true;
|
|
|
|
-- =============================================================================
|
|
-- 2. RPC: create_document_version (atomic versioning with row-level lock)
|
|
-- Pattern follows next_voucher_number() from migration 016
|
|
-- =============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION public.create_document_version(
|
|
p_user_id uuid,
|
|
p_original_doc_id uuid,
|
|
p_storage_path text,
|
|
p_file_name text,
|
|
p_file_size_bytes bigint,
|
|
p_mime_type text,
|
|
p_sha256_hash text,
|
|
p_upload_source text DEFAULT NULL
|
|
)
|
|
RETURNS uuid
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
AS $$
|
|
DECLARE
|
|
v_current RECORD;
|
|
v_root_original_id uuid;
|
|
v_new_version integer;
|
|
v_new_id uuid;
|
|
BEGIN
|
|
-- Lock the current version row (prevents concurrent versioning)
|
|
SELECT id, version, sha256_hash, original_id, journal_entry_id,
|
|
journal_entry_line_id, upload_source
|
|
INTO v_current
|
|
FROM public.document_attachments
|
|
WHERE id = p_original_doc_id
|
|
AND user_id = p_user_id
|
|
AND is_current_version = true
|
|
FOR UPDATE;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Document not found, not owned by user, or not the current version';
|
|
END IF;
|
|
|
|
v_root_original_id := COALESCE(v_current.original_id, v_current.id);
|
|
v_new_version := v_current.version + 1;
|
|
|
|
-- Insert new version with hash chain link
|
|
INSERT INTO public.document_attachments (
|
|
user_id,
|
|
storage_path,
|
|
file_name,
|
|
file_size_bytes,
|
|
mime_type,
|
|
sha256_hash,
|
|
version,
|
|
original_id,
|
|
is_current_version,
|
|
uploaded_by,
|
|
upload_source,
|
|
digitization_date,
|
|
journal_entry_id,
|
|
journal_entry_line_id,
|
|
prev_version_hash
|
|
) VALUES (
|
|
p_user_id,
|
|
p_storage_path,
|
|
p_file_name,
|
|
p_file_size_bytes,
|
|
p_mime_type,
|
|
p_sha256_hash,
|
|
v_new_version,
|
|
v_root_original_id,
|
|
true,
|
|
p_user_id,
|
|
COALESCE(p_upload_source, v_current.upload_source),
|
|
now(),
|
|
v_current.journal_entry_id,
|
|
v_current.journal_entry_line_id,
|
|
v_current.sha256_hash -- cryptographic link to previous version
|
|
)
|
|
RETURNING id INTO v_new_id;
|
|
|
|
-- Mark old version as superseded (within same transaction = atomic)
|
|
UPDATE public.document_attachments
|
|
SET is_current_version = false,
|
|
superseded_by_id = v_new_id
|
|
WHERE id = v_current.id;
|
|
|
|
RETURN v_new_id;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.create_document_version(uuid, uuid, text, text, bigint, text, text, text) TO authenticated;
|
|
|
|
-- =============================================================================
|
|
-- 3. RPC: validate_version_chain
|
|
-- Verifies that prev_version_hash matches sha256_hash of the preceding version
|
|
-- =============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION public.validate_version_chain(
|
|
p_user_id uuid,
|
|
p_original_doc_id uuid
|
|
)
|
|
RETURNS TABLE (
|
|
doc_id uuid,
|
|
version integer,
|
|
sha256_hash text,
|
|
prev_version_hash text,
|
|
expected_prev_hash text,
|
|
chain_valid boolean
|
|
)
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
AS $$
|
|
BEGIN
|
|
RETURN QUERY
|
|
WITH chain AS (
|
|
SELECT
|
|
da.id,
|
|
da.version,
|
|
da.sha256_hash AS current_hash,
|
|
da.prev_version_hash AS stored_prev_hash,
|
|
LAG(da.sha256_hash) OVER (ORDER BY da.version) AS computed_prev_hash
|
|
FROM public.document_attachments da
|
|
WHERE da.user_id = p_user_id
|
|
AND (da.id = p_original_doc_id OR da.original_id = p_original_doc_id)
|
|
ORDER BY da.version
|
|
)
|
|
SELECT
|
|
chain.id AS doc_id,
|
|
chain.version,
|
|
chain.current_hash AS sha256_hash,
|
|
chain.stored_prev_hash AS prev_version_hash,
|
|
chain.computed_prev_hash AS expected_prev_hash,
|
|
CASE
|
|
WHEN chain.version = 1 THEN (chain.stored_prev_hash IS NULL)
|
|
ELSE (chain.stored_prev_hash IS NOT NULL AND chain.stored_prev_hash = chain.computed_prev_hash)
|
|
END AS chain_valid
|
|
FROM chain
|
|
ORDER BY chain.version;
|
|
END;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.validate_version_chain(uuid, uuid) TO authenticated;
|