diff --git a/.gitattributes b/.gitattributes index 853f783294..1470e49897 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,3 +22,20 @@ apps/web/performance/tzuyang-review-census-20261009/checkpoint-v1/census_admissi # Preserve original bytes of sealed production build logs bound to detached maps. apps/web/performance/readiness-develop-integration-20261010/production-build-v7.log -text apps/web/performance/readiness-followthrough-20261010/production-build-final-v10.log -text + +# The hash-bound audit bundle contains literal unified-diff blank context lines. +# Preserve those space markers; other whitespace checks still apply. +apps/web/performance/cross-audit-20261018/source_bundle.txt whitespace=-blank-at-eol +# Forward readiness SQL is sealed by the retained PG17 replay and audit bundle. +backend/supabase/migrations/20261016090000_review_verification_delete_guard_readiness.sql whitespace=-blank-at-eof + +# Preserve literal bytes of the final-source package and its failed build receipt. +apps/web/performance/cross-audit-20261018/final-source-antithesis-post-cms/final-source-antithesis-source_bundle.txt whitespace=-blank-at-eof +apps/web/performance/cross-audit-20261018/followup-verification/web-build.txt whitespace=-blank-at-eol + +# Preserve hash-bound original audit Markdown hard breaks and the raw test receipt. +# These exact evidence paths retain their bytes; source whitespace checks stay enabled. +apps/web/performance/cross-audit-20261019/ocr-indeterminate-recovery-independent-audit/audit.md whitespace=-blank-at-eol +apps/web/performance/cross-audit-20261019/ocr-indeterminate-recovery-independent-audit/final-candidate-audit-20261018T195954Z.md whitespace=-blank-at-eol +apps/web/performance/cross-audit-20261019/ocr-indeterminate-recovery-independent-audit/syntax-delta-followup-20261018T201149Z.md whitespace=-blank-at-eol +apps/web/performance/cross-audit-20261019/ocr-admission-recovery-app-integrated-verification/recovery108-source-ready-scoped-20261019.txt whitespace=-blank-at-eof diff --git a/.github/receipt-ocr-post105-sql-successor.v1.json b/.github/receipt-ocr-post105-sql-successor.v1.json new file mode 100644 index 0000000000..006fe5ed79 --- /dev/null +++ b/.github/receipt-ocr-post105-sql-successor.v1.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": 1, + "id": "receipt_ocr_post105_sql_successor_v1", + "purpose": "receipt-ocr-definitive-credential-release-post105", + "projectRef": "aqlcofblfxdrjhhdmarw", + "serverMajor": 17, + "databaseName": "postgres", + "executorRole": "postgres", + "advisoryLockKey": 7311754282260313, + "launchPolicy": { + "state": "held", + "code": "RECEIPT_OCR_POST105_LAUNCH_HELD", + "reason": "source-only-contract-pending-exact-pg17-constituent-and-aggregate-evidence" + }, + "protectedSource": { + "remote": "origin", + "ref": "refs/heads/main", + "repositoryUrl": "https://github.com/twoimo/tzudong.git" + }, + "stateRootAlgorithm": "review-media-ledger-identity/v1", + "operatingTransition": { + "before": 105, + "after": 106 + }, + "predecessor": { + "manifestPath": ".github/review-media-evidence-prefixed-feature-successor.v1.json", + "manifestSha256": "45b7f146f31bca9afb863152fd616529e90562fb1b3ecbaa409c218439d41867", + "ledgerCount": 105, + "terminalIdentityRoot": "3ef9d904fd9545027d39041483e5375e6d9c861f5f1a64a61c23b94df1146e4c" + }, + "rehearsalFixture": { + "path": "apps/web/performance/feature-sql-integration-20261013/pg17-evidence-prefixed-aggregate-compiled-20261018.json", + "receiptSha256": "0efdf7f1c9c60feda3d14d9473c464fa8ce91a23d5f12d9427bfbac2d4ccc0c6", + "ledgerCount": 105, + "terminalIdentityRoot": "2a1548759fa029261eebd8d121777e3ca7d51520722c750c4a1b0794040a0ab0", + "targetIdentityRoot": "b6184e8b14977dcba0d66959d1ef266b29268f5420b86451a7d213b81d5c6e24" + }, + "migration": { + "id": "receipt_ocr_definitive_credential_rejection_release", + "version": "20261018152032", + "name": "receipt_ocr_definitive_credential_rejection_release", + "path": "backend/supabase/migrations/20261018152032_receipt_ocr_definitive_credential_rejection_release.sql", + "sha256": "9dc92d7b42f8329d16b61ac51bf0b05a8533a1655a94faa8cd74e3d9a92fe74c", + "statementCount": 44, + "statementVectorSha256": "54df90f1f3e6b23eb11b949d4c0d57856e02dcfb66fc998b5065cc424a34d14e" + }, + "operatingIdentity": { + "migrationIdentityRoot": "17dc235a7c78f273a2e4c1d9030995f712da1a2e42e8cca28c8c7b732b37e65a", + "suffixRoot": "a1d19693f49105141cb75c37645824a41637e5a8047aaca837fd853d4fbe4b07", + "targetIdentityRoot": "83a1b91fa12541581e53dca933a12ca6c5aa4470dd70f912444d38dcd19522c1" + }, + "requiredWriterFences": { + "adminRecordMutationsHold": "active", + "g037WriteFreeze": "active", + "automaticReviewer": "stopped", + "browserRpc": "stopped", + "directSql": "stopped", + "dashboard": "stopped", + "providerIngress": "stopped" + }, + "toolchain": [ + { + "path": "apps/web/scripts/receipt-ocr-post105-sql-successor.mjs", + "sha256": "4ba7e8b5c12e212809acf6d3c5fdaed4cf58ac70a1c663c25fb7db0bc979ebda" + } + ] +} diff --git a/.github/receipt-ocr-post106-acl-admission-sql-successor.v1.json b/.github/receipt-ocr-post106-acl-admission-sql-successor.v1.json new file mode 100644 index 0000000000..854a60ed51 --- /dev/null +++ b/.github/receipt-ocr-post106-acl-admission-sql-successor.v1.json @@ -0,0 +1,79 @@ +{ + "schemaVersion": 1, + "id": "receipt_ocr_post106_acl_admission_sql_successor_v1", + "purpose": "receipt-ocr-credential-release-acl-admission-post106", + "projectRef": "aqlcofblfxdrjhhdmarw", + "serverMajor": 17, + "databaseName": "postgres", + "executorRole": "postgres", + "advisoryLockKey": 7311754282260314, + "launchPolicy": { + "state": "held", + "code": "RECEIPT_OCR_POST106_ACL_LAUNCH_HELD", + "reason": "source-only-contract-pending-exact-pg17-default-acl-negative-and-aggregate-evidence" + }, + "protectedSource": { + "remote": "origin", + "ref": "refs/heads/main", + "repositoryUrl": "https://github.com/twoimo/tzudong.git" + }, + "stateRootAlgorithm": "review-media-ledger-identity/v1", + "operatingTransition": { + "before": 105, + "intermediate": 106, + "after": 107 + }, + "predecessor": { + "manifestPath": ".github/review-media-evidence-prefixed-feature-successor.v1.json", + "manifestSha256": "45b7f146f31bca9afb863152fd616529e90562fb1b3ecbaa409c218439d41867", + "ledgerCount": 105, + "terminalIdentityRoot": "3ef9d904fd9545027d39041483e5375e6d9c861f5f1a64a61c23b94df1146e4c" + }, + "baseSuccessor": { + "state": "held_unapplied", + "manifestPath": ".github/receipt-ocr-post105-sql-successor.v1.json", + "manifestSha256": "ecc1d0678210f4ac06fe47fc4b62cfdd0694511aacc18ab20978d3e83d82af81", + "compilerPath": "apps/web/scripts/receipt-ocr-post105-sql-successor.mjs", + "compilerSha256": "4ba7e8b5c12e212809acf6d3c5fdaed4cf58ac70a1c663c25fb7db0bc979ebda", + "operatingTargetIdentityRoot": "83a1b91fa12541581e53dca933a12ca6c5aa4470dd70f912444d38dcd19522c1", + "rehearsalTargetIdentityRoot": "b6184e8b14977dcba0d66959d1ef266b29268f5420b86451a7d213b81d5c6e24" + }, + "rehearsalFixture": { + "path": "apps/web/performance/feature-sql-integration-20261013/pg17-evidence-prefixed-aggregate-compiled-20261018.json", + "receiptSha256": "0efdf7f1c9c60feda3d14d9473c464fa8ce91a23d5f12d9427bfbac2d4ccc0c6", + "ledgerCount": 105, + "terminalIdentityRoot": "2a1548759fa029261eebd8d121777e3ca7d51520722c750c4a1b0794040a0ab0", + "stage106IdentityRoot": "b6184e8b14977dcba0d66959d1ef266b29268f5420b86451a7d213b81d5c6e24", + "targetIdentityRoot": "1b869222e1efc419e744eeabda52fd1d1900a6a71308a7227a583859136706f7" + }, + "migration": { + "id": "receipt_ocr_credential_release_acl_admission_v2", + "version": "20261019120000", + "name": "receipt_ocr_credential_release_acl_admission_v2", + "path": "backend/supabase/migrations/20261019120000_receipt_ocr_credential_release_acl_admission_v2.sql", + "sha256": "512f778b71a196e28f092266782fe5938b7d15ec72b61bf05978f3b1696faec9", + "statementCount": 27, + "statementVectorSha256": "37decc48cfcd3cbb9e40afd6511929262da2400264aa1f18e51317a01d92e9bf" + }, + "operatingIdentity": { + "stage106IdentityRoot": "83a1b91fa12541581e53dca933a12ca6c5aa4470dd70f912444d38dcd19522c1", + "migrationIdentityRoot": "54d0570e259be8f6435da9ab655424d98a0566e36809ce37d52c631ebad2f696", + "suffixRoot": "d473f460ed45863ce606769339c7929e7735675159c3da1a11653bcbca16a664", + "targetIdentityRoot": "cd04aebfc1efcc5588486adf3b7ba47fc5b5b76e39a9c23e316768f2919d9d9e" + }, + "requiredWriterFences": { + "adminRecordMutationsHold": "active", + "g037WriteFreeze": "active", + "automaticReviewer": "stopped", + "browserRpc": "stopped", + "directSql": "stopped", + "dashboard": "stopped", + "providerIngress": "stopped" + }, + "toolchain": [ + { + "path": "apps/web/scripts/receipt-ocr-post106-acl-admission-sql-successor.mjs", + "sha256": "5dd0c66725e14ee9584dd162dccda4419c304ddbda6f3f28b1e645b7aa686b21" + } + ] +} diff --git a/.github/receipt-ocr-recovery108-sql-successor.v1.json b/.github/receipt-ocr-recovery108-sql-successor.v1.json new file mode 100644 index 0000000000..d221b774bd --- /dev/null +++ b/.github/receipt-ocr-recovery108-sql-successor.v1.json @@ -0,0 +1,73 @@ +{ + "schemaVersion": 1, + "id": "receipt_ocr_recovery108_sql_successor_v1", + "purpose": "receipt-ocr-provider-indeterminate-recovery", + "projectRef": "aqlcofblfxdrjhhdmarw", + "serverMajor": 17, + "databaseName": "postgres", + "executorRole": "postgres", + "advisoryLockKey": 7311754282260318, + "launchPolicy": { + "state": "held", + "code": "RECEIPT_OCR_RECOVERY108_LAUNCH_HELD", + "reason": "source-ready-held-no-operating-executor-or-hosted-authorization" + }, + "protectedSource": { + "remote": "origin", + "ref": "refs/heads/main", + "repositoryUrl": "https://github.com/twoimo/tzudong.git" + }, + "stateRootAlgorithm": "review-media-ledger-identity/v1", + "operatingTransition": { + "before": 107, + "after": 108 + }, + "predecessor": { + "state": "source_ready", + "manifestPath": ".github/receipt-ocr-post106-acl-admission-sql-successor.v1.json", + "manifestSha256": "58adb514cbdf4221952b9bddfc75dead6a91f74d6fedc0d39445983c542e9b5a", + "ledgerCount": 107, + "terminalIdentityRoot": "cd04aebfc1efcc5588486adf3b7ba47fc5b5b76e39a9c23e316768f2919d9d9e" + }, + "migration": { + "id": "receipt_ocr_indeterminate_recovery", + "version": "20261019130000", + "name": "receipt_ocr_indeterminate_recovery", + "path": "backend/supabase/migrations/20261019130000_receipt_ocr_indeterminate_recovery.sql", + "sha256": "d3258b679cbf42ecd97edeab941a5bbaf30fa7b15d968081b60b8b600a3ecd1a", + "statementCount": 83, + "statementVectorSha256": "3d0bfeb1229b194f818b95a1b4b1ba50609770a93afd8e3c67daabd513c0bd56" + }, + "operatingIdentity": { + "migrationIdentityRoot": "0f6f764cb097b9ae1e5387a395411ea6fcd98cd5286bace4542901a1c08f3f6e", + "predecessorIdentityRoot": "cd04aebfc1efcc5588486adf3b7ba47fc5b5b76e39a9c23e316768f2919d9d9e", + "targetIdentityRoot": "2c7b7fff113a68ad4b73a8ebb005296ddd1344de5fa09fcf828b905e4517bb1d" + }, + "fixture": { + "path": "backend/supabase/tests/receipt_ocr_indeterminate_recovery.sql", + "sha256": "01766da42c73195925c4fdcf3dbbdcf1efb651b947e3087481306f86179e76c2" + }, + "aclInitialDefaultFixture": { + "path": "backend/supabase/tests/receipt_ocr_indeterminate_recovery_acl_initial_default.sql", + "sha256": "01a712df0fa0de40c3b0760f52d51c8238bc0f4ecb5ebb8f53160eb1fa5a9e20" + }, + "aclAdmissionFixture": { + "path": "backend/supabase/tests/receipt_ocr_indeterminate_recovery_acl_admission.sql", + "sha256": "76317420f3be8199fccfbf716ad024573dad324d6f906c3a811c70e4e28df66e" + }, + "toolchain": [ + { + "path": "apps/web/scripts/receipt-ocr-recovery108-sql-successor.mjs", + "sha256": "5a9154f51cfe19f25007ce1737eee969faa32c166698b977884218c370ad20f8" + } + ], + "requiredWriterFences": { + "adminRecordMutationsHold": "active", + "g037WriteFreeze": "active", + "automaticReviewer": "stopped", + "browserRpc": "stopped", + "directSql": "stopped", + "dashboard": "stopped", + "providerIngress": "stopped" + } +} diff --git a/.github/review-media-evidence-prefixed-feature-successor.v1.json b/.github/review-media-evidence-prefixed-feature-successor.v1.json new file mode 100644 index 0000000000..b50fa79fa8 --- /dev/null +++ b/.github/review-media-evidence-prefixed-feature-successor.v1.json @@ -0,0 +1,111 @@ +{ + "schemaVersion": 1, + "id": "review_media_evidence_prefixed_feature_successor_v1", + "phase": "feature", + "purpose": "review-media-evidence-prefixed-five-stage", + "projectRef": "aqlcofblfxdrjhhdmarw", + "serverMajor": 17, + "databaseName": "postgres", + "executorRole": "postgres", + "advisoryLockKey": 7311754282260312, + "launchPolicy": { + "state": "held", + "code": "EVIDENCE_PREFIXED_FEATURE_LAUNCH_HELD", + "reason": "fresh-evidence-first-rehearsal-and-protected-admission-required" + }, + "protectedSource": { + "remote": "origin", + "ref": "refs/heads/main", + "repositoryUrl": "https://github.com/twoimo/tzudong.git" + }, + "stateRootAlgorithm": "review-media-ledger-identity/v1", + "operatingTransition": { + "before": 100, + "after": 105 + }, + "baseMapping": { + "path": "apps/web/performance/cross-audit-20261018/hosted-ledger-mapping-20261018.json", + "sha256": "7d504bcff6afc3008935dedf5978d3f576c8fbf0f2e8ea4ff55641e7c7b5ba0a", + "rowCount": 86, + "baseIdentityRoot": "394d3517244d79a673b0595dec0fefa40e6d8257b3762c27dd56a01cc198115d" + }, + "historicalAppliedVariant": { + "version": "20261008124858", + "name": "public_profile_leaderboard_read_boundary", + "statementCount": 1, + "statementVectorSha256": "8bab7119608afa8cf4fec529fb5cab6546fbd0c9326e08b56c9a053daa21b360" + }, + "evidenceMigrations": [ + { + "version": "20261017043000", + "name": "evidence_frames_and_popularity", + "path": "backend/supabase/migrations/20261017043000_evidence_frames_and_popularity.sql", + "sha256": "d254fe76eb8c74587183bc89372891cd08a6a1cf92a99b8aa041497fa6ca7733", + "statementCount": 8, + "statementVectorSha256": "e8c4dbbe5fad3c9af301d5dfd0843417c3de148bbbddbde677c8b755ed6e45e4" + }, + { + "version": "20261017140000", + "name": "restaurant_popularity_shadow", + "path": "backend/supabase/migrations/20261017140000_restaurant_popularity_shadow.sql", + "sha256": "fe0621f3a6f663beec2c14a60729ddbb17ba715205a114f24879af9b4dba11c6", + "statementCount": 3, + "statementVectorSha256": "6b1de5b932c1cf769436d5048fdde157127c7e5cb294c2b284ca63940995d16a" + }, + { + "version": "20261017160000", + "name": "restaurant_popularity_shadow_tier", + "path": "backend/supabase/migrations/20261017160000_restaurant_popularity_shadow_tier.sql", + "sha256": "4ba19ee5b7ae3fbbfc4ffa00a93ed30e1197cd2ad1763e063a75f75158a48426", + "statementCount": 1, + "statementVectorSha256": "ed0eacead47eab1f9ceffacd0ab49abb035c40a7a7ecb1bacb19e34070088359" + } + ], + "targetManifest": { + "id": "review_media_feature_sql_successor_v1", + "manifestPath": ".github/review-media-feature-sql-successor.v1.json", + "manifestSha256": "360e5b56fd2214523ee5738eec94e1d77f9050f899dd243136abff3b6b1cb3df", + "sourceRoot": "960c276aa12a571c7a0c70261bcbaea61e668b5cb8872bd6f15e5bf577b04ab9", + "targetCount": 5 + }, + "operatingIdentity": { + "evidenceIdentityRoot": "37d598ad72fd4c0a057a34579e5c2053fc9a7a92645d8d045530890ad1034802", + "prefixIdentityRoot": "3bddf89c99ba6f4e635ed25c66451a92c23eaf95c2b387434978c04baad900d1", + "targetStageRoots": [ + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "97271e80e3e79de33d26e9e79c1b5e6adb889b9780f119bb175c085eac5f6d0f", + "cc0991cb8359dc1aad086b27207a128d1c618deeb7aa7d1f32ef98cf42aba480", + "0e88d834ace4da6763191e6d1565f6126f402e5c832faf5cdbd4f49f215a6784", + "9842e3591bb87bf8cc4f19c7f5fdab2459bdea31c74290b61b18c66c5d4c0f50", + "a52c56662c26a3e2fca757b43f36103f4b1c265e25d3e3133881047f23a96332" + ], + "ledgerStageRoots": [ + "3bddf89c99ba6f4e635ed25c66451a92c23eaf95c2b387434978c04baad900d1", + "7d4832d0aee15ebcfc22bba65dafa7593800535fb38823c89c2ba06be0d6f2ff", + "33e22b020343d42565ef14ddf19b9baf772c9b394387058b7b2397fcdeb94e1b", + "138f29125a170210f25cef3446cfa752c99612c324062ff7e81f173493e6ffba", + "6f4c133842fef1eb7e6f37d62cc1a8cce98a3974c44e1f619e6af8e5836af54f", + "3ef9d904fd9545027d39041483e5375e6d9c861f5f1a64a61c23b94df1146e4c" + ], + "terminalIdentityRoot": "3ef9d904fd9545027d39041483e5375e6d9c861f5f1a64a61c23b94df1146e4c" + }, + "requiredWriterFences": { + "adminRecordMutationsHold": "active", + "g037WriteFreeze": "active", + "automaticReviewer": "stopped", + "browserRpc": "stopped", + "directSql": "stopped", + "dashboard": "stopped", + "providerIngress": "stopped" + }, + "toolchain": [ + { + "path": "apps/web/scripts/review-media-evidence-prefixed-sql-successors.mjs", + "sha256": "d76e45f0d3284d47a32231495409843b640f8107969ebf23814498f277827d3d" + }, + { + "path": "apps/web/scripts/review-media-evidence-prefixed-feature-sql-successor.mjs", + "sha256": "1abc26c0bc4a2546f0e8930067a2d0e089d156b37ed5d160eee6a3e53e5ef618" + } + ] +} diff --git a/.github/review-media-evidence-prefixed-predecessor-successor.v1.json b/.github/review-media-evidence-prefixed-predecessor-successor.v1.json new file mode 100644 index 0000000000..9c3cb411f2 --- /dev/null +++ b/.github/review-media-evidence-prefixed-predecessor-successor.v1.json @@ -0,0 +1,123 @@ +{ + "schemaVersion": 1, + "id": "review_media_evidence_prefixed_predecessor_successor_v1", + "phase": "predecessor", + "purpose": "review-media-evidence-prefixed-eleven-stage", + "projectRef": "aqlcofblfxdrjhhdmarw", + "serverMajor": 17, + "databaseName": "postgres", + "executorRole": "postgres", + "advisoryLockKey": 7311754282260311, + "launchPolicy": { + "state": "held", + "code": "EVIDENCE_PREFIXED_PREDECESSOR_LAUNCH_HELD", + "reason": "fresh-evidence-first-rehearsal-and-protected-admission-required" + }, + "protectedSource": { + "remote": "origin", + "ref": "refs/heads/main", + "repositoryUrl": "https://github.com/twoimo/tzudong.git" + }, + "stateRootAlgorithm": "review-media-ledger-identity/v1", + "operatingTransition": { + "before": 89, + "after": 100 + }, + "baseMapping": { + "path": "apps/web/performance/cross-audit-20261018/hosted-ledger-mapping-20261018.json", + "sha256": "7d504bcff6afc3008935dedf5978d3f576c8fbf0f2e8ea4ff55641e7c7b5ba0a", + "rowCount": 86, + "baseIdentityRoot": "394d3517244d79a673b0595dec0fefa40e6d8257b3762c27dd56a01cc198115d" + }, + "historicalAppliedVariant": { + "version": "20261008124858", + "name": "public_profile_leaderboard_read_boundary", + "statementCount": 1, + "statementVectorSha256": "8bab7119608afa8cf4fec529fb5cab6546fbd0c9326e08b56c9a053daa21b360" + }, + "evidenceMigrations": [ + { + "version": "20261017043000", + "name": "evidence_frames_and_popularity", + "path": "backend/supabase/migrations/20261017043000_evidence_frames_and_popularity.sql", + "sha256": "d254fe76eb8c74587183bc89372891cd08a6a1cf92a99b8aa041497fa6ca7733", + "statementCount": 8, + "statementVectorSha256": "e8c4dbbe5fad3c9af301d5dfd0843417c3de148bbbddbde677c8b755ed6e45e4" + }, + { + "version": "20261017140000", + "name": "restaurant_popularity_shadow", + "path": "backend/supabase/migrations/20261017140000_restaurant_popularity_shadow.sql", + "sha256": "fe0621f3a6f663beec2c14a60729ddbb17ba715205a114f24879af9b4dba11c6", + "statementCount": 3, + "statementVectorSha256": "6b1de5b932c1cf769436d5048fdde157127c7e5cb294c2b284ca63940995d16a" + }, + { + "version": "20261017160000", + "name": "restaurant_popularity_shadow_tier", + "path": "backend/supabase/migrations/20261017160000_restaurant_popularity_shadow_tier.sql", + "sha256": "4ba19ee5b7ae3fbbfc4ffa00a93ed30e1197cd2ad1763e063a75f75158a48426", + "statementCount": 1, + "statementVectorSha256": "ed0eacead47eab1f9ceffacd0ab49abb035c40a7a7ecb1bacb19e34070088359" + } + ], + "targetManifest": { + "id": "review_media_sql_successor_v1", + "manifestPath": ".github/review-media-sql-successor.v1.json", + "manifestSha256": "ad1da2db884229ea4b1fbcf6e4caaa04c30c6df7562e94fff01341736ee689d6", + "sourceRoot": "61f9d7637c0714a7b0d9108c6f7959ea820175a7870dba681498b570296a1544", + "targetCount": 11 + }, + "operatingIdentity": { + "evidenceIdentityRoot": "37d598ad72fd4c0a057a34579e5c2053fc9a7a92645d8d045530890ad1034802", + "prefixIdentityRoot": "2a92473931847b85f90224bbc645a14425c96fc3d92019b6eeca8e6f43a504ea", + "targetStageRoots": [ + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "9bbc6efe38d16acc237f28369c492110b594d6a7ba3fc44cbf0380bc77dace22", + "a8ae77a6afeba7078ac94982623ef0e75a54b261238f8fabc2cd29f14ada244c", + "aa6ce2a2df98576c5ce069adf5a5c78e82fc92b2c63e8157f975b0def0cc93f4", + "edd12e029e6e967e2f37820dcebad7a4884fa655f40c961d21ac085e09d411aa", + "c9e40f8a7db153c1054c8514747b1588a972ac0985f571c62f1370d012f4c8e5", + "6b68e996a4116b87a4518134768b8c51a6e4d3f49b3c158dbc0ce1bc15d2d55f", + "e8af028d8c10f44287dcec975e6a21bcb9f41c4491e20eb5c42de5fe8e14cb90", + "e0d992a1d21435b1d7423e21f5a13ec24d195a9e26aad36596890d54197ed7e1", + "0a58b1942e77ce3e6b4b384c028cd5ce6166bd63aa7a0ad5abfe29e54150b2fb", + "b1e33fbbcc87e5c220faeea86da2e6c28b5eda43bc3fa7000ba71510526c228f", + "7e8273296aa9073d8201ef2696f974ea99c4106d70d1f2d20d67f103aa4fa025" + ], + "ledgerStageRoots": [ + "2a92473931847b85f90224bbc645a14425c96fc3d92019b6eeca8e6f43a504ea", + "0099d364184b725bd747d472a32c5e7f74664f7f732858c71f6667fa51e049bf", + "c38f13cc1a2744ed9a08bd53feb28a127745ada4728f65df3b1ec20a68c8c938", + "c1ba46b464ecd469b6c7e3b9b4b879eef55a4401fc5fae075c9385a69c45c2b4", + "7bf946871314bae9f52e6413aceabf6e7cbb6a818c35174aea79a9122015c93f", + "3d640bf7c4d9ff118e3a32cc11cbd05532132c6f357c8638f5871ab4f2fce4fc", + "f7ef0e87a86da269de5776777a511928eb93a6b28c46e0063672c2dd8e601990", + "7b6e8c84b09c0f8001c70e9700dee3165bf9950770d44f1109af77a2ac801736", + "4b7982cdaef63b297b703587d3d315eeda7ee477cf297b6b7b7a0443549003c1", + "feaf1cce7edb1b38c8b73a138b63145a211c9534f9aea07bbf10872f298346ec", + "cf52257f0f17f4e52d594b9d4d009a78cade28acc553485e26b24dffecda853b", + "3bddf89c99ba6f4e635ed25c66451a92c23eaf95c2b387434978c04baad900d1" + ], + "terminalIdentityRoot": "3bddf89c99ba6f4e635ed25c66451a92c23eaf95c2b387434978c04baad900d1" + }, + "requiredWriterFences": { + "adminRecordMutationsHold": "active", + "g037WriteFreeze": "active", + "automaticReviewer": "stopped", + "browserRpc": "stopped", + "directSql": "stopped", + "dashboard": "stopped", + "providerIngress": "stopped" + }, + "toolchain": [ + { + "path": "apps/web/scripts/review-media-evidence-prefixed-sql-successors.mjs", + "sha256": "d76e45f0d3284d47a32231495409843b640f8107969ebf23814498f277827d3d" + }, + { + "path": "apps/web/scripts/review-media-evidence-prefixed-predecessor-sql-successor.mjs", + "sha256": "54d5a66695938c66b75c2c89b456dbfae9e876592fcbe3d5f9310ffc7cf2a07f" + } + ] +} diff --git a/.github/review-media-feature-sql-successor.v1.json b/.github/review-media-feature-sql-successor.v1.json new file mode 100644 index 0000000000..698d64fc1f --- /dev/null +++ b/.github/review-media-feature-sql-successor.v1.json @@ -0,0 +1,286 @@ +{ + "schemaVersion": 1, + "id": "review_media_feature_sql_successor_v1", + "purpose": "review-media-feature-five-stage", + "projectRef": "aqlcofblfxdrjhhdmarw", + "protectedSource": { + "remote": "origin", + "ref": "refs/heads/main", + "repositoryUrl": "https://github.com/twoimo/tzudong.git" + }, + "databaseName": "postgres", + "executorRole": "postgres", + "serverMajor": 17, + "advisoryLockKey": "7311754282260212", + "stateRootAlgorithm": "review-media-feature-successor-state/v1", + "sourceRoot": "960c276aa12a571c7a0c70261bcbaea61e668b5cb8872bd6f15e5bf577b04ab9", + "operatingTransition": { + "beforeFive": 97, + "afterFive": 102 + }, + "predecessor": { + "id": "review_media_sql_successor_v1", + "manifestPath": ".github/review-media-sql-successor.v1.json", + "manifestSha256": "ad1da2db884229ea4b1fbcf6e4caaa04c30c6df7562e94fff01341736ee689d6", + "sourceRoot": "61f9d7637c0714a7b0d9108c6f7959ea820175a7870dba681498b570296a1544", + "afterLedger": 97 + }, + "launchPolicy": { + "state": "held", + "code": "FEATURE_SUCCESSOR_LAUNCH_HELD", + "reason": "protected-feature-release-and-fresh-admission-required" + }, + "migrations": [ + { + "bytes": 15228, + "id": "storyboard_gemini_execution_v1", + "name": "storyboard_gemini_execution_v1", + "originalStatementVector": [ + "-- Separate immutable storyboard-mlx-v1 history from storyboard-gemini-v1 execution.\n-- Existing rows, revisions, assets and events remain readable; only the new workflow can mutate or queue.\nBEGIN", + "ALTER TABLE public.admin_storyboard_production_projects\n DROP CONSTRAINT admin_storyboard_production_projects_request_check,\n DROP CONSTRAINT admin_storyboard_production_projects_check", + "ALTER TABLE public.admin_storyboard_production_projects\n ADD CONSTRAINT admin_storyboard_production_projects_request_workflow_check CHECK (\n jsonb_typeof(request) = 'object' AND octet_length(request::text) <= 65536\n AND (request->>'workflow' = 'storyboard-mlx-v1' OR (\n request->>'workflow' = 'storyboard-gemini-v1'\n AND request#>>'{providers,externalAI}' = 'true'\n AND request#>>'{providers,text,id}' = 'gemini-api'\n AND request#>>'{providers,text,model}' = 'gemini-3.8-flash'\n AND request#>>'{providers,image,id}' = 'gemini-api'\n AND request#>>'{providers,image,model}' IN ('gemini-3.1-flash-image','gemini-3-pro-image')))),\n ADD CONSTRAINT admin_storyboard_production_projects_document_workflow_check CHECK (\n document IS NULL OR (jsonb_typeof(document) = 'object'\n AND octet_length(document::text) <= 196608\n AND document->>'schema' = request->>'workflow'\n AND document->>'projectId' = id::text\n AND (document->>'revision')::integer = revision))", + "ALTER TABLE public.admin_storyboard_production_revisions\n DROP CONSTRAINT admin_storyboard_production_revisions_check", + "ALTER TABLE public.admin_storyboard_production_revisions\n ADD CONSTRAINT admin_storyboard_production_revisions_document_workflow_check CHECK (\n jsonb_typeof(document) = 'object' AND octet_length(document::text) <= 196608\n AND document->>'schema' IN ('storyboard-mlx-v1', 'storyboard-gemini-v1')\n AND document->>'projectId' = project_id::text\n AND (document->>'revision')::integer = revision)", + "-- Active legacy work is terminally held at cutover. Documents, assets, revisions,\n-- restore receipts, attempts and prior events stay intact and readable.\nWITH legacy_active AS (\n SELECT j.id AS job_id, j.project_id, j.owner_id, j.worker_id AS former_worker_id, p.revision\n FROM public.admin_storyboard_production_jobs j\n JOIN public.admin_storyboard_production_projects p ON p.id = j.project_id AND p.owner_id = j.owner_id\n WHERE p.request->>'workflow' = 'storyboard-mlx-v1' AND j.status IN ('queued','claimed')\n FOR UPDATE OF j, p\n), retired AS (\n UPDATE public.admin_storyboard_production_jobs j\n SET status = 'cancelled', stage = 'cancelled', error_code = 'generation_cancelled',\n worker_id = NULL, lease_token = NULL, lease_expires_at = NULL, updated_at = clock_timestamp()\n FROM legacy_active l WHERE j.id = l.job_id\n RETURNING j.id, l.project_id, l.owner_id, l.former_worker_id, l.revision\n), held_projects AS (\n UPDATE public.admin_storyboard_production_projects p\n SET status = 'cancelled', updated_at = clock_timestamp()\n FROM retired r WHERE p.id = r.project_id AND p.owner_id = r.owner_id\n RETURNING p.id\n)\nINSERT INTO public.admin_storyboard_production_events(\n project_id, owner_id, job_id, worker_id, operation, revision, error_code)\nSELECT r.project_id, r.owner_id, r.id, r.former_worker_id, 'cancelled', r.revision, 'generation_cancelled'\nFROM retired r", + "-- A function invocation that started before this transaction can retain its old\n-- PL/pgSQL body while waiting for these tables. These row guards are installed in\n-- the same transaction as the cutover so that such an invocation cannot recreate\n-- or mutate archived work after the table locks are released at commit.\nCREATE FUNCTION public.storyboard_production_reject_archived_write() RETURNS trigger\nLANGUAGE plpgsql\nSECURITY INVOKER\nSET search_path = ''\nAS $storyboard_archive_guard$\nDECLARE old_project_id uuid; new_project_id uuid;\nBEGIN\n IF TG_TABLE_NAME = 'admin_storyboard_production_projects' THEN\n IF (TG_OP = 'UPDATE' AND OLD.request->>'workflow' = 'storyboard-mlx-v1')\n OR NEW.request->>'workflow' = 'storyboard-mlx-v1' THEN\n RAISE EXCEPTION 'archived_project_read_only' USING ERRCODE = '55000';\n END IF;\n RETURN NEW;\n END IF;\n\n new_project_id := NEW.project_id;\n IF TG_OP = 'UPDATE' THEN old_project_id := OLD.project_id; END IF;\n IF EXISTS (SELECT 1 FROM public.admin_storyboard_production_projects p\n WHERE p.id IN (new_project_id, old_project_id)\n AND p.request->>'workflow' = 'storyboard-mlx-v1') THEN\n RAISE EXCEPTION 'archived_project_read_only' USING ERRCODE = '55000';\n END IF;\n RETURN NEW;\nEND;\n$storyboard_archive_guard$", + "REVOKE ALL ON FUNCTION public.storyboard_production_reject_archived_write() FROM PUBLIC, anon, authenticated, privacy_workflow_owner", + "GRANT EXECUTE ON FUNCTION public.storyboard_production_reject_archived_write() TO service_role", + "CREATE TRIGGER storyboard_archive_projects_write_guard\n BEFORE INSERT OR UPDATE ON public.admin_storyboard_production_projects\n FOR EACH ROW EXECUTE FUNCTION public.storyboard_production_reject_archived_write()", + "CREATE TRIGGER storyboard_archive_jobs_write_guard\n BEFORE INSERT OR UPDATE ON public.admin_storyboard_production_jobs\n FOR EACH ROW EXECUTE FUNCTION public.storyboard_production_reject_archived_write()", + "CREATE TRIGGER storyboard_archive_assets_write_guard\n BEFORE INSERT OR UPDATE ON public.admin_storyboard_production_assets\n FOR EACH ROW EXECUTE FUNCTION public.storyboard_production_reject_archived_write()", + "CREATE TRIGGER storyboard_archive_events_write_guard\n BEFORE INSERT OR UPDATE ON public.admin_storyboard_production_events\n FOR EACH ROW EXECUTE FUNCTION public.storyboard_production_reject_archived_write()", + "CREATE TRIGGER storyboard_archive_revisions_write_guard\n BEFORE INSERT OR UPDATE ON public.admin_storyboard_production_revisions\n FOR EACH ROW EXECUTE FUNCTION public.storyboard_production_reject_archived_write()", + "CREATE TRIGGER storyboard_archive_restores_write_guard\n BEFORE INSERT OR UPDATE ON public.admin_storyboard_production_restores\n FOR EACH ROW EXECUTE FUNCTION public.storyboard_production_reject_archived_write()", + "DO $storyboard_gemini_admin$\nDECLARE target oid; definition text; body text; old text; replacement text;\n metadata_before jsonb; metadata_after jsonb; occurrences integer;\nBEGIN\n target := pg_catalog.to_regprocedure('public.storyboard_production_admin(uuid,text,uuid,integer,jsonb)');\n SELECT pg_catalog.pg_get_functiondef(p.oid), p.prosrc,\n jsonb_build_array(p.proowner,p.proacl,p.prosecdef,p.proconfig,p.provolatile,p.proparallel)\n INTO definition, body, metadata_before FROM pg_catalog.pg_proc p\n WHERE p.oid = target AND p.proowner = 'postgres'::pg_catalog.regrole AND NOT p.prosecdef\n AND p.proconfig = ARRAY['search_path=\"\"']::text[];\n IF definition IS NULL THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_ADMIN_SOURCE_DRIFT'; END IF;\n\n old := $old$IF p_action = 'create' THEN\n IF p_payload->>'workflow' IS DISTINCT FROM 'storyboard-mlx-v1'\n OR (p_payload->>'sceneCount')::integer NOT BETWEEN 5 AND 12 THEN RAISE EXCEPTION 'invalid_request'; END IF;$old$;\n replacement := $new$IF p_action = 'create' THEN\n IF p_payload->>'workflow' IS DISTINCT FROM 'storyboard-gemini-v1'\n OR (p_payload->>'sceneCount')::integer NOT BETWEEN 5 AND 12 THEN RAISE EXCEPTION 'invalid_request'; END IF;$new$;\n occurrences := (length(definition)-length(replace(definition,old,'')))/length(old);\n IF occurrences <> 1 THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_ADMIN_SOURCE_DRIFT'; END IF;\n definition := replace(definition, old, replacement);\n\n old := $old$ RETURN jsonb_build_object('ok', true, 'versions', version_rows, 'preview', preview);\n END IF;\n IF p_action IN ('retry','regenerate') THEN$old$;\n replacement := $new$ RETURN jsonb_build_object('ok', true, 'versions', version_rows, 'preview', preview);\n END IF;\n IF p.request->>'workflow' IS DISTINCT FROM 'storyboard-gemini-v1' THEN\n RAISE EXCEPTION 'archived_project_read_only';\n END IF;\n IF p_action NOT IN ('edit','regenerate','retry','cancel','restore') THEN\n RAISE EXCEPTION 'invalid_request';\n END IF;\n IF p_action IN ('retry','regenerate') THEN$new$;\n occurrences := (length(definition)-length(replace(definition,old,'')))/length(old);\n IF occurrences <> 1 THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_ADMIN_SOURCE_DRIFT'; END IF;\n definition := replace(definition, old, replacement);\n\n EXECUTE definition;\n SELECT jsonb_build_array(p.proowner,p.proacl,p.prosecdef,p.proconfig,p.provolatile,p.proparallel)\n INTO metadata_after FROM pg_catalog.pg_proc p WHERE p.oid = target;\n IF metadata_after IS DISTINCT FROM metadata_before THEN\n RAISE EXCEPTION 'STORYBOARD_GEMINI_ADMIN_METADATA_DRIFT';\n END IF;\nEND;\n$storyboard_gemini_admin$", + "DO $storyboard_gemini_worker$\nDECLARE target oid; definition text; body text; old text; replacement text;\n metadata_before jsonb; metadata_after jsonb; occurrences integer;\nBEGIN\n target := pg_catalog.to_regprocedure('public.storyboard_production_worker(uuid,text,uuid,uuid,jsonb)');\n SELECT pg_catalog.pg_get_functiondef(p.oid), p.prosrc,\n jsonb_build_array(p.proowner,p.proacl,p.prosecdef,p.proconfig,p.provolatile,p.proparallel)\n INTO definition, body, metadata_before FROM pg_catalog.pg_proc p\n WHERE p.oid = target AND p.proowner = 'postgres'::pg_catalog.regrole AND NOT p.prosecdef\n AND p.proconfig = ARRAY['search_path=\"\"']::text[];\n IF definition IS NULL THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_WORKER_SOURCE_DRIFT'; END IF;\n\n old := $old$ UPDATE public.admin_storyboard_production_jobs SET last_heartbeat = clock_timestamp(),\n lease_expires_at = clock_timestamp() + interval '120 seconds', updated_at = clock_timestamp()\n WHERE id = p_job_id AND worker_id = w.id AND owner_id = w.owner_id AND status = 'claimed'\n AND lease_token = p_lease_token AND lease_expires_at > clock_timestamp();$old$;\n replacement := $new$ UPDATE public.admin_storyboard_production_jobs active_job SET last_heartbeat = clock_timestamp(),\n lease_expires_at = clock_timestamp() + interval '120 seconds', updated_at = clock_timestamp()\n FROM public.admin_storyboard_production_projects q\n WHERE active_job.id = p_job_id AND active_job.worker_id = w.id\n AND active_job.owner_id = w.owner_id AND active_job.status = 'claimed'\n AND q.id = active_job.project_id AND q.owner_id = active_job.owner_id\n AND q.request->>'workflow' = 'storyboard-gemini-v1'\n AND active_job.lease_token = p_lease_token AND active_job.lease_expires_at > clock_timestamp();$new$;\n occurrences := (length(definition)-length(replace(definition,old,'')))/length(old);\n IF occurrences <> 1 THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_WORKER_SOURCE_DRIFT'; END IF;\n definition := replace(definition, old, replacement);\n\n old := $old$WHERE q.owner_id = w.owner_id AND EXISTS (SELECT 1 FROM public.admin_storyboard_production_jobs x\n WHERE x.project_id = q.id AND x.status = 'claimed' AND x.lease_expires_at <= clock_timestamp())\n ORDER BY q.created_at LIMIT 32 FOR UPDATE OF q SKIP LOCKED$old$;\n replacement := $new$WHERE q.owner_id = w.owner_id AND q.request->>'workflow' = 'storyboard-gemini-v1'\n AND EXISTS (SELECT 1 FROM public.admin_storyboard_production_jobs x\n WHERE x.project_id = q.id AND x.status = 'claimed' AND x.lease_expires_at <= clock_timestamp())\n ORDER BY q.created_at LIMIT 32 FOR UPDATE OF q SKIP LOCKED$new$;\n occurrences := (length(definition)-length(replace(definition,old,'')))/length(old);\n IF occurrences <> 1 THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_WORKER_SOURCE_DRIFT'; END IF;\n definition := replace(definition, old, replacement);\n\n old := $old$WHERE q.owner_id = w.owner_id AND EXISTS (SELECT 1 FROM public.admin_storyboard_production_jobs x\n WHERE x.project_id = q.id AND x.status = 'queued' AND x.available_at <= clock_timestamp() AND x.attempts < 3)\n AND q.request#>>'{providers,text,id}' = 'gemini-api'$old$;\n replacement := $new$WHERE q.owner_id = w.owner_id AND EXISTS (SELECT 1 FROM public.admin_storyboard_production_jobs x\n WHERE x.project_id = q.id AND x.status = 'queued' AND x.available_at <= clock_timestamp() AND x.attempts < 3)\n AND q.request->>'workflow' = 'storyboard-gemini-v1'\n AND q.request#>>'{providers,text,id}' = 'gemini-api'$new$;\n occurrences := (length(definition)-length(replace(definition,old,'')))/length(old);\n IF occurrences <> 1 THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_WORKER_SOURCE_DRIFT'; END IF;\n definition := replace(definition, old, replacement);\n\n old := $old$AND (q.document IS NOT NULL OR public.storyboard_production_model_available(w.models,q.request#>>'{providers,text,model}','chat'))\n AND public.storyboard_production_model_available(w.models,q.request#>>'{providers,image,model}','image')$old$;\n replacement := $new$AND public.storyboard_production_model_available(w.models,q.request#>>'{providers,text,model}','chat')\n AND public.storyboard_production_model_available(w.models,q.request#>>'{providers,image,model}','image')$new$;\n occurrences := (length(definition)-length(replace(definition,old,'')))/length(old);\n IF occurrences <> 1 THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_WORKER_SOURCE_DRIFT'; END IF;\n definition := replace(definition, old, replacement);\n\n old := $old$ IF p.revision <> j.base_revision THEN RAISE EXCEPTION 'revision_conflict'; END IF;\n scene := (p_payload->>'sceneNo')::integer;$old$;\n replacement := $new$ IF p.revision <> j.base_revision THEN RAISE EXCEPTION 'revision_conflict'; END IF;\n IF p.request->>'workflow' IS DISTINCT FROM 'storyboard-gemini-v1' THEN\n RAISE EXCEPTION 'worker_lease_lost';\n END IF;\n scene := (p_payload->>'sceneNo')::integer;$new$;\n occurrences := (length(definition)-length(replace(definition,old,'')))/length(old);\n IF occurrences <> 1 THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_WORKER_SOURCE_DRIFT'; END IF;\n definition := replace(definition, old, replacement);\n\n old := $old$jsonb_build_object('schema','storyboard-mlx-v1'$old$;\n replacement := $new$jsonb_build_object('schema','storyboard-gemini-v1'$new$;\n occurrences := (length(definition)-length(replace(definition,old,'')))/length(old);\n IF occurrences <> 1 THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_WORKER_SOURCE_DRIFT'; END IF;\n definition := replace(definition, old, replacement);\n\n old := $old$status = CASE WHEN request#>>'{providers,image,id}' IN ('manual','chatgpt-manual','grok-manual') THEN 'awaiting_import' ELSE 'generating' END,$old$;\n replacement := $new$status = 'generating',$new$;\n occurrences := (length(definition)-length(replace(definition,old,'')))/length(old);\n IF occurrences <> 1 THEN RAISE EXCEPTION 'STORYBOARD_GEMINI_WORKER_SOURCE_DRIFT'; END IF;\n definition := replace(definition, old, replacement);\n\n EXECUTE definition;\n SELECT jsonb_build_array(p.proowner,p.proacl,p.prosecdef,p.proconfig,p.provolatile,p.proparallel)\n INTO metadata_after FROM pg_catalog.pg_proc p WHERE p.oid = target;\n IF metadata_after IS DISTINCT FROM metadata_before THEN\n RAISE EXCEPTION 'STORYBOARD_GEMINI_WORKER_METADATA_DRIFT';\n END IF;\nEND;\n$storyboard_gemini_worker$", + "COMMIT" + ], + "path": "backend/supabase/migrations/20261013093518_storyboard_gemini_execution_v1.sql", + "sha256": "84d351dead86ef90040b6949fe4a6dc93eb91a783c1b7a78531c7f564ea4ff4e", + "statementCount": 18, + "statementVectorSha256": "7e1e55befbc946a56528697a16c933223216ea095ad4ca24d6f0c4f379002b55", + "version": "20261013093518" + }, + { + "bytes": 56061, + "id": "durable_receipt_ocr_results", + "name": "durable_receipt_ocr_results", + "originalStatementVector": [ + "-- Durable, server-owned OCR result claims. The table stores normalized receipt\n-- fields only; images, raw provider output and provider diagnostics stay out.\nCREATE SCHEMA IF NOT EXISTS ocr_private", + "REVOKE ALL ON SCHEMA ocr_private FROM PUBLIC, anon, authenticated", + "-- These four review columns already exist in the operating catalog, but their\n-- original creation was never captured by a repository migration. Reconstruct\n-- that exact nullable/default contract before any OCR trigger or function\n-- references it. IF NOT EXISTS keeps the operating transition additive; the\n-- immediate catalog assertion rejects a same-name object with another shape.\nALTER TABLE public.reviews\n ADD COLUMN IF NOT EXISTS receipt_hash text,\n ADD COLUMN IF NOT EXISTS receipt_data jsonb,\n ADD COLUMN IF NOT EXISTS is_duplicate boolean DEFAULT false,\n ADD COLUMN IF NOT EXISTS ocr_processed_at timestamptz", + "DO $review_ocr_column_contract$\nDECLARE\n v_ready boolean;\nBEGIN\n SELECT pg_catalog.count(*) = 4\n AND pg_catalog.bool_and(\n NOT attribute.attnotnull\n AND attribute.atttypmod = -1\n AND attribute.attidentity = ''\n AND attribute.attgenerated = ''\n AND CASE attribute.attname\n WHEN 'receipt_hash' THEN\n attribute.atttypid = 'text'::pg_catalog.regtype\n AND default_value.oid IS NULL\n WHEN 'receipt_data' THEN\n attribute.atttypid = 'jsonb'::pg_catalog.regtype\n AND default_value.oid IS NULL\n WHEN 'is_duplicate' THEN\n attribute.atttypid = 'boolean'::pg_catalog.regtype\n AND pg_catalog.pg_get_expr(\n default_value.adbin,\n default_value.adrelid\n ) = 'false'\n WHEN 'ocr_processed_at' THEN\n attribute.atttypid = 'timestamp with time zone'::pg_catalog.regtype\n AND default_value.oid IS NULL\n ELSE false\n END\n )\n INTO v_ready\n FROM pg_catalog.pg_attribute AS attribute\n LEFT JOIN pg_catalog.pg_attrdef AS default_value\n ON default_value.adrelid = attribute.attrelid\n AND default_value.adnum = attribute.attnum\n WHERE attribute.attrelid = 'public.reviews'::regclass\n AND attribute.attnum > 0\n AND NOT attribute.attisdropped\n AND attribute.attname = ANY (ARRAY[\n 'receipt_hash',\n 'receipt_data',\n 'is_duplicate',\n 'ocr_processed_at'\n ]::name[]);\n\n IF NOT coalesce(v_ready, false) THEN\n RAISE EXCEPTION 'REVIEW_OCR_COLUMN_CONTRACT_DRIFT';\n END IF;\nEND;\n$review_ocr_column_contract$", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_receipt_hash\n ON public.reviews USING btree (receipt_hash)\n WHERE receipt_hash IS NOT NULL", + "CREATE INDEX IF NOT EXISTS reviews_ocr_pending_idx\n ON public.reviews USING btree (ocr_processed_at)\n WHERE verification_photo IS NOT NULL", + "CREATE INDEX IF NOT EXISTS reviews_duplicate_idx\n ON public.reviews USING btree (is_duplicate)", + "DO $review_ocr_index_contract$\nDECLARE\n v_ready boolean;\nBEGIN\n SELECT pg_catalog.count(*) = 3\n AND pg_catalog.bool_and(\n index_relation.relkind = 'i'\n AND index_row.indrelid = 'public.reviews'::regclass\n AND access_method.amname = 'btree'\n AND index_row.indnkeyatts = 1\n AND index_row.indnatts = 1\n AND index_row.indexprs IS NULL\n AND index_row.indisvalid\n AND index_row.indisready\n AND index_row.indislive\n AND NOT index_row.indisprimary\n AND NOT index_row.indisexclusion\n AND CASE index_relation.relname\n WHEN 'idx_reviews_receipt_hash' THEN\n index_row.indisunique\n AND pg_catalog.pg_get_indexdef(index_relation.oid, 1, true) = 'receipt_hash'\n AND pg_catalog.pg_get_expr(\n index_row.indpred,\n index_row.indrelid\n ) = '(receipt_hash IS NOT NULL)'\n WHEN 'reviews_ocr_pending_idx' THEN\n NOT index_row.indisunique\n AND pg_catalog.pg_get_indexdef(index_relation.oid, 1, true) = 'ocr_processed_at'\n AND pg_catalog.pg_get_expr(\n index_row.indpred,\n index_row.indrelid\n ) = '(verification_photo IS NOT NULL)'\n WHEN 'reviews_duplicate_idx' THEN\n NOT index_row.indisunique\n AND pg_catalog.pg_get_indexdef(index_relation.oid, 1, true) = 'is_duplicate'\n AND index_row.indpred IS NULL\n ELSE false\n END\n )\n INTO v_ready\n FROM pg_catalog.pg_class AS index_relation\n JOIN pg_catalog.pg_namespace AS namespace\n ON namespace.oid = index_relation.relnamespace\n JOIN pg_catalog.pg_index AS index_row\n ON index_row.indexrelid = index_relation.oid\n JOIN pg_catalog.pg_am AS access_method\n ON access_method.oid = index_relation.relam\n WHERE namespace.nspname = 'public'\n AND index_relation.relname = ANY (ARRAY[\n 'idx_reviews_receipt_hash',\n 'reviews_ocr_pending_idx',\n 'reviews_duplicate_idx'\n ]::name[]);\n\n IF NOT coalesce(v_ready, false) THEN\n RAISE EXCEPTION 'REVIEW_OCR_INDEX_CONTRACT_DRIFT';\n END IF;\nEND;\n$review_ocr_index_contract$", + "CREATE TABLE ocr_private.receipt_results (\n id uuid PRIMARY KEY DEFAULT extensions.gen_random_uuid(),\n user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,\n image_hash text NOT NULL CHECK (image_hash ~ '^[0-9a-f]{64}$'),\n input_context_hash text NOT NULL CHECK (input_context_hash ~ '^[0-9a-f]{64}$'),\n provider text NOT NULL CHECK (provider = 'gemini'),\n model_policy jsonb NOT NULL CHECK (\n jsonb_typeof(model_policy) = 'array'\n AND jsonb_array_length(model_policy) BETWEEN 1 AND 8\n ),\n generation uuid NOT NULL,\n prompt_version text NOT NULL CHECK (length(prompt_version) BETWEEN 1 AND 160),\n preprocess_version text NOT NULL CHECK (length(preprocess_version) BETWEEN 1 AND 160),\n normalization_version text NOT NULL CHECK (length(normalization_version) BETWEEN 1 AND 160),\n status text NOT NULL CHECK (status IN ('processing', 'succeeded', 'indeterminate')),\n dispatch_stage text NOT NULL CHECK (dispatch_stage IN ('reserved', 'possibly_dispatched', 'completed', 'uncertain')),\n claim_token uuid,\n result_payload jsonb,\n result_revision uuid,\n claimed_at timestamptz NOT NULL DEFAULT now(),\n completed_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT receipt_results_state_check CHECK (\n -- A forced refresh may retain the last verified payload while its replacement\n -- is in flight. It is never returned until that claim resolves.\n (status = 'processing' AND dispatch_stage IN ('reserved', 'possibly_dispatched') AND claim_token IS NOT NULL AND completed_at IS NULL\n AND ((result_payload IS NULL AND result_revision IS NULL) OR (result_payload IS NOT NULL AND result_revision IS NOT NULL)))\n OR (status = 'succeeded' AND dispatch_stage IN ('completed', 'uncertain') AND claim_token IS NULL\n AND result_payload IS NOT NULL AND result_revision IS NOT NULL AND completed_at IS NOT NULL)\n OR (status = 'indeterminate' AND dispatch_stage = 'uncertain' AND claim_token IS NULL\n AND result_payload IS NULL AND result_revision IS NULL AND completed_at IS NOT NULL)\n ),\n CONSTRAINT receipt_results_fingerprint_key UNIQUE (\n user_id,\n image_hash,\n input_context_hash,\n provider,\n model_policy,\n generation,\n prompt_version,\n preprocess_version,\n normalization_version\n )\n)", + "COMMENT ON TABLE ocr_private.receipt_results IS\n 'Owner-bound normalized receipt OCR results. Rows have no automatic TTL: account deletion cascades, version-bound fingerprints prevent stale reuse, undispatched reservations require explicit recovery, and possibly dispatched calls remain blocked from replay.'", + "ALTER TABLE ocr_private.receipt_results ENABLE ROW LEVEL SECURITY", + "ALTER TABLE ocr_private.receipt_results FORCE ROW LEVEL SECURITY", + "REVOKE ALL ON TABLE ocr_private.receipt_results FROM PUBLIC, anon, authenticated", + "-- Every review gets an opaque OCR generation. Explicit admin reset UPDATEs\n-- rotate it even when the visible OCR columns were already null, so a stale\n-- workflow cannot reuse the prior durable cache or publish after a later reset.\nCREATE TABLE ocr_private.review_generations (\n review_id uuid PRIMARY KEY REFERENCES public.reviews(id) ON DELETE CASCADE,\n user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,\n generation uuid NOT NULL,\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (review_id, user_id, generation)\n)", + "ALTER TABLE ocr_private.review_generations ENABLE ROW LEVEL SECURITY", + "ALTER TABLE ocr_private.review_generations FORCE ROW LEVEL SECURITY", + "REVOKE ALL ON TABLE ocr_private.review_generations FROM PUBLIC, anon, authenticated", + "CREATE TABLE ocr_private.feature_runtime_capability (\n capability_version text PRIMARY KEY,\n schema_version integer NOT NULL CHECK (schema_version = 1),\n activated_at timestamptz NOT NULL DEFAULT now()\n)", + "ALTER TABLE ocr_private.feature_runtime_capability ENABLE ROW LEVEL SECURITY", + "ALTER TABLE ocr_private.feature_runtime_capability FORCE ROW LEVEL SECURITY", + "REVOKE ALL ON TABLE ocr_private.feature_runtime_capability FROM PUBLIC, anon, authenticated", + "INSERT INTO ocr_private.review_generations (review_id, user_id, generation)\nSELECT review.id, review.user_id, extensions.gen_random_uuid()\nFROM public.reviews AS review", + "CREATE FUNCTION ocr_private.register_review_ocr_generation()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nBEGIN\n INSERT INTO ocr_private.review_generations (review_id, user_id, generation)\n VALUES (NEW.id, NEW.user_id, extensions.gen_random_uuid())\n ON CONFLICT (review_id) DO UPDATE SET\n user_id = EXCLUDED.user_id,\n generation = extensions.gen_random_uuid(),\n updated_at = now();\n RETURN NEW;\nEND;\n$$", + "CREATE TRIGGER review_ocr_generation_on_insert\nAFTER INSERT ON public.reviews\nFOR EACH ROW EXECUTE FUNCTION ocr_private.register_review_ocr_generation()", + "CREATE TRIGGER review_ocr_generation_on_reset\nAFTER UPDATE OF receipt_hash, receipt_data, is_duplicate, ocr_processed_at ON public.reviews\nFOR EACH ROW\nWHEN (\n NEW.verification_photo IS NOT NULL\n AND NEW.receipt_hash IS NULL\n AND NEW.receipt_data IS NULL\n AND NEW.ocr_processed_at IS NULL\n AND NEW.is_duplicate = false\n)\nEXECUTE FUNCTION ocr_private.register_review_ocr_generation()", + "CREATE FUNCTION ocr_private.valid_model_policy(p_policy jsonb)\nRETURNS boolean\nLANGUAGE sql\nIMMUTABLE\nSET search_path = ''\nAS $$\n SELECT p_policy IS NOT NULL\n AND pg_catalog.jsonb_typeof(p_policy) = 'array'\n AND pg_catalog.jsonb_array_length(p_policy) BETWEEN 1 AND 8\n AND NOT EXISTS (\n SELECT 1\n FROM pg_catalog.jsonb_array_elements(p_policy) AS entry(value)\n WHERE pg_catalog.jsonb_typeof(entry.value) <> 'string'\n OR pg_catalog.length(pg_catalog.btrim(entry.value #>> '{}')) NOT BETWEEN 1 AND 160\n OR pg_catalog.btrim(entry.value #>> '{}') <> entry.value #>> '{}'\n OR (entry.value #>> '{}') !~ '^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$'\n )\n AND (\n SELECT pg_catalog.count(*)\n FROM pg_catalog.jsonb_array_elements_text(p_policy) AS model(value)\n ) = (\n SELECT pg_catalog.count(DISTINCT model.value)\n FROM pg_catalog.jsonb_array_elements_text(p_policy) AS model(value)\n );\n$$", + "CREATE OR REPLACE FUNCTION ocr_private.valid_result_payload(p_payload jsonb)\nRETURNS boolean\nLANGUAGE plpgsql\nIMMUTABLE\nSET search_path = ''\nAS $$\nDECLARE\n v_normalized jsonb;\n v_entry jsonb;\n v_item jsonb;\n v_review_field text;\nBEGIN\n IF p_payload IS NULL\n OR pg_catalog.jsonb_typeof(p_payload) <> 'object'\n OR pg_catalog.octet_length(pg_catalog.convert_to(p_payload::text, 'UTF8')) > 32768\n OR NOT (p_payload ?& ARRAY[\n 'schema_version', 'provider', 'model', 'normalized', 'field_trust',\n 'confidence', 'needs_review', 'normalization_version'\n ])\n OR EXISTS (\n SELECT 1\n FROM pg_catalog.jsonb_object_keys(p_payload) AS key(value)\n WHERE value <> ALL (ARRAY[\n 'schema_version', 'provider', 'model', 'normalized', 'failure_code',\n 'field_trust', 'confidence', 'needs_review', 'normalization_version'\n ])\n )\n OR pg_catalog.jsonb_typeof(p_payload->'schema_version') <> 'string'\n OR pg_catalog.jsonb_typeof(p_payload->'provider') <> 'string'\n OR pg_catalog.jsonb_typeof(p_payload->'model') <> 'string'\n OR pg_catalog.jsonb_typeof(p_payload->'normalization_version') <> 'string'\n OR p_payload->>'schema_version' <> 'receipt-public-ocr-v1'\n OR p_payload->>'provider' <> 'gemini'\n OR pg_catalog.length(pg_catalog.btrim(p_payload->>'model')) NOT BETWEEN 1 AND 160\n OR pg_catalog.btrim(p_payload->>'model') <> p_payload->>'model'\n OR pg_catalog.length(p_payload->>'normalization_version') NOT BETWEEN 1 AND 160\n OR pg_catalog.btrim(p_payload->>'normalization_version') <> p_payload->>'normalization_version'\n OR (p_payload ? 'failure_code' AND (\n pg_catalog.jsonb_typeof(p_payload->'failure_code') <> 'string'\n OR p_payload->>'failure_code' NOT IN ('not_receipt', 'unreadable', 'low_quality', 'ocr_failed')\n ))\n THEN\n RETURN false;\n END IF;\n\n IF pg_catalog.jsonb_typeof(p_payload->'confidence') <> 'number'\n OR (p_payload->>'confidence')::numeric NOT BETWEEN 0 AND 1\n THEN\n RETURN false;\n END IF;\n\n v_normalized := p_payload->'normalized';\n IF pg_catalog.jsonb_typeof(v_normalized) <> 'object'\n OR EXISTS (\n SELECT 1\n FROM pg_catalog.jsonb_object_keys(v_normalized) AS key(value)\n WHERE value <> ALL (ARRAY[\n 'store_name', 'date', 'time', 'total_amount', 'category',\n 'review_draft', 'items', 'confidence'\n ])\n )\n THEN\n RETURN false;\n END IF;\n\n IF v_normalized ? 'store_name'\n AND (pg_catalog.jsonb_typeof(v_normalized->'store_name') <> 'string'\n OR pg_catalog.length(v_normalized->>'store_name') NOT BETWEEN 1 AND 160\n OR pg_catalog.btrim(v_normalized->>'store_name') <> v_normalized->>'store_name')\n THEN RETURN false; END IF;\n\n IF v_normalized ? 'date'\n AND (pg_catalog.jsonb_typeof(v_normalized->'date') <> 'string'\n OR (v_normalized->>'date') !~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$')\n THEN RETURN false; END IF;\n\n IF v_normalized ? 'time'\n AND (pg_catalog.jsonb_typeof(v_normalized->'time') <> 'string'\n OR (v_normalized->>'time') !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$')\n THEN RETURN false; END IF;\n\n IF v_normalized ? 'total_amount' THEN\n IF pg_catalog.jsonb_typeof(v_normalized->'total_amount') <> 'number'\n OR (v_normalized->>'total_amount')::numeric NOT BETWEEN 0 AND 1000000000\n THEN RETURN false; END IF;\n END IF;\n\n IF v_normalized ? 'category'\n AND (pg_catalog.jsonb_typeof(v_normalized->'category') <> 'string'\n OR pg_catalog.length(v_normalized->>'category') NOT BETWEEN 1 AND 32\n OR pg_catalog.btrim(v_normalized->>'category') <> v_normalized->>'category')\n THEN RETURN false; END IF;\n\n IF v_normalized ? 'review_draft'\n AND (pg_catalog.jsonb_typeof(v_normalized->'review_draft') <> 'string'\n OR pg_catalog.length(v_normalized->>'review_draft') NOT BETWEEN 1 AND 4000\n OR pg_catalog.btrim(v_normalized->>'review_draft') <> v_normalized->>'review_draft')\n THEN RETURN false; END IF;\n\n IF v_normalized ? 'confidence' THEN\n IF pg_catalog.jsonb_typeof(v_normalized->'confidence') <> 'number'\n OR (v_normalized->>'confidence')::numeric NOT BETWEEN 0 AND 1\n THEN RETURN false; END IF;\n END IF;\n\n IF v_normalized ? 'items' THEN\n IF pg_catalog.jsonb_typeof(v_normalized->'items') <> 'array'\n OR pg_catalog.jsonb_array_length(v_normalized->'items') > 50\n THEN RETURN false; END IF;\n FOR v_item IN SELECT value FROM pg_catalog.jsonb_array_elements(v_normalized->'items') LOOP\n IF pg_catalog.jsonb_typeof(v_item) <> 'object'\n OR NOT (v_item ?& ARRAY['name', 'price'])\n OR EXISTS (\n SELECT 1\n FROM pg_catalog.jsonb_object_keys(v_item) AS key(value)\n WHERE value <> ALL (ARRAY['name', 'price'])\n )\n OR pg_catalog.jsonb_typeof(v_item->'name') <> 'string'\n OR pg_catalog.length(v_item->>'name') NOT BETWEEN 1 AND 160\n OR pg_catalog.btrim(v_item->>'name') <> v_item->>'name'\n THEN RETURN false; END IF;\n IF pg_catalog.jsonb_typeof(v_item->'price') NOT IN ('number', 'null') THEN RETURN false; END IF;\n IF pg_catalog.jsonb_typeof(v_item->'price') = 'number'\n AND (v_item->>'price')::numeric NOT BETWEEN 0 AND 1000000000\n THEN RETURN false; END IF;\n END LOOP;\n END IF;\n\n IF pg_catalog.jsonb_typeof(p_payload->'field_trust') <> 'array'\n OR pg_catalog.jsonb_array_length(p_payload->'field_trust') > 16\n THEN RETURN false; END IF;\n FOR v_entry IN SELECT value FROM pg_catalog.jsonb_array_elements(p_payload->'field_trust') LOOP\n IF pg_catalog.jsonb_typeof(v_entry) <> 'object'\n OR NOT (v_entry ?& ARRAY['field', 'level', 'source', 'reason', 'needsReview'])\n OR EXISTS (\n SELECT 1\n FROM pg_catalog.jsonb_object_keys(v_entry) AS key(value)\n WHERE value <> ALL (ARRAY['field', 'level', 'source', 'reason', 'needsReview'])\n )\n OR v_entry->>'field' NOT IN ('store_name', 'restaurant_id', 'date', 'time', 'total_amount', 'items', 'category', 'review_draft')\n OR v_entry->>'level' NOT IN ('high', 'medium', 'low')\n OR v_entry->>'source' NOT IN ('model_raw', 'model_candidate', 'selected_restaurant', 'db_exact', 'db_fuzzy', 'rule_validation', 'user_required')\n OR pg_catalog.jsonb_typeof(v_entry->'reason') <> 'string'\n OR pg_catalog.length(v_entry->>'reason') NOT BETWEEN 1 AND 240\n OR pg_catalog.jsonb_typeof(v_entry->'needsReview') <> 'boolean'\n THEN RETURN false; END IF;\n END LOOP;\n\n IF pg_catalog.jsonb_typeof(p_payload->'needs_review') <> 'array'\n OR pg_catalog.jsonb_array_length(p_payload->'needs_review') > 8\n THEN RETURN false; END IF;\n FOR v_review_field IN SELECT value #>> '{}' FROM pg_catalog.jsonb_array_elements(p_payload->'needs_review') LOOP\n IF v_review_field NOT IN ('store_name', 'restaurant_id', 'date', 'time', 'total_amount', 'items', 'category', 'review_draft')\n THEN RETURN false; END IF;\n END LOOP;\n IF (SELECT pg_catalog.count(*) FROM pg_catalog.jsonb_array_elements_text(p_payload->'needs_review'))\n <> (SELECT pg_catalog.count(DISTINCT value) FROM pg_catalog.jsonb_array_elements_text(p_payload->'needs_review') AS field(value))\n THEN RETURN false; END IF;\n\n RETURN true;\nEXCEPTION WHEN OTHERS THEN\n RETURN false;\nEND;\n$$", + "REVOKE ALL ON FUNCTION ocr_private.valid_result_payload(jsonb) FROM PUBLIC, anon, authenticated", + "CREATE OR REPLACE FUNCTION public.claim_receipt_ocr_result(\n p_user_id uuid,\n p_image_hash text,\n p_input_context_hash text,\n p_provider text,\n p_model_policy jsonb,\n p_generation uuid,\n p_prompt_version text,\n p_preprocess_version text,\n p_normalization_version text,\n p_operation_id uuid,\n p_force_refresh boolean DEFAULT false\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_result ocr_private.receipt_results%ROWTYPE;\nBEGIN\n IF p_user_id IS NULL\n OR p_operation_id IS NULL\n OR p_image_hash IS NULL\n OR p_input_context_hash IS NULL\n OR p_provider IS NULL\n OR NOT ocr_private.valid_model_policy(p_model_policy)\n OR p_generation IS NULL\n OR p_prompt_version IS NULL\n OR p_preprocess_version IS NULL\n OR p_normalization_version IS NULL\n OR p_image_hash !~ '^[0-9a-f]{64}$'\n OR p_input_context_hash !~ '^[0-9a-f]{64}$'\n OR p_provider <> 'gemini'\n OR length(p_prompt_version) NOT BETWEEN 1 AND 160\n OR length(p_preprocess_version) NOT BETWEEN 1 AND 160\n OR length(p_normalization_version) NOT BETWEEN 1 AND 160\n THEN\n RAISE EXCEPTION 'invalid_receipt_ocr_claim' USING ERRCODE = '22023';\n END IF;\n\n INSERT INTO ocr_private.receipt_results (\n user_id, image_hash, input_context_hash, provider, model_policy, generation,\n prompt_version, preprocess_version, normalization_version,\n status, dispatch_stage, claim_token\n ) VALUES (\n p_user_id, p_image_hash, p_input_context_hash, p_provider, p_model_policy, p_generation,\n p_prompt_version, p_preprocess_version, p_normalization_version,\n 'processing', 'reserved', p_operation_id\n )\n ON CONFLICT (\n user_id, image_hash, input_context_hash, provider, model_policy, generation,\n prompt_version, preprocess_version, normalization_version\n ) DO NOTHING\n RETURNING * INTO v_result;\n\n IF FOUND THEN\n RETURN jsonb_build_object(\n 'status', 'claimed',\n 'result_ref', v_result.id,\n 'claim_token', v_result.claim_token\n );\n END IF;\n\n SELECT * INTO v_result\n FROM ocr_private.receipt_results\n WHERE user_id = p_user_id\n AND image_hash = p_image_hash\n AND input_context_hash = p_input_context_hash\n AND provider = p_provider\n AND model_policy = p_model_policy\n AND generation = p_generation\n AND prompt_version = p_prompt_version\n AND preprocess_version = p_preprocess_version\n AND normalization_version = p_normalization_version\n FOR UPDATE;\n\n IF v_result.status = 'succeeded' AND (NOT p_force_refresh OR v_result.dispatch_stage = 'uncertain') THEN\n RETURN jsonb_build_object(\n 'status', 'cached',\n 'result_ref', v_result.id,\n 'result_revision', v_result.result_revision,\n 'result_payload', v_result.result_payload,\n 'retry_blocked', v_result.dispatch_stage = 'uncertain'\n );\n END IF;\n\n IF v_result.status = 'processing' THEN\n IF p_force_refresh AND v_result.dispatch_stage = 'reserved' THEN\n UPDATE ocr_private.receipt_results\n SET claim_token = p_operation_id,\n claimed_at = now(),\n updated_at = now()\n WHERE id = v_result.id\n AND status = 'processing'\n AND dispatch_stage = 'reserved'\n AND claim_token = v_result.claim_token\n RETURNING * INTO v_result;\n IF FOUND THEN\n RETURN jsonb_build_object(\n 'status', 'claimed',\n 'result_ref', v_result.id,\n 'claim_token', v_result.claim_token,\n 'recovered_reservation', true\n );\n END IF;\n ELSIF p_force_refresh AND v_result.dispatch_stage = 'possibly_dispatched' THEN\n UPDATE ocr_private.receipt_results\n SET status = CASE WHEN result_payload IS NULL THEN 'indeterminate' ELSE 'succeeded' END,\n dispatch_stage = 'uncertain',\n claim_token = NULL,\n completed_at = now(),\n updated_at = now()\n WHERE id = v_result.id\n AND status = 'processing'\n AND dispatch_stage = 'possibly_dispatched'\n AND claim_token = v_result.claim_token\n RETURNING * INTO v_result;\n IF FOUND AND v_result.result_payload IS NOT NULL THEN\n RETURN jsonb_build_object(\n 'status', 'cached',\n 'result_ref', v_result.id,\n 'result_revision', v_result.result_revision,\n 'result_payload', v_result.result_payload,\n 'retry_blocked', true\n );\n ELSIF FOUND THEN\n RETURN jsonb_build_object('status', 'indeterminate', 'result_ref', v_result.id);\n END IF;\n END IF;\n RETURN jsonb_build_object('status', 'processing', 'result_ref', v_result.id);\n END IF;\n\n -- An uncertain provider dispatch is never replayed by force refresh. Any\n -- future retry needs a separate reviewed server operation, not this route.\n IF v_result.status = 'indeterminate' OR NOT p_force_refresh THEN\n RETURN jsonb_build_object('status', 'indeterminate', 'result_ref', v_result.id);\n END IF;\n\n UPDATE ocr_private.receipt_results\n SET status = 'processing',\n dispatch_stage = 'reserved',\n claim_token = p_operation_id,\n claimed_at = now(),\n completed_at = NULL,\n updated_at = now()\n WHERE id = v_result.id\n RETURNING * INTO v_result;\n\n RETURN jsonb_build_object(\n 'status', 'claimed',\n 'result_ref', v_result.id,\n 'claim_token', v_result.claim_token\n );\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.mark_receipt_ocr_dispatched(\n p_result_id uuid,\n p_claim_token uuid\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_result ocr_private.receipt_results%ROWTYPE;\nBEGIN\n UPDATE ocr_private.receipt_results\n SET dispatch_stage = 'possibly_dispatched',\n updated_at = now()\n WHERE id = p_result_id\n AND status = 'processing'\n AND dispatch_stage = 'reserved'\n AND claim_token = p_claim_token\n RETURNING * INTO v_result;\n\n IF FOUND THEN\n RETURN jsonb_build_object('status', 'dispatched', 'result_ref', v_result.id);\n END IF;\n SELECT * INTO v_result FROM ocr_private.receipt_results WHERE id = p_result_id;\n IF v_result.status = 'processing'\n AND v_result.dispatch_stage = 'possibly_dispatched'\n AND v_result.claim_token = p_claim_token\n THEN\n RETURN jsonb_build_object('status', 'dispatched', 'result_ref', v_result.id);\n END IF;\n RAISE EXCEPTION 'receipt_ocr_claim_mismatch' USING ERRCODE = '42501';\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.complete_receipt_ocr_result(\n p_result_id uuid,\n p_claim_token uuid,\n p_result_payload jsonb\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_result ocr_private.receipt_results%ROWTYPE;\nBEGIN\n IF NOT ocr_private.valid_result_payload(p_result_payload) THEN\n RAISE EXCEPTION 'invalid_receipt_ocr_result' USING ERRCODE = '22023';\n END IF;\n\n UPDATE ocr_private.receipt_results\n SET status = 'succeeded',\n dispatch_stage = 'completed',\n claim_token = NULL,\n result_payload = p_result_payload,\n result_revision = extensions.gen_random_uuid(),\n completed_at = now(),\n updated_at = now()\n WHERE id = p_result_id\n AND status = 'processing'\n AND dispatch_stage = 'possibly_dispatched'\n AND claim_token = p_claim_token\n AND provider = p_result_payload->>'provider'\n AND model_policy ? (p_result_payload->>'model')\n AND normalization_version = p_result_payload->>'normalization_version'\n RETURNING * INTO v_result;\n\n IF FOUND THEN\n RETURN jsonb_build_object(\n 'status', 'succeeded',\n 'result_ref', v_result.id,\n 'result_revision', v_result.result_revision\n );\n END IF;\n\n SELECT * INTO v_result FROM ocr_private.receipt_results WHERE id = p_result_id;\n IF v_result.status = 'succeeded' AND v_result.result_payload = p_result_payload THEN\n RETURN jsonb_build_object(\n 'status', 'succeeded',\n 'result_ref', v_result.id,\n 'result_revision', v_result.result_revision\n );\n END IF;\n RAISE EXCEPTION 'receipt_ocr_claim_mismatch' USING ERRCODE = '42501';\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.mark_receipt_ocr_result_indeterminate(\n p_result_id uuid,\n p_claim_token uuid\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_result ocr_private.receipt_results%ROWTYPE;\nBEGIN\n UPDATE ocr_private.receipt_results\n SET status = CASE WHEN result_payload IS NULL THEN 'indeterminate' ELSE 'succeeded' END,\n dispatch_stage = 'uncertain',\n claim_token = NULL,\n completed_at = now(),\n updated_at = now()\n WHERE id = p_result_id\n AND status = 'processing'\n AND dispatch_stage = 'possibly_dispatched'\n AND claim_token = p_claim_token\n RETURNING * INTO v_result;\n\n IF FOUND THEN\n RETURN jsonb_build_object('status', v_result.status, 'result_ref', v_result.id);\n END IF;\n SELECT * INTO v_result FROM ocr_private.receipt_results WHERE id = p_result_id;\n IF v_result.status = 'succeeded' THEN\n RETURN jsonb_build_object('status', 'succeeded', 'result_ref', v_result.id);\n END IF;\n RAISE EXCEPTION 'receipt_ocr_claim_mismatch' USING ERRCODE = '42501';\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.release_receipt_ocr_claim(\n p_result_id uuid,\n p_claim_token uuid\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_restored boolean := false;\nBEGIN\n UPDATE ocr_private.receipt_results\n SET status = 'succeeded',\n dispatch_stage = 'completed',\n claim_token = NULL,\n completed_at = now(),\n updated_at = now()\n WHERE id = p_result_id\n AND status = 'processing'\n AND dispatch_stage = 'reserved'\n AND claim_token = p_claim_token\n AND result_payload IS NOT NULL;\n v_restored := FOUND;\n\n DELETE FROM ocr_private.receipt_results\n WHERE id = p_result_id\n AND status = 'processing'\n AND dispatch_stage = 'reserved'\n AND claim_token = p_claim_token\n AND result_payload IS NULL;\n IF NOT FOUND AND NOT v_restored THEN\n RAISE EXCEPTION 'receipt_ocr_claim_mismatch' USING ERRCODE = '42501';\n END IF;\n RETURN jsonb_build_object('status', 'released', 'result_ref', p_result_id);\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.read_receipt_ocr_result(\n p_user_id uuid,\n p_image_hash text,\n p_input_context_hash text,\n p_provider text,\n p_model_policy jsonb,\n p_generation uuid,\n p_prompt_version text,\n p_preprocess_version text,\n p_normalization_version text\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSTABLE\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_result ocr_private.receipt_results%ROWTYPE;\nBEGIN\n IF p_user_id IS NULL\n OR p_image_hash !~ '^[0-9a-f]{64}$'\n OR p_input_context_hash !~ '^[0-9a-f]{64}$'\n OR p_provider <> 'gemini'\n OR NOT ocr_private.valid_model_policy(p_model_policy)\n OR p_generation IS NULL\n OR pg_catalog.length(p_prompt_version) NOT BETWEEN 1 AND 160\n OR pg_catalog.length(p_preprocess_version) NOT BETWEEN 1 AND 160\n OR pg_catalog.length(p_normalization_version) NOT BETWEEN 1 AND 160\n THEN\n RAISE EXCEPTION 'invalid_receipt_ocr_read' USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_result\n FROM ocr_private.receipt_results\n WHERE user_id = p_user_id\n AND image_hash = p_image_hash\n AND input_context_hash = p_input_context_hash\n AND provider = p_provider\n AND model_policy = p_model_policy\n AND generation = p_generation\n AND prompt_version = p_prompt_version\n AND preprocess_version = p_preprocess_version\n AND normalization_version = p_normalization_version;\n\n IF NOT FOUND THEN\n RETURN pg_catalog.jsonb_build_object('status', 'not_found');\n END IF;\n IF v_result.status = 'succeeded' THEN\n IF NOT ocr_private.valid_result_payload(v_result.result_payload)\n OR NOT (v_result.model_policy ? (v_result.result_payload->>'model'))\n THEN\n RAISE EXCEPTION 'invalid_receipt_ocr_result' USING ERRCODE = '22023';\n END IF;\n RETURN pg_catalog.jsonb_build_object(\n 'status', 'succeeded',\n 'result_ref', v_result.id,\n 'result_revision', v_result.result_revision,\n 'result_payload', v_result.result_payload\n );\n END IF;\n RETURN pg_catalog.jsonb_build_object('status', v_result.status, 'result_ref', v_result.id);\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.get_review_receipt_ocr_generation(\n p_user_id uuid,\n p_review_id uuid\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSTABLE\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_generation uuid;\nBEGIN\n IF p_user_id IS NULL OR p_review_id IS NULL THEN\n RAISE EXCEPTION 'invalid_review_ocr_generation_read' USING ERRCODE = '22023';\n END IF;\n SELECT generation INTO v_generation\n FROM ocr_private.review_generations\n WHERE review_id = p_review_id AND user_id = p_user_id;\n IF NOT FOUND THEN\n RETURN pg_catalog.jsonb_build_object('status', 'not_found');\n END IF;\n RETURN pg_catalog.jsonb_build_object(\n 'status', 'ready',\n 'review_id', p_review_id,\n 'generation', v_generation\n );\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.publish_review_receipt_ocr_result(\n p_user_id uuid,\n p_review_id uuid,\n p_generation uuid,\n p_result_id uuid,\n p_result_revision uuid,\n p_receipt_hash text,\n p_receipt_data jsonb,\n p_is_duplicate boolean\n)\nRETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_generation uuid;\n v_result ocr_private.receipt_results%ROWTYPE;\n v_review public.reviews%ROWTYPE;\nBEGIN\n IF p_user_id IS NULL OR p_review_id IS NULL OR p_generation IS NULL\n OR p_result_id IS NULL OR p_result_revision IS NULL OR p_is_duplicate IS NULL\n OR p_receipt_data IS NULL OR pg_catalog.jsonb_typeof(p_receipt_data) <> 'object'\n OR pg_catalog.octet_length(pg_catalog.convert_to(p_receipt_data::text, 'UTF8')) > 32768\n OR (p_receipt_hash IS NOT NULL AND p_receipt_hash !~ '^[0-9a-f]{64}$')\n THEN\n RAISE EXCEPTION 'invalid_review_ocr_publish' USING ERRCODE = '22023';\n END IF;\n\n -- Match reset's lock order: both paths lock the review row before the\n -- generation row. This removes the reviews -> generations / generations ->\n -- reviews inversion while preserving a fixed stale-generation rejection.\n SELECT * INTO v_review\n FROM public.reviews\n WHERE id = p_review_id AND user_id = p_user_id\n FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'review_ocr_publish_conflict' USING ERRCODE = '42501';\n END IF;\n\n SELECT generation INTO v_generation\n FROM ocr_private.review_generations\n WHERE review_id = p_review_id AND user_id = p_user_id\n FOR SHARE;\n IF NOT FOUND OR v_generation IS DISTINCT FROM p_generation THEN\n RAISE EXCEPTION 'review_ocr_generation_stale' USING ERRCODE = '42501';\n END IF;\n\n SELECT * INTO v_result\n FROM ocr_private.receipt_results\n WHERE id = p_result_id\n AND user_id = p_user_id\n AND generation = p_generation\n AND status = 'succeeded'\n FOR SHARE;\n IF NOT FOUND\n OR v_result.result_revision IS DISTINCT FROM p_result_revision\n OR NOT ocr_private.valid_result_payload(v_result.result_payload)\n OR NOT (v_result.model_policy ? (v_result.result_payload->>'model'))\n THEN\n RAISE EXCEPTION 'review_ocr_result_stale' USING ERRCODE = '42501';\n END IF;\n\n UPDATE public.reviews\n SET receipt_hash = p_receipt_hash,\n receipt_data = p_receipt_data,\n is_duplicate = p_is_duplicate,\n ocr_processed_at = now()\n WHERE id = p_review_id\n AND user_id = p_user_id\n AND receipt_hash IS NULL\n AND receipt_data IS NULL\n AND ocr_processed_at IS NULL\n AND is_duplicate = false\n RETURNING * INTO v_review;\n\n IF NOT FOUND THEN\n SELECT * INTO v_review\n FROM public.reviews\n WHERE id = p_review_id AND user_id = p_user_id;\n IF NOT FOUND\n OR v_review.receipt_hash IS DISTINCT FROM p_receipt_hash\n OR v_review.receipt_data IS DISTINCT FROM p_receipt_data\n OR v_review.is_duplicate IS DISTINCT FROM p_is_duplicate\n OR v_review.ocr_processed_at IS NULL\n THEN\n RAISE EXCEPTION 'review_ocr_publish_conflict' USING ERRCODE = '42501';\n END IF;\n END IF;\n\n RETURN pg_catalog.jsonb_build_object(\n 'status', 'published',\n 'review_id', v_review.id,\n 'generation', p_generation,\n 'result_ref', p_result_id,\n 'result_revision', p_result_revision,\n 'receipt_hash', v_review.receipt_hash,\n 'receipt_data', v_review.receipt_data,\n 'is_duplicate', v_review.is_duplicate,\n 'ocr_processed_at', v_review.ocr_processed_at\n );\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.get_feature_runtime_capability(p_user_id uuid)\nRETURNS jsonb\nLANGUAGE plpgsql\nSTABLE\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_ready boolean;\n v_contracts_ready boolean;\n v_review_ocr_columns_ready boolean;\n v_review_ocr_indexes_ready boolean;\n v_trigger_helper_oid oid := pg_catalog.to_regprocedure(\n 'ocr_private.register_review_ocr_generation()'\n );\nBEGIN\n IF p_user_id IS NULL OR NOT EXISTS (\n SELECT 1 FROM auth.users AS user_row WHERE user_row.id = p_user_id\n ) THEN\n RETURN pg_catalog.jsonb_build_object(\n 'schema_version', 1,\n 'status', 'held',\n 'code', 'FEATURE_DATABASE_HELD',\n 'capability_version', 'review-media-feature-v1'\n );\n END IF;\n\n SELECT NOT EXISTS (\n SELECT 1\n FROM (\n VALUES\n ('public.claim_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text,uuid,boolean)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.mark_receipt_ocr_dispatched(uuid,uuid)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.complete_receipt_ocr_result(uuid,uuid,jsonb)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.mark_receipt_ocr_result_indeterminate(uuid,uuid)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.release_receipt_ocr_claim(uuid,uuid)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.read_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text)', 'jsonb'::pg_catalog.regtype, false, 's'::\"char\"),\n ('public.get_review_receipt_ocr_generation(uuid,uuid)', 'jsonb'::pg_catalog.regtype, false, 's'::\"char\"),\n ('public.publish_review_receipt_ocr_result(uuid,uuid,uuid,uuid,uuid,text,jsonb,boolean)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.get_feature_runtime_capability(uuid)', 'jsonb'::pg_catalog.regtype, false, 's'::\"char\"),\n ('public.get_owned_review_storage_objects(uuid,uuid,text,text[])', 'record'::pg_catalog.regtype, true, 'v'::\"char\"),\n ('public.create_review_with_receipt_ocr(uuid,uuid,uuid,text,text,timestamptz,text,text[],text[],uuid,uuid,uuid,text,timestamptz,text,jsonb)', 'uuid'::pg_catalog.regtype, false, 'v'::\"char\")\n ) AS required(signature, return_type, returns_set, volatility)\n LEFT JOIN pg_catalog.pg_proc AS procedure_row\n ON procedure_row.oid = pg_catalog.to_regprocedure(required.signature)\n WHERE procedure_row.oid IS NULL\n OR procedure_row.proowner <> 'privacy_workflow_owner'::pg_catalog.regrole\n OR NOT procedure_row.prosecdef\n OR procedure_row.proconfig IS DISTINCT FROM ARRAY['search_path=\"\"']::text[]\n OR procedure_row.prokind <> 'f'\n OR procedure_row.provolatile <> required.volatility\n OR procedure_row.prorettype <> required.return_type\n OR procedure_row.proretset IS DISTINCT FROM required.returns_set\n OR procedure_row.prolang <> (\n SELECT language.oid\n FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'sql'\n )\n OR NOT pg_catalog.has_function_privilege('service_role', procedure_row.oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('anon', procedure_row.oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('authenticated', procedure_row.oid, 'EXECUTE')\n ) INTO v_contracts_ready;\n\n -- Keep the runtime decision tied to the same exact review-column contract\n -- that the migration and successor controller admit. A later same-name\n -- column with a different type, default, nullability, identity or generated\n -- shape must put the feature back on hold.\n SELECT pg_catalog.count(*) = 4\n AND pg_catalog.bool_and(\n NOT attribute.attnotnull\n AND attribute.atttypmod = -1\n AND attribute.attidentity = ''\n AND attribute.attgenerated = ''\n AND CASE attribute.attname\n WHEN 'receipt_hash' THEN\n attribute.atttypid = 'text'::pg_catalog.regtype\n AND default_value.oid IS NULL\n WHEN 'receipt_data' THEN\n attribute.atttypid = 'jsonb'::pg_catalog.regtype\n AND default_value.oid IS NULL\n WHEN 'is_duplicate' THEN\n attribute.atttypid = 'boolean'::pg_catalog.regtype\n AND pg_catalog.pg_get_expr(\n default_value.adbin,\n default_value.adrelid\n ) = 'false'\n WHEN 'ocr_processed_at' THEN\n attribute.atttypid = 'timestamp with time zone'::pg_catalog.regtype\n AND default_value.oid IS NULL\n ELSE false\n END\n )\n INTO v_review_ocr_columns_ready\n FROM pg_catalog.pg_attribute AS attribute\n LEFT JOIN pg_catalog.pg_attrdef AS default_value\n ON default_value.adrelid = attribute.attrelid\n AND default_value.adnum = attribute.attnum\n WHERE attribute.attrelid = 'public.reviews'::regclass\n AND attribute.attnum > 0\n AND NOT attribute.attisdropped\n AND attribute.attname = ANY (ARRAY[\n 'receipt_hash',\n 'receipt_data',\n 'is_duplicate',\n 'ocr_processed_at'\n ]::name[]);\n\n -- Index identity is not enough: method, key, predicate and online-validity\n -- flags are all part of the runtime contract.\n SELECT pg_catalog.count(*) = 3\n AND pg_catalog.bool_and(\n index_relation.relkind = 'i'\n AND index_row.indrelid = 'public.reviews'::regclass\n AND access_method.amname = 'btree'\n AND index_row.indnkeyatts = 1\n AND index_row.indnatts = 1\n AND index_row.indexprs IS NULL\n AND index_row.indisvalid\n AND index_row.indisready\n AND index_row.indislive\n AND NOT index_row.indisprimary\n AND NOT index_row.indisexclusion\n AND CASE index_relation.relname\n WHEN 'idx_reviews_receipt_hash' THEN\n index_row.indisunique\n AND pg_catalog.pg_get_indexdef(index_relation.oid, 1, true) = 'receipt_hash'\n AND pg_catalog.pg_get_expr(\n index_row.indpred,\n index_row.indrelid\n ) = '(receipt_hash IS NOT NULL)'\n WHEN 'reviews_ocr_pending_idx' THEN\n NOT index_row.indisunique\n AND pg_catalog.pg_get_indexdef(index_relation.oid, 1, true) = 'ocr_processed_at'\n AND pg_catalog.pg_get_expr(\n index_row.indpred,\n index_row.indrelid\n ) = '(verification_photo IS NOT NULL)'\n WHEN 'reviews_duplicate_idx' THEN\n NOT index_row.indisunique\n AND pg_catalog.pg_get_indexdef(index_relation.oid, 1, true) = 'is_duplicate'\n AND index_row.indpred IS NULL\n ELSE false\n END\n )\n INTO v_review_ocr_indexes_ready\n FROM pg_catalog.pg_class AS index_relation\n JOIN pg_catalog.pg_namespace AS namespace\n ON namespace.oid = index_relation.relnamespace\n JOIN pg_catalog.pg_index AS index_row\n ON index_row.indexrelid = index_relation.oid\n JOIN pg_catalog.pg_am AS access_method\n ON access_method.oid = index_relation.relam\n WHERE namespace.nspname = 'public'\n AND index_relation.relname = ANY (ARRAY[\n 'idx_reviews_receipt_hash',\n 'reviews_ocr_pending_idx',\n 'reviews_duplicate_idx'\n ]::name[]);\n\n SELECT EXISTS (\n SELECT 1\n FROM ocr_private.feature_runtime_capability AS capability\n WHERE capability.capability_version = 'review-media-feature-v1'\n AND capability.schema_version = 1\n )\n AND v_contracts_ready\n AND coalesce(v_review_ocr_columns_ready, false)\n AND coalesce(v_review_ocr_indexes_ready, false)\n AND pg_catalog.to_regprocedure('public.storyboard_production_admin(uuid,text,uuid,integer,jsonb)') IS NOT NULL\n AND pg_catalog.to_regprocedure('public.storyboard_production_worker(uuid,text,uuid,uuid,jsonb)') IS NOT NULL\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_class AS table_row\n WHERE table_row.oid = 'ocr_private.receipt_results'::pg_catalog.regclass\n AND table_row.relowner = 'postgres'::pg_catalog.regrole\n AND table_row.relrowsecurity\n AND table_row.relforcerowsecurity\n )\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_class AS table_row\n WHERE table_row.oid = 'ocr_private.review_generations'::pg_catalog.regclass\n AND table_row.relowner = 'postgres'::pg_catalog.regrole\n AND table_row.relrowsecurity\n AND table_row.relforcerowsecurity\n )\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_proc AS helper\n WHERE helper.oid = v_trigger_helper_oid\n AND helper.proowner = 'postgres'::pg_catalog.regrole\n AND helper.prosecdef\n AND helper.proconfig = ARRAY['search_path=\"\"']::text[]\n AND helper.prorettype = 'trigger'::pg_catalog.regtype\n AND helper.prolang = (\n SELECT language.oid\n FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n AND NOT pg_catalog.has_function_privilege('anon', helper.oid, 'EXECUTE')\n AND NOT pg_catalog.has_function_privilege('authenticated', helper.oid, 'EXECUTE')\n AND NOT pg_catalog.has_function_privilege('service_role', helper.oid, 'EXECUTE')\n )\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_trigger AS trigger_row\n WHERE trigger_row.tgrelid = 'public.reviews'::pg_catalog.regclass\n AND trigger_row.tgfoid = v_trigger_helper_oid\n AND trigger_row.tgname IN (\n 'review_ocr_generation_on_insert',\n 'review_ocr_generation_on_reset'\n )\n AND NOT trigger_row.tgisinternal\n GROUP BY trigger_row.tgfoid\n HAVING pg_catalog.count(*) = 2\n )\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_trigger AS trigger_row\n WHERE trigger_row.tgrelid = 'storage.objects'::pg_catalog.regclass\n AND trigger_row.tgname = 'a01_review_verification_immutable_update'\n AND NOT trigger_row.tgisinternal\n )\n INTO v_ready;\n\n RETURN pg_catalog.jsonb_build_object(\n 'schema_version', 1,\n 'status', CASE WHEN v_ready THEN 'ready' ELSE 'held' END,\n 'code', CASE WHEN v_ready THEN 'FEATURE_DATABASE_READY' ELSE 'FEATURE_DATABASE_HELD' END,\n 'capability_version', 'review-media-feature-v1'\n );\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.get_owned_review_storage_objects(\n p_user_id uuid,\n p_review_id uuid,\n p_purpose text,\n p_names text[]\n)\nRETURNS TABLE (\n id uuid,\n bucket_id text,\n name text,\n owner_id text,\n version text,\n updated_at timestamptz,\n metadata jsonb,\n archived_at timestamptz,\n is_delete_marker boolean\n)\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_bucket text;\n v_prefix text;\nBEGIN\n v_bucket := CASE p_purpose\n WHEN 'verification' THEN 'review-verifications'\n WHEN 'food' THEN 'review-photos'\n ELSE NULL\n END;\n v_prefix := p_user_id::text || '/reviews/' || p_review_id::text || '/' || p_purpose || '/';\n IF p_user_id IS NULL OR p_review_id IS NULL OR v_bucket IS NULL\n OR p_names IS NULL OR cardinality(p_names) NOT BETWEEN 1 AND 20\n OR (SELECT count(*) FROM unnest(p_names)) <> (SELECT count(DISTINCT object_name) FROM unnest(p_names) AS object_name)\n OR EXISTS (\n SELECT 1 FROM unnest(p_names) AS object_name\n WHERE object_name NOT LIKE v_prefix || '%'\n OR substring(object_name FROM length(v_prefix) + 1) LIKE '%/%'\n )\n THEN\n RAISE EXCEPTION 'invalid_review_storage_lookup' USING ERRCODE = '22023';\n END IF;\n\n RETURN QUERY\n SELECT object_row.id, object_row.bucket_id, object_row.name, object_row.owner_id,\n object_row.version, object_row.updated_at, object_row.metadata,\n object_row.archived_at, object_row.is_delete_marker\n FROM storage.objects AS object_row\n WHERE object_row.bucket_id = v_bucket\n AND object_row.name = ANY(p_names)\n AND object_row.owner_id = p_user_id::text\n AND object_row.archived_at IS NULL\n AND object_row.is_delete_marker = false\n ORDER BY object_row.name;\nEND;\n$$", + "CREATE OR REPLACE FUNCTION public.create_review_with_receipt_ocr(\n p_review_id uuid,\n p_user_id uuid,\n p_restaurant_id uuid,\n p_title text,\n p_content text,\n p_visited_at timestamptz,\n p_verification_photo text,\n p_food_photos text[],\n p_categories text[],\n p_ocr_result_id uuid,\n p_ocr_result_revision uuid,\n p_verification_object_id uuid,\n p_verification_object_version text,\n p_verification_object_updated_at timestamptz,\n p_verification_photo_sha256 text,\n p_user_corrections jsonb DEFAULT '{}'::jsonb\n)\nRETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_result ocr_private.receipt_results%ROWTYPE;\n v_normalized jsonb;\n v_receipt_data jsonb;\n v_receipt_hash text;\n v_duplicate_of uuid;\n v_owner_prefix text := p_user_id::text || '/reviews/' || p_review_id::text || '/';\n v_verification_object storage.objects%ROWTYPE;\n v_food_object storage.objects%ROWTYPE;\n v_food_photo text;\nBEGIN\n IF p_review_id IS NULL OR p_user_id IS NULL OR p_restaurant_id IS NULL\n OR p_ocr_result_id IS NULL OR p_ocr_result_revision IS NULL\n OR p_verification_object_id IS NULL OR p_verification_object_updated_at IS NULL\n OR p_verification_photo_sha256 IS NULL OR p_verification_photo_sha256 !~ '^[0-9a-f]{64}$'\n OR p_verification_photo IS NULL OR p_verification_photo NOT LIKE v_owner_prefix || 'verification/%'\n OR substring(p_verification_photo FROM length(v_owner_prefix || 'verification/') + 1) LIKE '%/%'\n OR p_food_photos IS NULL OR cardinality(p_food_photos) = 0\n OR EXISTS (SELECT 1 FROM unnest(p_food_photos) AS photo WHERE photo NOT LIKE v_owner_prefix || 'food/%')\n OR EXISTS (SELECT 1 FROM unnest(p_food_photos) AS photo WHERE substring(photo FROM length(v_owner_prefix || 'food/') + 1) LIKE '%/%')\n OR (SELECT count(*) FROM unnest(p_food_photos)) <> (SELECT count(DISTINCT photo) FROM unnest(p_food_photos) AS photo)\n OR p_user_corrections IS NULL OR jsonb_typeof(p_user_corrections) <> 'object'\n OR p_user_corrections ?| ARRAY['receipt_hash', 'ocr_result_id', 'ocr_result_revision', 'source', 'schema_version', 'status']\n THEN\n RAISE EXCEPTION 'invalid_review_receipt_input' USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_result\n FROM ocr_private.receipt_results\n WHERE id = p_ocr_result_id\n AND user_id = p_user_id\n AND generation = '00000000-0000-0000-0000-000000000000'::uuid\n AND status = 'succeeded'\n FOR SHARE;\n IF NOT FOUND OR NOT ocr_private.valid_result_payload(v_result.result_payload) THEN\n RAISE EXCEPTION 'receipt_ocr_result_not_available' USING ERRCODE = '42501';\n END IF;\n IF v_result.result_revision IS DISTINCT FROM p_ocr_result_revision THEN\n RAISE EXCEPTION 'receipt_ocr_result_stale' USING ERRCODE = '42501';\n END IF;\n IF v_result.image_hash <> p_verification_photo_sha256 THEN\n RAISE EXCEPTION 'receipt_ocr_image_mismatch' USING ERRCODE = '42501';\n END IF;\n\n -- This row lock protects only Storage metadata. Correct byte binding requires\n -- the Storage service to update version/updated_at atomically with content,\n -- immutable verification keys (browser upsert:false and no authenticated\n -- UPDATE policy), and no out-of-band direct S3 overwrite.\n SELECT * INTO v_verification_object\n FROM storage.objects\n WHERE id = p_verification_object_id\n AND bucket_id = 'review-verifications'\n AND name = p_verification_photo\n AND owner_id = p_user_id::text\n AND archived_at IS NULL\n AND is_delete_marker = false\n FOR SHARE;\n IF NOT FOUND\n OR v_verification_object.version IS DISTINCT FROM p_verification_object_version\n OR v_verification_object.updated_at IS DISTINCT FROM p_verification_object_updated_at\n THEN\n RAISE EXCEPTION 'receipt_verification_object_not_available' USING ERRCODE = '42501';\n END IF;\n\n FOREACH v_food_photo IN ARRAY p_food_photos LOOP\n SELECT * INTO v_food_object\n FROM storage.objects\n WHERE bucket_id = 'review-photos'\n AND name = v_food_photo\n AND owner_id = p_user_id::text\n AND archived_at IS NULL\n AND is_delete_marker = false\n FOR SHARE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'review_food_object_not_available' USING ERRCODE = '42501';\n END IF;\n END LOOP;\n\n v_normalized := v_result.result_payload->'normalized';\n IF v_result.result_payload ? 'failure_code' THEN\n v_receipt_data := jsonb_build_object(\n 'schema_version', 'receipt_summary_v1',\n 'status', 'failed',\n 'failure_code', v_result.result_payload->>'failure_code',\n 'source', 'public_ocr',\n 'ocr_result_id', v_result.id,\n 'ocr_result_revision', v_result.result_revision\n );\n ELSE\n v_receipt_data := jsonb_strip_nulls(jsonb_build_object(\n 'schema_version', 'receipt_summary_v1',\n 'status', 'processed',\n 'source', 'public_ocr',\n 'ocr_result_id', v_result.id,\n 'ocr_result_revision', v_result.result_revision,\n 'store_name', v_normalized->'store_name',\n 'date', v_normalized->'date',\n 'time', v_normalized->'time',\n 'total_amount', v_normalized->'total_amount',\n 'items', v_normalized->'items',\n 'item_count', COALESCE(jsonb_array_length(CASE WHEN jsonb_typeof(v_normalized->'items') = 'array' THEN v_normalized->'items' ELSE '[]'::jsonb END), 0),\n 'confidence', v_result.result_payload->'confidence',\n 'user_corrections', p_user_corrections\n ));\n\n IF NULLIF(v_normalized->>'store_name', '') IS NOT NULL\n AND (v_normalized->>'date') ~ '^\\d{4}-\\d{2}-\\d{2}$'\n AND (v_normalized->>'time') ~ '^\\d{2}:\\d{2}$'\n AND jsonb_typeof(v_normalized->'total_amount') = 'number'\n THEN\n v_receipt_hash := encode(extensions.digest(\n (v_normalized->>'store_name') || '|' || (v_normalized->>'date') || '|'\n || (v_normalized->>'time') || '|' || (v_normalized->>'total_amount'),\n 'sha256'\n ), 'hex');\n PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(v_receipt_hash, 0));\n SELECT id INTO v_duplicate_of\n FROM public.reviews\n WHERE receipt_hash = v_receipt_hash\n ORDER BY created_at, id\n LIMIT 1;\n IF v_duplicate_of IS NOT NULL THEN\n v_receipt_data := v_receipt_data || jsonb_build_object('duplicate_of', v_duplicate_of);\n END IF;\n END IF;\n END IF;\n\n INSERT INTO public.reviews (\n id, user_id, restaurant_id, title, content, visited_at,\n verification_photo, food_photos, categories, is_verified,\n receipt_hash, receipt_data, is_duplicate, ocr_processed_at\n ) VALUES (\n p_review_id, p_user_id, p_restaurant_id, p_title, p_content, p_visited_at,\n p_verification_photo, p_food_photos, p_categories, false,\n CASE WHEN v_duplicate_of IS NULL THEN v_receipt_hash ELSE NULL END,\n v_receipt_data,\n v_duplicate_of IS NOT NULL,\n now()\n );\n\n RETURN p_review_id;\nEND;\n$$", + "REVOKE ALL ON FUNCTION ocr_private.register_review_ocr_generation() FROM PUBLIC, anon, authenticated, service_role", + "REVOKE ALL ON FUNCTION ocr_private.valid_model_policy(jsonb) FROM PUBLIC, anon, authenticated, service_role", + "REVOKE ALL ON FUNCTION public.claim_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text,uuid,boolean) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.mark_receipt_ocr_dispatched(uuid,uuid) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.complete_receipt_ocr_result(uuid,uuid,jsonb) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.mark_receipt_ocr_result_indeterminate(uuid,uuid) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.release_receipt_ocr_claim(uuid,uuid) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.read_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.get_review_receipt_ocr_generation(uuid,uuid) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.publish_review_receipt_ocr_result(uuid,uuid,uuid,uuid,uuid,text,jsonb,boolean) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.get_feature_runtime_capability(uuid) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.get_owned_review_storage_objects(uuid,uuid,text,text[]) FROM PUBLIC, anon, authenticated", + "REVOKE ALL ON FUNCTION public.create_review_with_receipt_ocr(uuid,uuid,uuid,text,text,timestamptz,text,text[],text[],uuid,uuid,uuid,text,timestamptz,text,jsonb) FROM PUBLIC, anon, authenticated", + "GRANT EXECUTE ON FUNCTION public.claim_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text,uuid,boolean) TO service_role", + "GRANT EXECUTE ON FUNCTION public.mark_receipt_ocr_dispatched(uuid,uuid) TO service_role", + "GRANT EXECUTE ON FUNCTION public.complete_receipt_ocr_result(uuid,uuid,jsonb) TO service_role", + "GRANT EXECUTE ON FUNCTION public.mark_receipt_ocr_result_indeterminate(uuid,uuid) TO service_role", + "GRANT EXECUTE ON FUNCTION public.release_receipt_ocr_claim(uuid,uuid) TO service_role", + "GRANT EXECUTE ON FUNCTION public.read_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text) TO service_role", + "GRANT EXECUTE ON FUNCTION public.get_review_receipt_ocr_generation(uuid,uuid) TO service_role", + "GRANT EXECUTE ON FUNCTION public.publish_review_receipt_ocr_result(uuid,uuid,uuid,uuid,uuid,text,jsonb,boolean) TO service_role", + "GRANT EXECUTE ON FUNCTION public.get_feature_runtime_capability(uuid) TO service_role", + "GRANT EXECUTE ON FUNCTION public.get_owned_review_storage_objects(uuid,uuid,text,text[]) TO service_role", + "GRANT EXECUTE ON FUNCTION public.create_review_with_receipt_ocr(uuid,uuid,uuid,text,text,timestamptz,text,text[],text[],uuid,uuid,uuid,text,timestamptz,text,jsonb) TO service_role" + ], + "path": "backend/supabase/migrations/20261013190000_durable_receipt_ocr_results.sql", + "sha256": "7a140594826c3375da71e74be44bdfe9c6e99c4465d8a9484a9d8180d696480d", + "statementCount": 63, + "statementVectorSha256": "debf9cb256473bc1738d8144e7ce512030be09778713be4813f788ffb3fc5fb4", + "version": "20261013190000" + }, + { + "bytes": 25506, + "id": "g014_feature_rpc_catalog_contract", + "name": "g014_feature_rpc_catalog_contract", + "originalStatementVector": [ + "-- Forward-only registration for the durable OCR service RPCs plus retirement of\n-- direct Data API execution on the storyboard archive trigger helper.\n--\n-- The file was created by Supabase CLI as\n-- 20261013113313_g014_feature_rpc_catalog_contract.sql, then renamed before any\n-- application so it sorts after the frozen 20261013190000 OCR dependency.\nBEGIN", + "SET LOCAL lock_timeout = '2s'", + "SELECT pg_catalog.pg_advisory_xact_lock(\n pg_catalog.hashtextextended('tzudong:g014-feature-rpc-catalog:v1', 0)\n)", + "CREATE TEMPORARY TABLE pg_temp.g014_feature_rpc_registry (\n source_signature text PRIMARY KEY,\n expected_return_type regtype NOT NULL,\n expected_set boolean NOT NULL,\n expected_volatility \"char\" NOT NULL\n) ON COMMIT DROP", + "INSERT INTO pg_temp.g014_feature_rpc_registry (\n source_signature,\n expected_return_type,\n expected_set,\n expected_volatility\n)\nVALUES\n ('public.claim_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text,uuid,boolean)', 'jsonb'::regtype, false, 'v'),\n ('public.mark_receipt_ocr_dispatched(uuid,uuid)', 'jsonb'::regtype, false, 'v'),\n ('public.complete_receipt_ocr_result(uuid,uuid,jsonb)', 'jsonb'::regtype, false, 'v'),\n ('public.mark_receipt_ocr_result_indeterminate(uuid,uuid)', 'jsonb'::regtype, false, 'v'),\n ('public.release_receipt_ocr_claim(uuid,uuid)', 'jsonb'::regtype, false, 'v'),\n ('public.read_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text)', 'jsonb'::regtype, false, 's'),\n ('public.get_review_receipt_ocr_generation(uuid,uuid)', 'jsonb'::regtype, false, 's'),\n ('public.publish_review_receipt_ocr_result(uuid,uuid,uuid,uuid,uuid,text,jsonb,boolean)', 'jsonb'::regtype, false, 'v'),\n ('public.get_feature_runtime_capability(uuid)', 'jsonb'::regtype, false, 's'),\n ('public.get_owned_review_storage_objects(uuid,uuid,text,text[])', 'record'::regtype, true, 'v'),\n ('public.create_review_with_receipt_ocr(uuid,uuid,uuid,text,text,timestamptz,text,text[],text[],uuid,uuid,uuid,text,timestamptz,text,jsonb)', 'uuid'::regtype, false, 'v')", + "GRANT SELECT ON TABLE pg_temp.g014_feature_rpc_registry\n TO privacy_workflow_owner", + "CREATE TEMPORARY TABLE pg_temp.g014_feature_membership_before ON COMMIT DROP AS\nSELECT pg_catalog.to_jsonb(membership) AS row_value\nFROM pg_catalog.pg_auth_members AS membership", + "DO $prerequisites$\nDECLARE\n required record;\n procedure_oid oid;\n procedure_row pg_catalog.pg_proc%ROWTYPE;\n archive_guard_oid oid;\n generation_trigger_oid oid;\nBEGIN\n IF pg_catalog.current_setting('server_version_num')::integer / 10000 <> 17\n OR current_user <> 'postgres'\n OR session_user <> 'postgres'\n OR pg_catalog.current_setting('transaction_read_only') <> 'off' THEN\n RAISE EXCEPTION 'G014_FEATURE_RPC_EXECUTOR_DENIED';\n END IF;\n\n IF pg_catalog.to_regclass('privacy_retention.g014_public_rpc_allowlist') IS NULL\n OR pg_catalog.to_regclass('privacy_retention.g014_nested_helper_allowlist') IS NULL\n OR pg_catalog.to_regprocedure('privacy_retention.assert_g014_public_rpc_allowlist()') IS NULL\n OR pg_catalog.to_regprocedure('privacy_retention.assert_g014_definer_contract()') IS NULL THEN\n RAISE EXCEPTION 'G014_FEATURE_RPC_CATALOG_PREREQUISITE_MISSING';\n END IF;\n\n FOR required IN\n SELECT * FROM pg_temp.g014_feature_rpc_registry ORDER BY source_signature\n LOOP\n procedure_oid := pg_catalog.to_regprocedure(required.source_signature);\n SELECT procedure.* INTO procedure_row\n FROM pg_catalog.pg_proc AS procedure\n WHERE procedure.oid = procedure_oid;\n\n IF procedure_oid IS NULL\n OR procedure_row.proowner <> 'postgres'::regrole\n OR NOT procedure_row.prosecdef\n OR procedure_row.proconfig IS DISTINCT FROM ARRAY['search_path=\"\"']::text[]\n OR procedure_row.prokind <> 'f'\n OR procedure_row.provolatile <> required.expected_volatility\n OR procedure_row.prolang <> (\n SELECT language.oid FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n OR procedure_row.prorettype <> required.expected_return_type\n OR procedure_row.proretset IS DISTINCT FROM required.expected_set\n OR NOT pg_catalog.has_function_privilege('service_role', procedure_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('anon', procedure_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('authenticated', procedure_oid, 'EXECUTE')\n OR EXISTS (\n SELECT 1\n FROM pg_catalog.aclexplode(\n coalesce(\n procedure_row.proacl,\n pg_catalog.acldefault('f', procedure_row.proowner)\n )\n ) AS acl\n WHERE acl.privilege_type = 'EXECUTE'\n AND (\n acl.grantee = 0\n OR pg_catalog.pg_get_userbyid(acl.grantee) NOT IN ('postgres', 'service_role')\n OR (pg_catalog.pg_get_userbyid(acl.grantee) = 'service_role' AND acl.is_grantable)\n )\n ) THEN\n RAISE EXCEPTION 'G014_FEATURE_RPC_PREREQUISITE_DRIFT: %', required.source_signature;\n END IF;\n END LOOP;\n\n generation_trigger_oid := pg_catalog.to_regprocedure(\n 'ocr_private.register_review_ocr_generation()'\n );\n SELECT procedure.* INTO procedure_row\n FROM pg_catalog.pg_proc AS procedure\n WHERE procedure.oid = generation_trigger_oid;\n\n IF generation_trigger_oid IS NULL\n OR procedure_row.proowner <> 'postgres'::regrole\n OR NOT procedure_row.prosecdef\n OR procedure_row.proconfig IS DISTINCT FROM ARRAY['search_path=\"\"']::text[]\n OR procedure_row.prokind <> 'f'\n OR procedure_row.prorettype <> 'trigger'::regtype\n OR procedure_row.proretset\n OR procedure_row.prolang <> (\n SELECT language.oid FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n OR pg_catalog.has_function_privilege('anon', generation_trigger_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('authenticated', generation_trigger_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('service_role', generation_trigger_oid, 'EXECUTE')\n OR (\n SELECT pg_catalog.count(*)\n FROM pg_catalog.pg_trigger AS trigger_row\n WHERE trigger_row.tgfoid = generation_trigger_oid\n AND trigger_row.tgrelid = 'public.reviews'::regclass\n AND trigger_row.tgname IN (\n 'review_ocr_generation_on_insert',\n 'review_ocr_generation_on_reset'\n )\n AND trigger_row.tgenabled = 'O'\n AND NOT trigger_row.tgisinternal\n ) <> 2 THEN\n RAISE EXCEPTION 'G014_FEATURE_GENERATION_TRIGGER_PREREQUISITE_DRIFT';\n END IF;\n\n archive_guard_oid := pg_catalog.to_regprocedure(\n 'public.storyboard_production_reject_archived_write()'\n );\n SELECT procedure.* INTO procedure_row\n FROM pg_catalog.pg_proc AS procedure\n WHERE procedure.oid = archive_guard_oid;\n\n IF archive_guard_oid IS NULL\n OR procedure_row.proowner <> 'postgres'::regrole\n OR procedure_row.prosecdef\n OR procedure_row.proconfig IS DISTINCT FROM ARRAY['search_path=\"\"']::text[]\n OR procedure_row.prokind <> 'f'\n OR procedure_row.prorettype <> 'trigger'::regtype\n OR procedure_row.proretset\n OR procedure_row.prolang <> (\n SELECT language.oid FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n OR (\n SELECT pg_catalog.count(*)\n FROM pg_catalog.pg_trigger AS trigger_row\n WHERE trigger_row.tgfoid = archive_guard_oid\n AND NOT trigger_row.tgisinternal\n ) <> 6 THEN\n RAISE EXCEPTION 'G014_FEATURE_TRIGGER_PREREQUISITE_DRIFT';\n END IF;\nEND;\n$prerequisites$", + "-- Trigger creation already succeeded in the predecessor transaction. PostgreSQL\n-- checks EXECUTE when CREATE TRIGGER runs; firing an installed trigger does not\n-- require exposing its implementation as a PostgREST RPC. The PG17.6 rehearsal\n-- exercises the trigger after this revoke.\nREVOKE ALL ON FUNCTION public.storyboard_production_reject_archived_write()\n FROM PUBLIC, anon, authenticated, service_role, privacy_workflow_owner", + "-- Keep the pre-existing privileged implementations private. The public API is\n-- recreated below as workflow-owner wrappers, so the Data API never receives\n-- EXECUTE on a postgres-owned implementation.\nDO $move_helpers$\nDECLARE\n required record;\n procedure_oid oid;\nBEGIN\n FOR required IN\n SELECT * FROM pg_temp.g014_feature_rpc_registry ORDER BY source_signature\n LOOP\n procedure_oid := pg_catalog.to_regprocedure(required.source_signature);\n EXECUTE pg_catalog.format(\n 'ALTER FUNCTION %s SET SCHEMA ocr_private',\n procedure_oid::regprocedure\n );\n EXECUTE pg_catalog.format(\n 'REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated, service_role, privacy_workflow_owner',\n procedure_oid::regprocedure\n );\n EXECUTE pg_catalog.format(\n 'GRANT EXECUTE ON FUNCTION %s TO privacy_workflow_owner',\n procedure_oid::regprocedure\n );\n END LOOP;\nEND;\n$move_helpers$", + "GRANT USAGE ON SCHEMA ocr_private TO privacy_workflow_owner", + "CREATE TEMPORARY TABLE pg_temp.g014_feature_self_membership_before (\n inherit_option boolean NOT NULL,\n set_option boolean NOT NULL\n) ON COMMIT DROP", + "INSERT INTO pg_temp.g014_feature_self_membership_before (\n inherit_option,\n set_option\n)\nSELECT membership.inherit_option, membership.set_option\nFROM pg_catalog.pg_auth_members AS membership\nWHERE membership.roleid = 'privacy_workflow_owner'::regrole\n AND membership.member = 'postgres'::regrole\n AND membership.grantor = 'postgres'::regrole", + "DO $lease_workflow_owner$\nDECLARE\n workflow_owner oid := 'privacy_workflow_owner'::regrole;\n runner oid := 'postgres'::regrole;\nBEGIN\n IF EXISTS (\n SELECT 1\n FROM pg_catalog.pg_auth_members AS membership\n WHERE membership.roleid = workflow_owner\n AND membership.member = runner\n AND membership.grantor = runner\n AND membership.admin_option\n ) THEN\n RAISE EXCEPTION 'G014_FEATURE_RPC_SELF_MEMBERSHIP_DENIED';\n END IF;\n IF NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_auth_members AS membership\n WHERE membership.roleid = workflow_owner\n AND membership.member = runner\n AND membership.admin_option\n AND membership.grantor <> runner\n ) THEN\n RAISE EXCEPTION 'G014_FEATURE_RPC_OWNER_AUTHORITY_MISSING';\n END IF;\n\n GRANT privacy_workflow_owner TO postgres\n WITH ADMIN FALSE, INHERIT FALSE, SET TRUE\n GRANTED BY postgres;\nEND;\n$lease_workflow_owner$", + "SET LOCAL ROLE privacy_workflow_owner", + "CREATE TEMPORARY TABLE pg_temp.g014_feature_manifest_before ON COMMIT DROP AS\nSELECT coalesce(\n pg_catalog.jsonb_agg(\n pg_catalog.to_jsonb(manifest)\n ORDER BY manifest.manifest_kind, manifest.manifest_key\n ),\n '[]'::jsonb\n ) AS row_value\nFROM privacy_retention.g014_catalog_contract_manifest AS manifest", + "CREATE FUNCTION public.claim_receipt_ocr_result(\n p_user_id uuid,\n p_image_hash text,\n p_input_context_hash text,\n p_provider text,\n p_model_policy jsonb,\n p_generation uuid,\n p_prompt_version text,\n p_preprocess_version text,\n p_normalization_version text,\n p_operation_id uuid,\n p_force_refresh boolean DEFAULT false\n)\nRETURNS jsonb\nLANGUAGE sql\nVOLATILE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.claim_receipt_ocr_result(\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11\n );\n$wrapper$", + "CREATE FUNCTION public.mark_receipt_ocr_dispatched(\n p_result_id uuid,\n p_claim_token uuid\n)\nRETURNS jsonb\nLANGUAGE sql\nVOLATILE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.mark_receipt_ocr_dispatched($1, $2);\n$wrapper$", + "CREATE FUNCTION public.complete_receipt_ocr_result(\n p_result_id uuid,\n p_claim_token uuid,\n p_result_payload jsonb\n)\nRETURNS jsonb\nLANGUAGE sql\nVOLATILE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.complete_receipt_ocr_result($1, $2, $3);\n$wrapper$", + "CREATE FUNCTION public.mark_receipt_ocr_result_indeterminate(\n p_result_id uuid,\n p_claim_token uuid\n)\nRETURNS jsonb\nLANGUAGE sql\nVOLATILE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.mark_receipt_ocr_result_indeterminate($1, $2);\n$wrapper$", + "CREATE FUNCTION public.release_receipt_ocr_claim(\n p_result_id uuid,\n p_claim_token uuid\n)\nRETURNS jsonb\nLANGUAGE sql\nVOLATILE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.release_receipt_ocr_claim($1, $2);\n$wrapper$", + "CREATE FUNCTION public.read_receipt_ocr_result(\n p_user_id uuid,\n p_image_hash text,\n p_input_context_hash text,\n p_provider text,\n p_model_policy jsonb,\n p_generation uuid,\n p_prompt_version text,\n p_preprocess_version text,\n p_normalization_version text\n)\nRETURNS jsonb\nLANGUAGE sql\nSTABLE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.read_receipt_ocr_result($1, $2, $3, $4, $5, $6, $7, $8, $9);\n$wrapper$", + "CREATE FUNCTION public.get_review_receipt_ocr_generation(\n p_user_id uuid,\n p_review_id uuid\n)\nRETURNS jsonb\nLANGUAGE sql\nSTABLE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.get_review_receipt_ocr_generation($1, $2);\n$wrapper$", + "CREATE FUNCTION public.publish_review_receipt_ocr_result(\n p_user_id uuid,\n p_review_id uuid,\n p_generation uuid,\n p_result_id uuid,\n p_result_revision uuid,\n p_receipt_hash text,\n p_receipt_data jsonb,\n p_is_duplicate boolean\n)\nRETURNS jsonb\nLANGUAGE sql\nVOLATILE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.publish_review_receipt_ocr_result($1, $2, $3, $4, $5, $6, $7, $8);\n$wrapper$", + "CREATE FUNCTION public.get_feature_runtime_capability(p_user_id uuid)\nRETURNS jsonb\nLANGUAGE sql\nSTABLE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.get_feature_runtime_capability($1);\n$wrapper$", + "CREATE FUNCTION public.get_owned_review_storage_objects(\n p_user_id uuid,\n p_review_id uuid,\n p_purpose text,\n p_names text[]\n)\nRETURNS TABLE (\n id uuid,\n bucket_id text,\n name text,\n owner_id text,\n version text,\n updated_at timestamptz,\n metadata jsonb,\n archived_at timestamptz,\n is_delete_marker boolean\n)\nLANGUAGE sql\nVOLATILE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT *\n FROM ocr_private.get_owned_review_storage_objects($1, $2, $3, $4);\n$wrapper$", + "CREATE FUNCTION public.create_review_with_receipt_ocr(\n p_review_id uuid,\n p_user_id uuid,\n p_restaurant_id uuid,\n p_title text,\n p_content text,\n p_visited_at timestamptz,\n p_verification_photo text,\n p_food_photos text[],\n p_categories text[],\n p_ocr_result_id uuid,\n p_ocr_result_revision uuid,\n p_verification_object_id uuid,\n p_verification_object_version text,\n p_verification_object_updated_at timestamptz,\n p_verification_photo_sha256 text,\n p_user_corrections jsonb DEFAULT '{}'::jsonb\n)\nRETURNS uuid\nLANGUAGE sql\nVOLATILE\nSECURITY DEFINER\nSET search_path = ''\nAS $wrapper$\n SELECT ocr_private.create_review_with_receipt_ocr(\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16\n );\n$wrapper$", + "DO $wrapper_grants$\nDECLARE\n required record;\n procedure_oid oid;\nBEGIN\n FOR required IN\n SELECT * FROM pg_temp.g014_feature_rpc_registry ORDER BY source_signature\n LOOP\n procedure_oid := pg_catalog.to_regprocedure(required.source_signature);\n EXECUTE pg_catalog.format(\n 'REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated',\n procedure_oid::regprocedure\n );\n EXECUTE pg_catalog.format(\n 'GRANT EXECUTE ON FUNCTION %s TO service_role',\n procedure_oid::regprocedure\n );\n END LOOP;\nEND;\n$wrapper_grants$", + "INSERT INTO privacy_retention.g014_public_rpc_allowlist (\n function_schema,\n function_name,\n identity_arguments,\n grantee,\n source_signature\n)\nSELECT\n namespace.nspname,\n procedure.proname,\n procedure.proargtypes::text,\n 'service_role'::name,\n required.source_signature\nFROM pg_temp.g014_feature_rpc_registry AS required\nJOIN pg_catalog.pg_proc AS procedure\n ON procedure.oid = pg_catalog.to_regprocedure(required.source_signature)\nJOIN pg_catalog.pg_namespace AS namespace\n ON namespace.oid = procedure.pronamespace\nON CONFLICT (source_signature, grantee) DO UPDATE SET\n function_schema = EXCLUDED.function_schema,\n function_name = EXCLUDED.function_name,\n identity_arguments = EXCLUDED.identity_arguments", + "INSERT INTO privacy_retention.g014_nested_helper_allowlist (source_signature)\nSELECT 'ocr_private.' || pg_catalog.substr(\n required.source_signature,\n pg_catalog.length('public.') + 1\n )\nFROM pg_temp.g014_feature_rpc_registry AS required\nON CONFLICT (source_signature) DO NOTHING", + "INSERT INTO privacy_retention.g014_nested_helper_allowlist (source_signature)\nVALUES ('ocr_private.register_review_ocr_generation()')\nON CONFLICT (source_signature) DO NOTHING", + "DELETE FROM privacy_retention.g014_public_rpc_allowlist AS allowed\nWHERE allowed.source_signature = 'public.storyboard_production_reject_archived_write()'\n OR (\n allowed.function_schema = 'public'\n AND allowed.function_name = 'storyboard_production_reject_archived_write'\n )", + "CREATE FUNCTION pg_temp.g014_feature_rpc_assert() RETURNS void\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $bridge$\nBEGIN\n PERFORM privacy_retention.assert_g014_public_rpc_allowlist();\n PERFORM privacy_retention.assert_g014_definer_contract();\n DROP FUNCTION pg_temp.g014_feature_rpc_assert();\nEND;\n$bridge$", + "REVOKE ALL ON FUNCTION pg_temp.g014_feature_rpc_assert()\n FROM PUBLIC, anon, authenticated, service_role", + "GRANT EXECUTE ON FUNCTION pg_temp.g014_feature_rpc_assert() TO postgres", + "DO $manifest_readback$\nBEGIN\n IF (\n SELECT coalesce(\n pg_catalog.jsonb_agg(\n pg_catalog.to_jsonb(manifest)\n ORDER BY manifest.manifest_kind, manifest.manifest_key\n ),\n '[]'::jsonb\n )\n FROM privacy_retention.g014_catalog_contract_manifest AS manifest\n ) IS DISTINCT FROM (\n SELECT snapshot.row_value\n FROM pg_temp.g014_feature_manifest_before AS snapshot\n ) THEN\n RAISE EXCEPTION 'G014_FEATURE_RPC_CATALOG_MANIFEST_DRIFT';\n END IF;\nEND;\n$manifest_readback$", + "RESET ROLE", + "DO $restore_workflow_owner_membership$\nDECLARE\n self_membership record;\nBEGIN\n SELECT * INTO self_membership\n FROM pg_temp.g014_feature_self_membership_before;\n\n IF NOT FOUND THEN\n REVOKE privacy_workflow_owner FROM postgres GRANTED BY postgres;\n ELSE\n EXECUTE pg_catalog.format(\n 'GRANT privacy_workflow_owner TO postgres WITH ADMIN FALSE, INHERIT %s, SET %s GRANTED BY postgres',\n pg_catalog.upper(self_membership.inherit_option::text),\n pg_catalog.upper(self_membership.set_option::text)\n );\n END IF;\nEND;\n$restore_workflow_owner_membership$", + "SELECT pg_temp.g014_feature_rpc_assert()", + "DO $readback$\nDECLARE\n required record;\n wrapper_oid oid;\n helper_oid oid;\n wrapper_row pg_catalog.pg_proc%ROWTYPE;\n helper_row pg_catalog.pg_proc%ROWTYPE;\n helper_signature text;\n generation_trigger_oid oid := pg_catalog.to_regprocedure(\n 'ocr_private.register_review_ocr_generation()'\n );\n archive_guard_oid oid := pg_catalog.to_regprocedure(\n 'public.storyboard_production_reject_archived_write()'\n );\nBEGIN\n IF pg_catalog.to_regprocedure('pg_temp.g014_feature_rpc_assert()') IS NOT NULL THEN\n RAISE EXCEPTION 'G014_FEATURE_RPC_ASSERT_BRIDGE_REMAINING';\n END IF;\n\n IF EXISTS (\n (\n SELECT row_value FROM pg_temp.g014_feature_membership_before\n EXCEPT\n SELECT pg_catalog.to_jsonb(membership)\n FROM pg_catalog.pg_auth_members AS membership\n )\n UNION ALL\n (\n SELECT pg_catalog.to_jsonb(membership)\n FROM pg_catalog.pg_auth_members AS membership\n EXCEPT\n SELECT row_value FROM pg_temp.g014_feature_membership_before\n )\n ) THEN\n RAISE EXCEPTION 'G014_FEATURE_RPC_MEMBERSHIP_DRIFT';\n END IF;\n\n FOR required IN\n SELECT * FROM pg_temp.g014_feature_rpc_registry ORDER BY source_signature\n LOOP\n wrapper_oid := pg_catalog.to_regprocedure(required.source_signature);\n helper_signature := 'ocr_private.' || pg_catalog.substr(\n required.source_signature,\n pg_catalog.length('public.') + 1\n );\n helper_oid := pg_catalog.to_regprocedure(helper_signature);\n\n SELECT procedure.* INTO wrapper_row\n FROM pg_catalog.pg_proc AS procedure\n WHERE procedure.oid = wrapper_oid;\n SELECT procedure.* INTO helper_row\n FROM pg_catalog.pg_proc AS procedure\n WHERE procedure.oid = helper_oid;\n\n IF wrapper_oid IS NULL\n OR wrapper_row.proowner <> 'privacy_workflow_owner'::regrole\n OR NOT wrapper_row.prosecdef\n OR wrapper_row.proconfig IS DISTINCT FROM ARRAY['search_path=\"\"']::text[]\n OR wrapper_row.prokind <> 'f'\n OR wrapper_row.provolatile <> required.expected_volatility\n OR wrapper_row.prolang <> (\n SELECT language.oid FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'sql'\n )\n OR wrapper_row.prorettype <> required.expected_return_type\n OR wrapper_row.proretset IS DISTINCT FROM required.expected_set\n OR NOT pg_catalog.has_function_privilege('service_role', wrapper_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('anon', wrapper_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('authenticated', wrapper_oid, 'EXECUTE')\n OR (\n SELECT pg_catalog.count(*)\n FROM privacy_retention.g014_public_rpc_allowlist AS allowed\n WHERE allowed.source_signature = required.source_signature\n AND allowed.grantee = 'service_role'\n AND allowed.function_schema = 'public'\n AND allowed.function_name = (\n SELECT procedure.proname\n FROM pg_catalog.pg_proc AS procedure\n WHERE procedure.oid = wrapper_oid\n )\n AND allowed.identity_arguments = (\n SELECT procedure.proargtypes::text\n FROM pg_catalog.pg_proc AS procedure\n WHERE procedure.oid = wrapper_oid\n )\n ) <> 1\n OR helper_oid IS NULL\n OR helper_row.proowner <> 'postgres'::regrole\n OR NOT helper_row.prosecdef\n OR helper_row.proconfig IS DISTINCT FROM ARRAY['search_path=\"\"']::text[]\n OR helper_row.prokind <> 'f'\n OR helper_row.provolatile <> required.expected_volatility\n OR helper_row.prolang <> (\n SELECT language.oid FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n OR helper_row.prorettype <> required.expected_return_type\n OR helper_row.proretset IS DISTINCT FROM required.expected_set\n OR NOT pg_catalog.has_function_privilege('privacy_workflow_owner', helper_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('service_role', helper_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('anon', helper_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('authenticated', helper_oid, 'EXECUTE')\n OR (\n SELECT pg_catalog.count(*)\n FROM privacy_retention.g014_nested_helper_allowlist AS allowed\n WHERE allowed.source_signature = helper_signature\n ) <> 1 THEN\n RAISE EXCEPTION 'G014_FEATURE_RPC_READBACK_DRIFT: %', required.source_signature;\n END IF;\n END LOOP;\n\n SELECT procedure.* INTO helper_row\n FROM pg_catalog.pg_proc AS procedure\n WHERE procedure.oid = generation_trigger_oid;\n IF generation_trigger_oid IS NULL\n OR helper_row.proowner <> 'postgres'::regrole\n OR NOT helper_row.prosecdef\n OR helper_row.proconfig IS DISTINCT FROM ARRAY['search_path=\"\"']::text[]\n OR helper_row.prokind <> 'f'\n OR helper_row.prorettype <> 'trigger'::regtype\n OR helper_row.proretset\n OR helper_row.prolang <> (\n SELECT language.oid FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n OR pg_catalog.has_function_privilege('anon', generation_trigger_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('authenticated', generation_trigger_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('service_role', generation_trigger_oid, 'EXECUTE')\n OR (\n SELECT pg_catalog.count(*)\n FROM privacy_retention.g014_nested_helper_allowlist AS allowed\n WHERE allowed.source_signature = 'ocr_private.register_review_ocr_generation()'\n ) <> 1\n OR (\n SELECT pg_catalog.count(*)\n FROM pg_catalog.pg_trigger AS trigger_row\n WHERE trigger_row.tgfoid = generation_trigger_oid\n AND trigger_row.tgrelid = 'public.reviews'::regclass\n AND trigger_row.tgname IN (\n 'review_ocr_generation_on_insert',\n 'review_ocr_generation_on_reset'\n )\n AND trigger_row.tgenabled = 'O'\n AND NOT trigger_row.tgisinternal\n ) <> 2 THEN\n RAISE EXCEPTION 'G014_FEATURE_GENERATION_TRIGGER_READBACK_DRIFT';\n END IF;\n\n IF pg_catalog.has_function_privilege('service_role', archive_guard_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('anon', archive_guard_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('authenticated', archive_guard_oid, 'EXECUTE')\n OR EXISTS (\n SELECT 1\n FROM privacy_retention.g014_public_rpc_allowlist AS allowed\n WHERE allowed.source_signature = 'public.storyboard_production_reject_archived_write()'\n OR (\n allowed.function_schema = 'public'\n AND allowed.function_name = 'storyboard_production_reject_archived_write'\n )\n ) THEN\n RAISE EXCEPTION 'G014_FEATURE_TRIGGER_READBACK_DRIFT';\n END IF;\nEND;\n$readback$", + "COMMIT" + ], + "path": "backend/supabase/migrations/20261013190100_g014_feature_rpc_catalog_contract.sql", + "sha256": "0c3f8b4cadc1d4bb0d88b53de49be34ef8e2751a47bfc18ebad89ae543b4c370", + "statementCount": 41, + "statementVectorSha256": "aee042456f1563e139c88f28b188e367bc5ba1a2931d592036c59b34322c61fe", + "version": "20261013190100" + }, + { + "bytes": 14358, + "id": "review_verification_immutable_key_fence", + "name": "review_verification_immutable_key_fence", + "originalStatementVector": [ + "-- Supabase CLI 2.117.0 generated this migration as\n-- 20261013113348_review_verification_immutable_key_fence.sql. Before its first\n-- application it was renamed so it sorts after the durable OCR result contract\n-- (20261013190000) and its G014 RPC catalog integration (20261013190100).\n--\n-- Supabase Storage uploads first test the caller's RLS permission, write a\n-- versioned object to the provider, and then complete the metadata transaction\n-- as a privileged database actor. The restrictive policy rejects ordinary\n-- authenticated UPDATE permission before an upsert can reach the provider. The\n-- UPDATE trigger rejects privileged metadata replacement paths that bypass RLS.\n-- DELETE stays available only for unreferenced canonical verification objects:\n-- the delete trigger shares the review mutation lock and writes the durable\n-- retirement tombstone before metadata disappears. This preserves owner,\n-- guarded-admin and account-deletion cleanup after their review reference is\n-- gone while preventing a later review from reattaching the deleted key. This\n-- does not claim to lock provider bytes against credentials that write directly\n-- to S3; those credentials remain an operating authority boundary.\nBEGIN", + "SET LOCAL lock_timeout = '2s'", + "SELECT pg_catalog.pg_advisory_xact_lock(\n pg_catalog.hashtextextended('tzudong:review-verification-immutable-key:v1', 0)\n)", + "CREATE TEMP TABLE review_verification_lease (\n pg_major integer NOT NULL,\n schema_lease boolean NOT NULL,\n pg15_membership_lease boolean NOT NULL,\n self_membership jsonb,\n before_members jsonb NOT NULL,\n before_schema jsonb NOT NULL\n) ON COMMIT DROP", + "DO $prepare$\nDECLARE\n workflow_owner oid := 'privacy_workflow_owner'::regrole;\n runner oid := 'postgres'::regrole;\n pg_major integer := current_setting('server_version_num')::integer / 10000;\n schema_lease boolean;\n pg15_membership_lease boolean := false;\n self_membership jsonb;\n before_members jsonb;\n before_schema jsonb;\nBEGIN\n IF current_user <> 'postgres'\n OR session_user <> 'postgres'\n OR pg_major NOT IN (15, 17)\n OR current_setting('transaction_read_only') <> 'off'\n OR to_regclass('storage.objects') IS NULL\n OR to_regnamespace('review_media_private') IS NULL\n OR NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_roles\n WHERE oid = workflow_owner\n AND NOT rolsuper\n AND NOT rolbypassrls\n AND NOT rolcanlogin\n AND NOT rolinherit\n )\n THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_IMMUTABLE_EXECUTOR_DENIED';\n END IF;\n\n IF NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_class c\n WHERE c.oid = 'storage.objects'::regclass\n AND c.relowner = 'supabase_storage_admin'::regrole\n AND c.relrowsecurity\n AND NOT c.relforcerowsecurity\n ) THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_STORAGE_CATALOG_DRIFT';\n END IF;\n\n IF NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_policy p\n WHERE p.polrelid = 'storage.objects'::regclass\n AND p.polname = 'review_media_safe_update'\n AND p.polcmd = 'w'\n AND NOT p.polpermissive\n AND p.polroles = ARRAY['authenticated'::regrole::oid]\n ) THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_UPDATE_POLICY_BASELINE_DRIFT';\n END IF;\n\n IF NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_policy p\n WHERE p.polrelid = 'storage.objects'::regclass\n AND p.polname = 'review_verifications_owner_delete'\n AND p.polcmd = 'd'\n AND p.polpermissive\n AND p.polroles = ARRAY['authenticated'::regrole::oid]\n AND pg_catalog.pg_get_expr(p.polqual, p.polrelid) =\n '((bucket_id = ''review-verifications''::text) AND ((storage.foldername(name))[1] = ( SELECT (auth.uid())::text AS uid)))'\n ) OR NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_policy p\n WHERE p.polrelid = 'storage.objects'::regclass\n AND p.polname = 'review_verifications_safe_delete'\n AND p.polcmd = 'd'\n AND NOT p.polpermissive\n AND p.polroles = ARRAY['authenticated'::regrole::oid]\n AND pg_catalog.pg_get_expr(p.polqual, p.polrelid) =\n '((bucket_id <> ''review-verifications''::text) OR review_media_delete_allowed(name))'\n ) THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_DELETE_POLICY_BASELINE_DRIFT';\n END IF;\n\n SELECT coalesce(pg_catalog.jsonb_agg(pg_catalog.to_jsonb(m) ORDER BY roleid, member, grantor), '[]'::jsonb)\n INTO before_members\n FROM pg_catalog.pg_auth_members m;\n\n SELECT pg_catalog.to_jsonb(n)\n INTO before_schema\n FROM pg_catalog.pg_namespace n\n WHERE n.oid = 'review_media_private'::regnamespace;\n\n schema_lease := NOT pg_catalog.has_schema_privilege(\n workflow_owner,\n 'review_media_private',\n 'CREATE'\n );\n IF schema_lease THEN\n GRANT CREATE ON SCHEMA review_media_private TO privacy_workflow_owner;\n END IF;\n\n SELECT pg_catalog.to_jsonb(m)\n INTO self_membership\n FROM pg_catalog.pg_auth_members m\n WHERE m.roleid = workflow_owner\n AND m.member = runner\n AND m.grantor = runner;\n\n IF self_membership IS NOT NULL\n AND (self_membership->>'admin_option')::boolean\n THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_SELF_MEMBERSHIP_DENIED';\n END IF;\n\n IF pg_major = 17 THEN\n IF NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_auth_members m\n WHERE m.roleid = workflow_owner\n AND m.member = runner\n AND m.admin_option\n AND m.grantor <> runner\n ) THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_OWNER_AUTHORITY_MISSING';\n END IF;\n EXECUTE 'GRANT privacy_workflow_owner TO postgres WITH ADMIN FALSE, INHERIT FALSE, SET TRUE GRANTED BY postgres';\n ELSE\n pg15_membership_lease := NOT pg_catalog.pg_has_role(runner, workflow_owner, 'MEMBER');\n IF pg15_membership_lease THEN\n GRANT privacy_workflow_owner TO postgres;\n END IF;\n END IF;\n\n INSERT INTO pg_temp.review_verification_lease(\n pg_major,\n schema_lease,\n pg15_membership_lease,\n self_membership,\n before_members,\n before_schema\n ) VALUES (\n pg_major,\n schema_lease,\n pg15_membership_lease,\n self_membership,\n before_members,\n before_schema\n );\nEND;\n$prepare$", + "SET LOCAL ROLE privacy_workflow_owner", + "CREATE FUNCTION review_media_private.reject_verification_object_update()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY INVOKER\nSET search_path = ''\nAS $function$\nBEGIN\n IF TG_OP <> 'UPDATE'\n OR TG_TABLE_SCHEMA <> 'storage'\n OR TG_TABLE_NAME <> 'objects'\n THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_TRIGGER_CONTEXT_INVALID';\n END IF;\n\n IF OLD.bucket_id = 'review-verifications'\n OR NEW.bucket_id = 'review-verifications'\n THEN\n RAISE EXCEPTION USING\n MESSAGE = 'REVIEW_VERIFICATION_OBJECT_IMMUTABLE',\n ERRCODE = '23514';\n END IF;\n\n RETURN NEW;\nEND;\n$function$", + "REVOKE ALL ON FUNCTION review_media_private.reject_verification_object_update()\n FROM PUBLIC, anon, authenticated, service_role, supabase_storage_admin", + "GRANT EXECUTE ON FUNCTION review_media_private.reject_verification_object_update()\n TO postgres", + "CREATE FUNCTION review_media_private.guard_verification_object_delete()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $function$\nDECLARE\n object_owner uuid;\n object_review uuid;\n conflicting_tombstone boolean;\nBEGIN\n IF TG_OP <> 'DELETE'\n OR TG_TABLE_SCHEMA <> 'storage'\n OR TG_TABLE_NAME <> 'objects'\n THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_DELETE_TRIGGER_CONTEXT_INVALID';\n END IF;\n\n IF OLD.bucket_id <> 'review-verifications' THEN\n RETURN OLD;\n END IF;\n\n -- The review INSERT/UPDATE/DELETE statement trigger and the authenticated\n -- delete policy use this same transaction lock. Both race orders therefore\n -- finish with either a live reference plus object, or a retired object key\n -- that review_media_private.guard_references() refuses to reattach.\n PERFORM pg_catalog.pg_advisory_xact_lock(741093, 1);\n\n IF review_media_private.referenced(OLD.name) THEN\n RAISE EXCEPTION USING\n MESSAGE = 'REVIEW_VERIFICATION_OBJECT_REFERENCED',\n ERRCODE = '23503';\n END IF;\n\n BEGIN\n object_owner := pg_catalog.split_part(OLD.name, '/', 1)::uuid;\n object_review := pg_catalog.split_part(OLD.name, '/', 3)::uuid;\n EXCEPTION WHEN invalid_text_representation THEN\n RAISE EXCEPTION USING\n MESSAGE = 'REVIEW_VERIFICATION_OBJECT_DELETE_UNBOUND',\n ERRCODE = '23514';\n END;\n\n IF NOT review_media_private.canonical(\n OLD.name,\n object_owner,\n object_review,\n 'verification'\n ) THEN\n RAISE EXCEPTION USING\n MESSAGE = 'REVIEW_VERIFICATION_OBJECT_DELETE_UNBOUND',\n ERRCODE = '23514';\n END IF;\n\n SELECT EXISTS (\n SELECT 1\n FROM review_media_private.cleanup c\n WHERE c.path = OLD.name\n AND (\n c.owner_id IS DISTINCT FROM object_owner\n OR c.review_id IS DISTINCT FROM object_review\n OR c.purpose IS DISTINCT FROM 'verification'\n )\n ) INTO conflicting_tombstone;\n\n IF conflicting_tombstone THEN\n RAISE EXCEPTION USING\n MESSAGE = 'REVIEW_VERIFICATION_OBJECT_TOMBSTONE_CONFLICT',\n ERRCODE = '23514';\n END IF;\n\n INSERT INTO review_media_private.cleanup(\n path,\n owner_id,\n review_id,\n purpose,\n retired,\n complete\n ) VALUES (\n OLD.name,\n object_owner,\n object_review,\n 'verification',\n true,\n false\n )\n ON CONFLICT (path) DO UPDATE\n SET retired = true,\n complete = false;\n\n RETURN OLD;\nEND;\n$function$", + "REVOKE ALL ON FUNCTION review_media_private.guard_verification_object_delete()\n FROM PUBLIC, anon, authenticated, service_role, supabase_storage_admin", + "GRANT EXECUTE ON FUNCTION review_media_private.guard_verification_object_delete()\n TO postgres", + "RESET ROLE", + "CREATE POLICY review_verifications_immutable_update\n ON storage.objects\n AS RESTRICTIVE\n FOR UPDATE\n TO authenticated\n USING (bucket_id <> 'review-verifications')\n WITH CHECK (bucket_id <> 'review-verifications')", + "CREATE TRIGGER a01_review_verification_immutable_update\n BEFORE UPDATE ON storage.objects\n FOR EACH ROW\n EXECUTE FUNCTION review_media_private.reject_verification_object_update()", + "CREATE TRIGGER a01_review_verification_guard_delete\n BEFORE DELETE ON storage.objects\n FOR EACH ROW\n EXECUTE FUNCTION review_media_private.guard_verification_object_delete()", + "SET LOCAL ROLE privacy_workflow_owner", + "REVOKE EXECUTE ON FUNCTION review_media_private.reject_verification_object_update()\n FROM postgres", + "REVOKE EXECUTE ON FUNCTION review_media_private.guard_verification_object_delete()\n FROM postgres", + "RESET ROLE", + "DO $restore$\nDECLARE\n lease pg_temp.review_verification_lease%ROWTYPE;\nBEGIN\n SELECT * INTO STRICT lease FROM pg_temp.review_verification_lease;\n\n IF lease.schema_lease THEN\n REVOKE CREATE ON SCHEMA review_media_private FROM privacy_workflow_owner;\n END IF;\n\n IF lease.pg_major = 15 THEN\n IF lease.pg15_membership_lease THEN\n REVOKE privacy_workflow_owner FROM postgres;\n END IF;\n ELSIF lease.self_membership IS NULL THEN\n REVOKE privacy_workflow_owner FROM postgres GRANTED BY postgres;\n ELSE\n EXECUTE pg_catalog.format(\n 'GRANT privacy_workflow_owner TO postgres WITH ADMIN FALSE, INHERIT %s, SET %s GRANTED BY postgres',\n pg_catalog.upper(lease.self_membership->>'inherit_option'),\n pg_catalog.upper(lease.self_membership->>'set_option')\n );\n END IF;\n\n IF (\n SELECT coalesce(pg_catalog.jsonb_agg(pg_catalog.to_jsonb(m) ORDER BY roleid, member, grantor), '[]'::jsonb)\n FROM pg_catalog.pg_auth_members m\n ) IS DISTINCT FROM lease.before_members\n OR (\n SELECT pg_catalog.to_jsonb(n)\n FROM pg_catalog.pg_namespace n\n WHERE n.oid = 'review_media_private'::regnamespace\n ) IS DISTINCT FROM lease.before_schema\n THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_LEASE_PRESERVATION_DRIFT';\n END IF;\nEND;\n$restore$", + "DO $readback$\nBEGIN\n IF NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_policy p\n WHERE p.polrelid = 'storage.objects'::regclass\n AND p.polname = 'review_verifications_immutable_update'\n AND p.polcmd = 'w'\n AND NOT p.polpermissive\n AND p.polroles = ARRAY['authenticated'::regrole::oid]\n AND pg_catalog.pg_get_expr(p.polqual, p.polrelid) = '(bucket_id <> ''review-verifications''::text)'\n AND pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid) = '(bucket_id <> ''review-verifications''::text)'\n )\n OR NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_trigger t\n JOIN pg_catalog.pg_proc p ON p.oid = t.tgfoid\n WHERE t.tgrelid = 'storage.objects'::regclass\n AND t.tgname = 'a01_review_verification_immutable_update'\n AND t.tgenabled = 'O'\n AND NOT t.tgisinternal\n AND t.tgtype = 19\n AND p.oid = 'review_media_private.reject_verification_object_update()'::regprocedure\n AND NOT p.prosecdef\n AND p.proowner = 'privacy_workflow_owner'::regrole\n AND p.proconfig = ARRAY['search_path=\"\"']::text[]\n )\n OR NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_trigger t\n JOIN pg_catalog.pg_proc p ON p.oid = t.tgfoid\n WHERE t.tgrelid = 'storage.objects'::regclass\n AND t.tgname = 'a01_review_verification_guard_delete'\n AND t.tgenabled = 'O'\n AND NOT t.tgisinternal\n AND t.tgtype = 11\n AND p.oid = 'review_media_private.guard_verification_object_delete()'::regprocedure\n AND p.prosecdef\n AND p.proowner = 'privacy_workflow_owner'::regrole\n AND p.proconfig = ARRAY['search_path=\"\"']::text[]\n )\n OR EXISTS (\n SELECT 1\n FROM pg_catalog.unnest(ARRAY[\n 'anon',\n 'authenticated',\n 'service_role',\n 'supabase_storage_admin',\n 'postgres'\n ]) role_name\n WHERE pg_catalog.has_function_privilege(\n role_name,\n 'review_media_private.reject_verification_object_update()',\n 'EXECUTE'\n )\n OR pg_catalog.has_function_privilege(\n role_name,\n 'review_media_private.guard_verification_object_delete()',\n 'EXECUTE'\n )\n )\n THEN\n RAISE EXCEPTION 'REVIEW_VERIFICATION_IMMUTABLE_READBACK_DRIFT';\n END IF;\nEND;\n$readback$", + "-- The owner-scoped runtime probe remains held through stages 97-100. Activate\n-- it only after this final immutable-key fence and its readback both succeed.\nINSERT INTO ocr_private.feature_runtime_capability (\n capability_version,\n schema_version\n) VALUES (\n 'review-media-feature-v1',\n 1\n)", + "COMMIT" + ], + "path": "backend/supabase/migrations/20261013190200_review_verification_immutable_key_fence.sql", + "sha256": "0a060ca537d3558779fd0cb1b780e7965189eb129f5018987f157c1cb5f2cd3d", + "statementCount": 24, + "statementVectorSha256": "3d5094435176466fc265dccdd7c26f93852c7151b1e84a96d5527d788b918c7f", + "version": "20261013190200" + }, + { + "bytes": 19008, + "id": "review_verification_delete_guard_readiness", + "name": "review_verification_delete_guard_readiness", + "originalStatementVector": [ + "-- Forward-only successor for an already applied durable OCR migration.\n-- The earlier migrations remain immutable. This replacement tightens only the\n-- private runtime capability predicate so drift in either Storage metadata\n-- fence returns FEATURE_DATABASE_HELD before any admin mutation is admitted.\nBEGIN", + "SET LOCAL lock_timeout = '2s'", + "SELECT pg_catalog.pg_advisory_xact_lock(\n pg_catalog.hashtextextended('tzudong:feature-runtime-storage-fence:v1', 0)\n)", + "DO $prerequisites$\nDECLARE\n capability_oid oid := pg_catalog.to_regprocedure(\n 'ocr_private.get_feature_runtime_capability(uuid)'\n );\nBEGIN\n IF current_user <> 'postgres'\n OR session_user <> 'postgres'\n OR pg_catalog.current_setting('server_version_num')::integer / 10000 <> 17\n OR pg_catalog.current_setting('transaction_read_only') <> 'off'\n OR capability_oid IS NULL\n OR pg_catalog.to_regprocedure(\n 'public.get_feature_runtime_capability(uuid)'\n ) IS NULL\n THEN\n RAISE EXCEPTION 'FEATURE_RUNTIME_STORAGE_FENCE_PREREQUISITE_MISSING';\n END IF;\n\n IF NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_proc AS function_row\n WHERE function_row.oid = capability_oid\n AND function_row.proowner = 'postgres'::pg_catalog.regrole\n AND function_row.prosecdef\n AND function_row.proconfig = ARRAY['search_path=\"\"']::text[]\n AND function_row.prokind = 'f'\n AND function_row.provolatile = 's'\n AND NOT function_row.proretset\n AND function_row.prorettype = 'jsonb'::pg_catalog.regtype\n AND function_row.prolang = (\n SELECT language.oid\n FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n ) THEN\n RAISE EXCEPTION 'FEATURE_RUNTIME_STORAGE_FENCE_CAPABILITY_DRIFT';\n END IF;\n\n IF NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_trigger AS trigger_row\n JOIN pg_catalog.pg_proc AS function_row\n ON function_row.oid = trigger_row.tgfoid\n WHERE trigger_row.tgrelid = 'storage.objects'::pg_catalog.regclass\n AND trigger_row.tgname = 'a01_review_verification_immutable_update'\n AND trigger_row.tgenabled = 'O'\n AND NOT trigger_row.tgisinternal\n AND trigger_row.tgtype = 19\n AND function_row.oid =\n 'review_media_private.reject_verification_object_update()'::pg_catalog.regprocedure\n AND pg_catalog.encode(\n pg_catalog.sha256(pg_catalog.convert_to(function_row.prosrc, 'UTF8')),\n 'hex'\n ) = 'aa1d4951b8d5fdd15249b090c6d6efba897f39eaceee86bf09221eb1d942d545'\n ) OR NOT EXISTS (\n SELECT 1\n FROM pg_catalog.pg_trigger AS trigger_row\n JOIN pg_catalog.pg_proc AS function_row\n ON function_row.oid = trigger_row.tgfoid\n WHERE trigger_row.tgrelid = 'storage.objects'::pg_catalog.regclass\n AND trigger_row.tgname = 'a01_review_verification_guard_delete'\n AND trigger_row.tgenabled = 'O'\n AND NOT trigger_row.tgisinternal\n AND trigger_row.tgtype = 11\n AND function_row.oid =\n 'review_media_private.guard_verification_object_delete()'::pg_catalog.regprocedure\n AND pg_catalog.encode(\n pg_catalog.sha256(pg_catalog.convert_to(function_row.prosrc, 'UTF8')),\n 'hex'\n ) = '24311fc7071597fbc78c4c54897690da5aaa70edb62c24f7912fd23fecf515b0'\n ) THEN\n RAISE EXCEPTION 'FEATURE_RUNTIME_STORAGE_FENCE_SOURCE_DRIFT';\n END IF;\nEND;\n$prerequisites$", + "CREATE OR REPLACE FUNCTION ocr_private.get_feature_runtime_capability(p_user_id uuid)\nRETURNS jsonb\nLANGUAGE plpgsql\nSTABLE\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_ready boolean;\n v_contracts_ready boolean;\n v_review_ocr_columns_ready boolean;\n v_review_ocr_indexes_ready boolean;\n v_trigger_helper_oid oid := pg_catalog.to_regprocedure(\n 'ocr_private.register_review_ocr_generation()'\n );\nBEGIN\n IF p_user_id IS NULL OR NOT EXISTS (\n SELECT 1 FROM auth.users AS user_row WHERE user_row.id = p_user_id\n ) THEN\n RETURN pg_catalog.jsonb_build_object(\n 'schema_version', 1,\n 'status', 'held',\n 'code', 'FEATURE_DATABASE_HELD',\n 'capability_version', 'review-media-feature-v1'\n );\n END IF;\n\n SELECT NOT EXISTS (\n SELECT 1\n FROM (\n VALUES\n ('public.claim_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text,uuid,boolean)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.mark_receipt_ocr_dispatched(uuid,uuid)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.complete_receipt_ocr_result(uuid,uuid,jsonb)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.mark_receipt_ocr_result_indeterminate(uuid,uuid)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.release_receipt_ocr_claim(uuid,uuid)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.read_receipt_ocr_result(uuid,text,text,text,jsonb,uuid,text,text,text)', 'jsonb'::pg_catalog.regtype, false, 's'::\"char\"),\n ('public.get_review_receipt_ocr_generation(uuid,uuid)', 'jsonb'::pg_catalog.regtype, false, 's'::\"char\"),\n ('public.publish_review_receipt_ocr_result(uuid,uuid,uuid,uuid,uuid,text,jsonb,boolean)', 'jsonb'::pg_catalog.regtype, false, 'v'::\"char\"),\n ('public.get_feature_runtime_capability(uuid)', 'jsonb'::pg_catalog.regtype, false, 's'::\"char\"),\n ('public.get_owned_review_storage_objects(uuid,uuid,text,text[])', 'record'::pg_catalog.regtype, true, 'v'::\"char\"),\n ('public.create_review_with_receipt_ocr(uuid,uuid,uuid,text,text,timestamptz,text,text[],text[],uuid,uuid,uuid,text,timestamptz,text,jsonb)', 'uuid'::pg_catalog.regtype, false, 'v'::\"char\")\n ) AS required(signature, return_type, returns_set, volatility)\n LEFT JOIN pg_catalog.pg_proc AS procedure_row\n ON procedure_row.oid = pg_catalog.to_regprocedure(required.signature)\n WHERE procedure_row.oid IS NULL\n OR procedure_row.proowner <> 'privacy_workflow_owner'::pg_catalog.regrole\n OR NOT procedure_row.prosecdef\n OR procedure_row.proconfig IS DISTINCT FROM ARRAY['search_path=\"\"']::text[]\n OR procedure_row.prokind <> 'f'\n OR procedure_row.provolatile <> required.volatility\n OR procedure_row.prorettype <> required.return_type\n OR procedure_row.proretset IS DISTINCT FROM required.returns_set\n OR procedure_row.prolang <> (\n SELECT language.oid\n FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'sql'\n )\n OR NOT pg_catalog.has_function_privilege('service_role', procedure_row.oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('anon', procedure_row.oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege('authenticated', procedure_row.oid, 'EXECUTE')\n ) INTO v_contracts_ready;\n\n -- Keep the runtime decision tied to the same exact review-column contract\n -- that the migration and successor controller admit. A later same-name\n -- column with a different type, default, nullability, identity or generated\n -- shape must put the feature back on hold.\n SELECT pg_catalog.count(*) = 4\n AND pg_catalog.bool_and(\n NOT attribute.attnotnull\n AND attribute.atttypmod = -1\n AND attribute.attidentity = ''\n AND attribute.attgenerated = ''\n AND CASE attribute.attname\n WHEN 'receipt_hash' THEN\n attribute.atttypid = 'text'::pg_catalog.regtype\n AND default_value.oid IS NULL\n WHEN 'receipt_data' THEN\n attribute.atttypid = 'jsonb'::pg_catalog.regtype\n AND default_value.oid IS NULL\n WHEN 'is_duplicate' THEN\n attribute.atttypid = 'boolean'::pg_catalog.regtype\n AND pg_catalog.pg_get_expr(\n default_value.adbin,\n default_value.adrelid\n ) = 'false'\n WHEN 'ocr_processed_at' THEN\n attribute.atttypid = 'timestamp with time zone'::pg_catalog.regtype\n AND default_value.oid IS NULL\n ELSE false\n END\n )\n INTO v_review_ocr_columns_ready\n FROM pg_catalog.pg_attribute AS attribute\n LEFT JOIN pg_catalog.pg_attrdef AS default_value\n ON default_value.adrelid = attribute.attrelid\n AND default_value.adnum = attribute.attnum\n WHERE attribute.attrelid = 'public.reviews'::regclass\n AND attribute.attnum > 0\n AND NOT attribute.attisdropped\n AND attribute.attname = ANY (ARRAY[\n 'receipt_hash',\n 'receipt_data',\n 'is_duplicate',\n 'ocr_processed_at'\n ]::name[]);\n\n -- Index identity is not enough: method, key, predicate and online-validity\n -- flags are all part of the runtime contract.\n SELECT pg_catalog.count(*) = 3\n AND pg_catalog.bool_and(\n index_relation.relkind = 'i'\n AND index_row.indrelid = 'public.reviews'::regclass\n AND access_method.amname = 'btree'\n AND index_row.indnkeyatts = 1\n AND index_row.indnatts = 1\n AND index_row.indexprs IS NULL\n AND index_row.indisvalid\n AND index_row.indisready\n AND index_row.indislive\n AND NOT index_row.indisprimary\n AND NOT index_row.indisexclusion\n AND CASE index_relation.relname\n WHEN 'idx_reviews_receipt_hash' THEN\n index_row.indisunique\n AND pg_catalog.pg_get_indexdef(index_relation.oid, 1, true) = 'receipt_hash'\n AND pg_catalog.pg_get_expr(\n index_row.indpred,\n index_row.indrelid\n ) = '(receipt_hash IS NOT NULL)'\n WHEN 'reviews_ocr_pending_idx' THEN\n NOT index_row.indisunique\n AND pg_catalog.pg_get_indexdef(index_relation.oid, 1, true) = 'ocr_processed_at'\n AND pg_catalog.pg_get_expr(\n index_row.indpred,\n index_row.indrelid\n ) = '(verification_photo IS NOT NULL)'\n WHEN 'reviews_duplicate_idx' THEN\n NOT index_row.indisunique\n AND pg_catalog.pg_get_indexdef(index_relation.oid, 1, true) = 'is_duplicate'\n AND index_row.indpred IS NULL\n ELSE false\n END\n )\n INTO v_review_ocr_indexes_ready\n FROM pg_catalog.pg_class AS index_relation\n JOIN pg_catalog.pg_namespace AS namespace\n ON namespace.oid = index_relation.relnamespace\n JOIN pg_catalog.pg_index AS index_row\n ON index_row.indexrelid = index_relation.oid\n JOIN pg_catalog.pg_am AS access_method\n ON access_method.oid = index_relation.relam\n WHERE namespace.nspname = 'public'\n AND index_relation.relname = ANY (ARRAY[\n 'idx_reviews_receipt_hash',\n 'reviews_ocr_pending_idx',\n 'reviews_duplicate_idx'\n ]::name[]);\n\n SELECT EXISTS (\n SELECT 1\n FROM ocr_private.feature_runtime_capability AS capability\n WHERE capability.capability_version = 'review-media-feature-v1'\n AND capability.schema_version = 1\n )\n AND v_contracts_ready\n AND coalesce(v_review_ocr_columns_ready, false)\n AND coalesce(v_review_ocr_indexes_ready, false)\n AND pg_catalog.to_regprocedure('public.storyboard_production_admin(uuid,text,uuid,integer,jsonb)') IS NOT NULL\n AND pg_catalog.to_regprocedure('public.storyboard_production_worker(uuid,text,uuid,uuid,jsonb)') IS NOT NULL\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_class AS table_row\n WHERE table_row.oid = 'ocr_private.receipt_results'::pg_catalog.regclass\n AND table_row.relowner = 'postgres'::pg_catalog.regrole\n AND table_row.relrowsecurity\n AND table_row.relforcerowsecurity\n )\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_class AS table_row\n WHERE table_row.oid = 'ocr_private.review_generations'::pg_catalog.regclass\n AND table_row.relowner = 'postgres'::pg_catalog.regrole\n AND table_row.relrowsecurity\n AND table_row.relforcerowsecurity\n )\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_proc AS helper\n WHERE helper.oid = v_trigger_helper_oid\n AND helper.proowner = 'postgres'::pg_catalog.regrole\n AND helper.prosecdef\n AND helper.proconfig = ARRAY['search_path=\"\"']::text[]\n AND helper.prorettype = 'trigger'::pg_catalog.regtype\n AND helper.prolang = (\n SELECT language.oid\n FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n AND NOT pg_catalog.has_function_privilege('anon', helper.oid, 'EXECUTE')\n AND NOT pg_catalog.has_function_privilege('authenticated', helper.oid, 'EXECUTE')\n AND NOT pg_catalog.has_function_privilege('service_role', helper.oid, 'EXECUTE')\n )\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_trigger AS trigger_row\n WHERE trigger_row.tgrelid = 'public.reviews'::pg_catalog.regclass\n AND trigger_row.tgfoid = v_trigger_helper_oid\n AND trigger_row.tgname IN (\n 'review_ocr_generation_on_insert',\n 'review_ocr_generation_on_reset'\n )\n AND NOT trigger_row.tgisinternal\n GROUP BY trigger_row.tgfoid\n HAVING pg_catalog.count(*) = 2\n )\n -- The metadata fence is operational only while both installed triggers and\n -- their implementation functions retain the exact audited contract. A\n -- disabled, retargeted, redefined or newly exposed helper holds the feature.\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_trigger AS trigger_row\n JOIN pg_catalog.pg_proc AS function_row\n ON function_row.oid = trigger_row.tgfoid\n WHERE trigger_row.tgrelid = 'storage.objects'::pg_catalog.regclass\n AND trigger_row.tgname = 'a01_review_verification_immutable_update'\n AND trigger_row.tgenabled = 'O'\n AND NOT trigger_row.tgisinternal\n AND trigger_row.tgtype = 19\n AND pg_catalog.pg_get_triggerdef(trigger_row.oid, false) =\n 'CREATE TRIGGER a01_review_verification_immutable_update BEFORE UPDATE ON storage.objects FOR EACH ROW EXECUTE FUNCTION review_media_private.reject_verification_object_update()'\n AND function_row.oid =\n 'review_media_private.reject_verification_object_update()'::pg_catalog.regprocedure\n AND NOT function_row.prosecdef\n AND function_row.proowner = 'privacy_workflow_owner'::pg_catalog.regrole\n AND function_row.proconfig = ARRAY['search_path=\"\"']::text[]\n AND function_row.prokind = 'f'\n AND function_row.provolatile = 'v'\n AND NOT function_row.proretset\n AND function_row.prorettype = 'trigger'::pg_catalog.regtype\n AND function_row.prolang = (\n SELECT language.oid\n FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n AND pg_catalog.encode(\n pg_catalog.sha256(pg_catalog.convert_to(function_row.prosrc, 'UTF8')),\n 'hex'\n ) = 'aa1d4951b8d5fdd15249b090c6d6efba897f39eaceee86bf09221eb1d942d545'\n AND NOT EXISTS (\n SELECT 1\n FROM pg_catalog.unnest(ARRAY[\n 'anon',\n 'authenticated',\n 'service_role',\n 'supabase_storage_admin',\n 'postgres'\n ]) AS role_name\n WHERE pg_catalog.has_function_privilege(\n role_name,\n function_row.oid,\n 'EXECUTE'\n )\n )\n )\n AND EXISTS (\n SELECT 1\n FROM pg_catalog.pg_trigger AS trigger_row\n JOIN pg_catalog.pg_proc AS function_row\n ON function_row.oid = trigger_row.tgfoid\n WHERE trigger_row.tgrelid = 'storage.objects'::pg_catalog.regclass\n AND trigger_row.tgname = 'a01_review_verification_guard_delete'\n AND trigger_row.tgenabled = 'O'\n AND NOT trigger_row.tgisinternal\n AND trigger_row.tgtype = 11\n AND pg_catalog.pg_get_triggerdef(trigger_row.oid, false) =\n 'CREATE TRIGGER a01_review_verification_guard_delete BEFORE DELETE ON storage.objects FOR EACH ROW EXECUTE FUNCTION review_media_private.guard_verification_object_delete()'\n AND function_row.oid =\n 'review_media_private.guard_verification_object_delete()'::pg_catalog.regprocedure\n AND function_row.prosecdef\n AND function_row.proowner = 'privacy_workflow_owner'::pg_catalog.regrole\n AND function_row.proconfig = ARRAY['search_path=\"\"']::text[]\n AND function_row.prokind = 'f'\n AND function_row.provolatile = 'v'\n AND NOT function_row.proretset\n AND function_row.prorettype = 'trigger'::pg_catalog.regtype\n AND function_row.prolang = (\n SELECT language.oid\n FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n )\n AND pg_catalog.encode(\n pg_catalog.sha256(pg_catalog.convert_to(function_row.prosrc, 'UTF8')),\n 'hex'\n ) = '24311fc7071597fbc78c4c54897690da5aaa70edb62c24f7912fd23fecf515b0'\n AND NOT EXISTS (\n SELECT 1\n FROM pg_catalog.unnest(ARRAY[\n 'anon',\n 'authenticated',\n 'service_role',\n 'supabase_storage_admin',\n 'postgres'\n ]) AS role_name\n WHERE pg_catalog.has_function_privilege(\n role_name,\n function_row.oid,\n 'EXECUTE'\n )\n )\n )\n INTO v_ready;\n\n RETURN pg_catalog.jsonb_build_object(\n 'schema_version', 1,\n 'status', CASE WHEN v_ready THEN 'ready' ELSE 'held' END,\n 'code', CASE WHEN v_ready THEN 'FEATURE_DATABASE_READY' ELSE 'FEATURE_DATABASE_HELD' END,\n 'capability_version', 'review-media-feature-v1'\n );\nEND;\n$$", + "ALTER FUNCTION ocr_private.get_feature_runtime_capability(uuid)\n OWNER TO postgres", + "REVOKE ALL ON FUNCTION ocr_private.get_feature_runtime_capability(uuid)\n FROM PUBLIC, anon, authenticated, service_role", + "GRANT EXECUTE ON FUNCTION ocr_private.get_feature_runtime_capability(uuid)\n TO privacy_workflow_owner", + "DO $readback$\nDECLARE\n capability_oid oid := pg_catalog.to_regprocedure(\n 'ocr_private.get_feature_runtime_capability(uuid)'\n );\n capability_source text;\nBEGIN\n SELECT function_row.prosrc\n INTO STRICT capability_source\n FROM pg_catalog.pg_proc AS function_row\n WHERE function_row.oid = capability_oid\n AND function_row.proowner = 'postgres'::pg_catalog.regrole\n AND function_row.prosecdef\n AND function_row.proconfig = ARRAY['search_path=\"\"']::text[]\n AND function_row.prokind = 'f'\n AND function_row.provolatile = 's'\n AND NOT function_row.proretset\n AND function_row.prorettype = 'jsonb'::pg_catalog.regtype\n AND function_row.prolang = (\n SELECT language.oid\n FROM pg_catalog.pg_language AS language\n WHERE language.lanname = 'plpgsql'\n );\n\n IF pg_catalog.strpos(\n capability_source,\n 'a01_review_verification_guard_delete'\n ) = 0\n OR pg_catalog.strpos(\n capability_source,\n '24311fc7071597fbc78c4c54897690da5aaa70edb62c24f7912fd23fecf515b0'\n ) = 0\n OR pg_catalog.strpos(\n capability_source,\n 'pg_catalog.pg_get_triggerdef(trigger_row.oid, false)'\n ) = 0\n OR NOT pg_catalog.has_function_privilege(\n 'privacy_workflow_owner',\n capability_oid,\n 'EXECUTE'\n )\n OR pg_catalog.has_function_privilege('anon', capability_oid, 'EXECUTE')\n OR pg_catalog.has_function_privilege(\n 'authenticated',\n capability_oid,\n 'EXECUTE'\n )\n OR pg_catalog.has_function_privilege(\n 'service_role',\n capability_oid,\n 'EXECUTE'\n )\n THEN\n RAISE EXCEPTION 'FEATURE_RUNTIME_STORAGE_FENCE_READBACK_DRIFT';\n END IF;\nEND;\n$readback$", + "COMMIT" + ], + "path": "backend/supabase/migrations/20261016090000_review_verification_delete_guard_readiness.sql", + "sha256": "469dbea773cc838feecb4519ca1b5c03e9ea2fab32462007f7769c5ed1b7fed9", + "statementCount": 10, + "statementVectorSha256": "32e28ac65144f6a0f279ad69e2255c4d8c97c4e71493e0e2e69fd1ad1f551bad", + "version": "20261016090000" + } + ], + "toolchain": [ + { + "path": "apps/web/scripts/review-media-feature-sql-successor.mjs", + "sha256": "8b34863fd9f9355c0534796162320fcd4ab1921b7c038dc0596de146b49be192" + }, + { + "path": "apps/web/scripts/review-media-sql-successor.mjs", + "sha256": "4f3dacb6f51a669bf64209b380b19e8274d2703101fc50f815153237f4a99d93" + }, + { + "path": "apps/web/scripts/supabase-migration-bundle.mjs", + "sha256": "fcebc20dc4846606dc14bdbe658a07daa32430ad632e52383151f5c22d22efb9" + }, + { + "path": "apps/web/scripts/supabase-migration-transaction.mjs", + "sha256": "c67bdff4c63b1bf1b4a157863b3eb5253b5973a8fb54b7fd49351270787bed26" + }, + { + "path": "apps/web/scripts/apply-supabase-migration.mjs", + "sha256": "02c404e0be3e81b7c8d4d7766ff29e750b1797f5ad49b5f496168170e21192d4" + }, + { + "path": "apps/web/scripts/vercel-rollback-readback.mjs", + "sha256": "fa3876153f0e949d23115612cb34bc545c6c29bccb6ba2fd6042a38e70555e04" + } + ], + "requiredWriterFences": { + "adminRecordMutationsHold": "active", + "g037WriteFreeze": "active", + "automaticReviewer": "stopped", + "browserRpc": "stopped", + "directSql": "stopped", + "dashboard": "stopped", + "providerIngress": "stopped" + } +} diff --git a/.github/review-media-sql-successor.v1.json b/.github/review-media-sql-successor.v1.json index d60b3b3dcb..9323a6a522 100644 --- a/.github/review-media-sql-successor.v1.json +++ b/.github/review-media-sql-successor.v1.json @@ -309,7 +309,7 @@ "toolchain": [ { "path": "apps/web/scripts/review-media-sql-successor.mjs", - "sha256": "4f1b2798e0cfc13db4f11cd6eb4a8653320aa8516eccd71d410fcff6d06a7f24" + "sha256": "4f3dacb6f51a669bf64209b380b19e8274d2703101fc50f815153237f4a99d93" }, { "path": "apps/web/scripts/review-media-sql-rehearsal.mjs", diff --git a/.github/workflows/g014-catalog-contract-baseline.yml b/.github/workflows/g014-catalog-contract-baseline.yml index bddc5a6a82..230fa710c8 100644 --- a/.github/workflows/g014-catalog-contract-baseline.yml +++ b/.github/workflows/g014-catalog-contract-baseline.yml @@ -17,8 +17,16 @@ on: - 'backend/supabase/scripts/transform_g014_guardian_replay.py' - 'backend/supabase/scripts/transform_storyboard_owner_replay.py' - 'backend/supabase/scripts/transform_review_storage_pg15_replay.py' + - 'backend/supabase/scripts/transform_receipt_ocr_credential_release_pg15_replay.py' + - 'backend/supabase/scripts/transform_receipt_ocr_acl_admission_pg15_replay.py' + - 'backend/supabase/scripts/transform_receipt_ocr_indeterminate_recovery_pg15_replay.py' + - 'backend/supabase/scripts/transform_review_verification_readiness_pg15_replay.py' - 'backend/supabase/tests/test_storyboard_owner_replay.py' - 'backend/supabase/tests/test_review_storage_pg15_replay.py' + - 'backend/supabase/tests/test_receipt_ocr_credential_release_pg15_replay.py' + - 'backend/supabase/tests/test_receipt_ocr_acl_admission_pg15_replay.py' + - 'backend/supabase/tests/test_receipt_ocr_indeterminate_recovery_pg15_replay.py' + - 'backend/supabase/tests/test_review_verification_readiness_pg15_replay.py' - 'backend/supabase/tests/test_g014_guardian_replay.py' - 'backend/supabase/scripts/recover_advisor_replay_prerequisites.py' - 'backend/supabase/tests/test_advisor_replay_prerequisites.py' @@ -112,6 +120,11 @@ jobs: backend.supabase.tests.test_g014_catalog_baseline_generator backend.supabase.tests.test_storyboard_owner_replay backend.supabase.tests.test_review_storage_pg15_replay + backend.supabase.tests.test_review_verification_readiness_pg15_replay + backend.supabase.tests.test_feature_rpc_catalog_pg15_replay + backend.supabase.tests.test_receipt_ocr_credential_release_pg15_replay + backend.supabase.tests.test_receipt_ocr_acl_admission_pg15_replay + backend.supabase.tests.test_receipt_ocr_indeterminate_recovery_pg15_replay backend.supabase.tests.test_reconstruction_source_archive backend.supabase.tests.test_g024_source_reconstruction backend.supabase.tests.test_compare_g024_clean_replays diff --git a/.github/workflows/ocr-review-receipts.yml b/.github/workflows/ocr-review-receipts.yml new file mode 100644 index 0000000000..73da99890b --- /dev/null +++ b/.github/workflows/ocr-review-receipts.yml @@ -0,0 +1,110 @@ +name: Review receipt OCR +run-name: Review receipt OCR · ${{ github.ref_name }} + +on: + workflow_dispatch: + inputs: + reviewIds: + description: Optional exact review UUID array, bounded to 50 + required: false + type: string + modelPolicy: + description: Exact ordered Gemini model policy from the administrator request (JSON array) + required: true + type: string + thinkingLevel: + description: Same inference setting as the administrator request + required: true + type: choice + options: [LOW, MEDIUM, HIGH] + recoveryOperationId: + description: Optional opaque recovery operation UUID for one bound review + required: false + type: string + +permissions: + contents: read + +concurrency: + group: review-receipt-ocr + cancel-in-progress: false + +jobs: + process: + if: >- + github.event_name == 'workflow_dispatch' && + github.repository == 'twoimo/tzudong' && + github.event.repository.full_name == 'twoimo/tzudong' && + github.event.repository.default_branch == 'main' && + github.event.repository.owner.login == github.repository_owner && + github.repository_owner == github.actor && + github.triggering_actor == github.actor && + github.event.sender.login == github.actor && + github.ref == 'refs/heads/main' && + github.ref_name == 'main' && + github.ref_protected + runs-on: ubuntu-latest + timeout-minutes: 60 + defaults: + run: + working-directory: apps/web + env: + OCR_REVIEW_IDS: ${{ inputs.reviewIds }} + OCR_BATCH_LIMIT: '50' + OCR_MODEL_POLICY: ${{ inputs.modelPolicy }} + OCR_THINKING_LEVEL: ${{ inputs.thinkingLevel }} + OCR_RECOVERY_OPERATION_ID: ${{ inputs.recoveryOperationId }} + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + GEMINI_CREDITS_API_KEY: ${{ secrets.GEMINI_CREDITS_API_KEY }} + steps: + - name: Checkout exact triggering commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Verify authenticated owner dispatch and protected main source + shell: bash + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_REPOSITORY_FULL_NAME: ${{ github.event.repository.full_name }} + EVENT_REPOSITORY_OWNER: ${{ github.event.repository.owner.login }} + REPOSITORY_OWNER: ${{ github.repository_owner }} + SENDER_LOGIN: ${{ github.event.sender.login }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + set -euo pipefail + test "$GITHUB_EVENT_NAME" = workflow_dispatch + test "$GITHUB_REPOSITORY" = twoimo/tzudong + test "$EVENT_REPOSITORY_FULL_NAME" = twoimo/tzudong + test "$DEFAULT_BRANCH" = main + test "$EVENT_REPOSITORY_OWNER" = "$REPOSITORY_OWNER" + test "$GITHUB_ACTOR" = "$REPOSITORY_OWNER" + test "$TRIGGERING_ACTOR" = "$REPOSITORY_OWNER" + test "$SENDER_LOGIN" = "$REPOSITORY_OWNER" + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_REF_NAME" = main + test "$GITHUB_REF_PROTECTED" = true + test "$GITHUB_SHA" = "$(git rev-parse --verify HEAD)" + test "$GITHUB_SHA" = "$(git show -s --format=%H HEAD)" + test -z "$(git symbolic-ref --quiet HEAD || true)" + test -z "$(git status --short)" + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: apps/web/package-lock.json + + - name: Install exact web dependencies + run: npx --yes npm@11.6.2 ci --ignore-scripts --no-audit --no-fund + + - name: Set up pinned Bun runtime + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: '1.4.0' + + - name: Process bounded receipt OCR batch + run: bun --no-env-file --conditions=react-server scripts/run-admin-receipt-ocr-workflow.ts diff --git a/.github/workflows/review-media-sql-successor.yml b/.github/workflows/review-media-sql-successor.yml new file mode 100644 index 0000000000..69f8b1cf64 --- /dev/null +++ b/.github/workflows/review-media-sql-successor.yml @@ -0,0 +1,579 @@ +name: Review media SQL successor +run-name: Review media SQL successor · ${{ inputs.mode }} · ${{ inputs.source_sha }} + +on: + workflow_dispatch: + inputs: + mode: + description: 'Explicit bounded path; apply performs a fresh rehearsal first.' + required: true + default: observe + type: choice + options: + - observe + - rehearse + - apply + source_sha: + description: 'Exact 40-character protected-main commit SHA.' + required: true + type: string + local_vercel_evidence: + description: 'Canonical sanitized local Vercel rollback/runtime-hold envelope; no credentials or provider bodies.' + required: true + type: string + local_vercel_bundle_sha256: + description: 'Exact SHA-256 printed by capture-vercel for the canonical sanitized bundle.' + required: true + type: string + apply_confirmation: + description: 'For apply only, enter apply::; leave empty otherwise.' + required: false + default: '' + type: string + +permissions: + actions: read + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + VERCEL_CLI_VERSION: '56.5.0' + VERCEL_ORG_ID: 'team_OUj64KeLxJI3PkEbOaFZnorA' + VERCEL_PROJECT_ID: 'prj_sau35J5uUtShIQ9OKofRtOVVnTSl' + +concurrency: + # Keep each accepted dispatch alive; the PostgreSQL transaction lock below + # serializes the operating command without GitHub replacing a pending run. + group: review-media-sql-successor-${{ github.run_id }}-${{ github.run_attempt }} + cancel-in-progress: false + +jobs: + operate: + if: >- + github.event_name == 'workflow_dispatch' && + github.repository == 'twoimo/tzudong' && + github.event.repository.full_name == 'twoimo/tzudong' && + github.event.repository.default_branch == 'main' && + github.event.repository.owner.login == github.repository_owner && + github.repository_owner == github.actor && + github.triggering_actor == github.actor && + github.event.sender.login == github.actor && + github.ref == 'refs/heads/main' && + github.ref_name == 'main' && + github.ref_protected && + github.sha == inputs.source_sha + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact protected-main source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # actions/checkout@v6 + with: + ref: ${{ inputs.source_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Verify authenticated owner dispatch and detached source + shell: bash + env: + APPLY_CONFIRMATION: ${{ inputs.apply_confirmation }} + EXPECTED_MODE: ${{ inputs.mode }} + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + LOCAL_VERCEL_BUNDLE_SHA256: ${{ inputs.local_vercel_bundle_sha256 }} + REPOSITORY_OWNER: ${{ github.repository_owner }} + SENDER_LOGIN: ${{ github.event.sender.login }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + set -euo pipefail + test "$GITHUB_EVENT_NAME" = workflow_dispatch + test "$GITHUB_REPOSITORY" = twoimo/tzudong + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_REF_NAME" = main + test "$GITHUB_REF_PROTECTED" = true + test "$GITHUB_ACTOR" = "$REPOSITORY_OWNER" + test "$TRIGGERING_ACTOR" = "$REPOSITORY_OWNER" + test "$SENDER_LOGIN" = "$REPOSITORY_OWNER" + [[ "$EXPECTED_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$LOCAL_VERCEL_BUNDLE_SHA256" =~ ^[0-9a-f]{64}$ ]] + test "$EXPECTED_SOURCE_SHA" = "$GITHUB_SHA" + test "$EXPECTED_SOURCE_SHA" = "$(git rev-parse --verify HEAD)" + test "$EXPECTED_SOURCE_SHA" = "$(git show -s --format=%H HEAD)" + test -z "$(git symbolic-ref --quiet HEAD || true)" + test -z "$(git status --short)" + case "$EXPECTED_MODE" in + observe|rehearse) + test -z "$APPLY_CONFIRMATION" + ;; + apply) + test "$APPLY_CONFIRMATION" = "apply:$EXPECTED_SOURCE_SHA:$LOCAL_VERCEL_BUNDLE_SHA256" + ;; + *) + exit 2 + ;; + esac + + - name: Set up pinned project Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # actions/setup-node@v6 + with: + node-version: '24.21.0' + + - name: Verify project runtime authority + shell: bash + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import { readFileSync } from 'node:fs'; + + const manifest = JSON.parse(readFileSync('apps/web/package.json', 'utf8')); + if (manifest.engines?.node !== '24.x' + || manifest.packageManager !== 'npm@11.6.2' + || process.versions.node.split('.')[0] !== '24') { + process.exit(2); + } + NODE + + - name: Install pinned Vercel CLI readback authority + id: vercel-cli + if: inputs.mode == 'apply' + shell: bash + run: | + set -euo pipefail + test "$(npm --version)" = 11.6.2 + npm install --global --no-audit --no-fund --ignore-scripts "vercel@$VERCEL_CLI_VERSION" + cli_path="$(command -v vercel)" + test -n "$cli_path" + test "$(vercel --version)" = "$VERCEL_CLI_VERSION" + printf 'cli_path=%s\n' "$cli_path" >> "$GITHUB_OUTPUT" + + - name: Install PostgreSQL client + shell: bash + run: | + set -euo pipefail + sudo apt-get -q update + sudo apt-get -q install --no-install-recommends -y postgresql-client + psql --version + + - name: Create private exclusive operating input and serialized runner + id: custody + shell: bash + env: + LOCAL_VERCEL_EVIDENCE: ${{ inputs.local_vercel_evidence }} + LOCAL_VERCEL_BUNDLE_SHA256: ${{ inputs.local_vercel_bundle_sha256 }} + run: | + set -euo pipefail + umask 077 + custody_root="$(mktemp -d "$RUNNER_TEMP/review-media-sql-successor.XXXXXXXX")" + chmod 700 "$custody_root" + operating_input="$custody_root/local-vercel-evidence.json" + custody_dir="$custody_root/custody" + serialized_runner="$custody_root/run-serialized-operation" + vercel_readback_adapter="$custody_root/vercel-readback-adapter.mjs" + OPERATING_INPUT="$operating_input" node --input-type=module <<'NODE' + import { createHash } from 'node:crypto'; + import { constants, openSync, writeFileSync, closeSync } from 'node:fs'; + + const raw = Buffer.from(process.env.LOCAL_VERCEL_EVIDENCE ?? '', 'utf8'); + const expectedSha256 = process.env.LOCAL_VERCEL_BUNDLE_SHA256 ?? ''; + if (raw.length < 2 || raw.length > 8192 || raw.includes(0) + || !/^[0-9a-f]{64}$/.test(expectedSha256)) process.exit(2); + let value; + try { + value = JSON.parse(raw.toString('utf8')); + } catch { + process.exit(2); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) process.exit(2); + const canonical = item => Array.isArray(item) + ? item.map(canonical) + : item && typeof item === 'object' + ? Object.fromEntries(Object.keys(item).sort().map(key => [key, canonical(item[key])])) + : item; + const bytes = Buffer.from(`${JSON.stringify(canonical(value))}\n`); + if (bytes.length > 8192 + || createHash('sha256').update(bytes).digest('hex') !== expectedSha256) process.exit(2); + const descriptor = openSync( + process.env.OPERATING_INPUT, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, + 0o600, + ); + try { + writeFileSync(descriptor, bytes); + } finally { + closeSync(descriptor); + } + NODE + cat > "$vercel_readback_adapter" <<'NODE' + #!/usr/bin/env node + import { spawnSync } from 'node:child_process'; + import { createHash } from 'node:crypto'; + import { readFileSync } from 'node:fs'; + import { isAbsolute } from 'node:path'; + + try { + const args = process.argv.slice(2); + const input = process.env.LOCAL_VERCEL_READBACK_INPUT ?? ''; + const bundleSha256 = process.env.LOCAL_VERCEL_BUNDLE_SHA256 ?? ''; + const vendorCliPath = process.env.VERCEL_VENDOR_CLI_PATH ?? ''; + const token = process.env.VERCEL_TOKEN ?? ''; + const teamId = 'team_OUj64KeLxJI3PkEbOaFZnorA'; + const projectId = 'prj_sau35J5uUtShIQ9OKofRtOVVnTSl'; + const verifyOnly = args.length === 1 && args[0] === 'verify'; + const apiRead = args.length === 5 && args[0] === 'api' && args[2] === '--scope' + && args[3] === teamId && args[4] === '--raw'; + if ((!verifyOnly && !apiRead) + || process.env.GITHUB_ACTIONS !== 'true' + || process.env.VERCEL_ORG_ID !== teamId + || process.env.VERCEL_PROJECT_ID !== projectId + || !/^[A-Za-z0-9._~-]{20,4096}$/.test(token) + || !isAbsolute(vendorCliPath) || /[\0-\x1f\x7f]/.test(vendorCliPath) + || !input || !/^[0-9a-f]{64}$/.test(bundleSha256)) process.exit(2); + const bytes = readFileSync(input); + if (bytes.length < 2 || bytes.length > 8192 + || createHash('sha256').update(bytes).digest('hex') !== bundleSha256) process.exit(2); + const bundle = JSON.parse(bytes.toString('utf8')); + const readback = bundle.rollbackReadback; + const storyboardPolicy = bundle.storyboardPolicy; + if (bundle.kind !== 'review-media-successor-vercel-operating-bundle' + || bundle.schemaVersion !== 1 + || !readback || readback.gitRef !== 'main' + || readback.repository !== 'twoimo/tzudong' + || readback.projectId !== projectId + || readback.teamId !== teamId + || readback.readyState !== 'READY' || readback.target !== 'production' + || readback.gitSha !== bundle.sourceRevision + || !Number.isFinite(Date.parse(readback.observedAt ?? '')) + || readback.productionAliases?.join('\0') !== 'tzudong.app\0www.tzudong.app' + || storyboardPolicy?.schemaVersion !== 1 + || storyboardPolicy?.deployment?.branch !== 'main' + || storyboardPolicy?.deployment?.deploymentId !== readback.deploymentId + || storyboardPolicy?.deployment?.sourceCommit !== readback.gitSha + || !/^[0-9a-f]{64}$/.test(storyboardPolicy?.policy?.canonicalPolicySha256 ?? '')) { + process.exit(2); + } + const deploymentEndpoint = `/v13/deployments/${readback.deploymentId}?teamId=${teamId}`; + const aliasEndpoints = readback.productionAliases.map(host => + `/v4/aliases/${host}?teamId=${teamId}`); + const allowedEndpoints = [deploymentEndpoint, ...aliasEndpoints]; + if (apiRead && !allowedEndpoints.includes(args[1])) process.exit(2); + const invoke = endpoint => { + const childEnvironment = Object.fromEntries(Object.entries({ + CI: 'true', + HOME: process.env.HOME, + NO_COLOR: '1', + PATH: process.env.PATH, + TMPDIR: process.env.TMPDIR, + VERCEL_ORG_ID: teamId, + VERCEL_PROJECT_ID: projectId, + VERCEL_TOKEN: token, + }).filter(([, value]) => typeof value === 'string')); + const vendor = spawnSync(vendorCliPath, [ + 'api', endpoint, '--scope', teamId, '--raw', '--non-interactive', '--no-color', + ], { + encoding: null, + env: childEnvironment, + killSignal: 'SIGKILL', + maxBuffer: 262144, + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 15000, + windowsHide: true, + }); + if (vendor.status !== 0 || vendor.error || vendor.stdout.length < 2 + || vendor.stdout.length > 262144) process.exit(2); + const result = JSON.parse(vendor.stdout.toString('utf8')); + if (endpoint === deploymentEndpoint) { + if (result?.id !== readback.deploymentId + || result?.projectId !== readback.projectId + || result?.ownerId !== readback.teamId + || result?.readyState !== readback.readyState + || result?.target !== readback.target + || result?.url !== new URL(readback.deploymentUrl).hostname + || result?.meta?.githubCommitOrg !== 'twoimo' + || result?.meta?.githubCommitRepo !== 'tzudong' + || result?.meta?.githubCommitRef !== readback.gitRef + || result?.meta?.githubCommitSha !== readback.gitSha) process.exit(2); + } else { + const alias = readback.productionAliases.find(host => + endpoint === `/v4/aliases/${host}?teamId=${teamId}`); + if (!alias || result?.alias !== alias + || result?.deploymentId !== readback.deploymentId + || result?.deployment?.id !== readback.deploymentId + || result?.projectId !== readback.projectId) process.exit(2); + } + return vendor.stdout; + }; + if (verifyOnly) { + for (const endpoint of allowedEndpoints) invoke(endpoint); + } else { + process.stdout.write(invoke(args[1])); + } + } catch { + process.exit(2); + } + NODE + chmod 700 "$vercel_readback_adapter" + cat > "$serialized_runner" <<'BASH' + #!/usr/bin/env bash + set -euo pipefail + + operation= + if (( $# == 8 )) && [[ "$1" = node + && "$2" = apps/web/scripts/review-media-operating-orchestrator.mjs + && "$3" = --mode && "$4" =~ ^(observe|rehearse)$ + && "$5" = --input && -n "$6" + && "$7" = --custody-dir && -n "$8" ]] + then + operation=base-only + operating_input=$6 + custody_dir=$8 + elif (( $# == 3 )) && [[ "$1" = apply-chain && -n "$2" && -n "$3" ]] + then + operation=apply-chain + operating_input=$2 + custody_dir=$3 + else + exit 2 + fi + test -n "${SUPABASE_DB_URL:-}" + test -n "${EXPECTED_SOURCE_SHA:-}" + + validate_result() { + RESULT_BASE=${3:-} RESULT_CUSTODY="$custody_dir" RESULT_PATH="$2" \ + RESULT_PHASE="$1" RESULT_SOURCE="$EXPECTED_SOURCE_SHA" node --input-type=module <<'NODE' + import { readFileSync, realpathSync } from 'node:fs'; + import { basename, join } from 'node:path'; + + const fail = () => process.exit(2); + const raw = readFileSync(process.env.RESULT_PATH); + if (raw.length < 2 || raw.length > 8192 || raw.includes(0)) fail(); + let value; + try { value = JSON.parse(raw.toString('utf8')); } catch { fail(); } + if (!value || typeof value !== 'object' || Array.isArray(value)) fail(); + const source = process.env.RESULT_SOURCE; + const custodyPath = process.env.RESULT_CUSTODY; + const custody = realpathSync(custodyPath); + const hex = /^[0-9a-f]{64}$/; + const exact = keys => Object.keys(value).sort().join('\0') === [...keys].sort().join('\0'); + const exactPath = (path, name) => typeof path === 'string' + && realpathSync(path) === join(custody, name) && basename(path) === name; + const base = process.env.RESULT_BASE + ? JSON.parse(readFileSync(process.env.RESULT_BASE, 'utf8')) + : undefined; + switch (process.env.RESULT_PHASE) { + case 'base-apply': + if (!exact(['code', 'custodyDirectory', 'expiresAt', 'mode', 'sourceRevision', 'status']) + || value.code !== 'SUCCESSOR_COMMITTED' || value.status !== 'committed' + || value.mode !== 'apply' || value.sourceRevision !== source + || realpathSync(value.custodyDirectory) !== custody) fail(); + break; + case 'feature-prepare-rehearsal': + if (!exact(['admissionPath', 'admissionSha256', 'expiresAt', 'journalPath', 'mode', 'receiptPath', 'status']) + || value.mode !== 'rehearsal' || value.status !== 'prepared' + || value.expiresAt !== base?.expiresAt || !hex.test(value.admissionSha256) + || !exactPath(value.admissionPath, 'feature-rehearsal-admission.json') + || value.journalPath !== join(custodyPath, 'feature-rehearsal-journal.jsonl') + || value.receiptPath !== join(custodyPath, 'feature-rehearsal-receipt.json')) fail(); + break; + case 'feature-rehearsal': + if (!exact(['attemptId', 'code', 'receiptSha256', 'sourceRevision', 'status']) + || value.code !== 'FEATURE_SUCCESSOR_REHEARSAL_PASSED' + || value.status !== 'rolled_back' || value.sourceRevision !== source + || !hex.test(value.receiptSha256)) fail(); + break; + case 'feature-prepare-apply': + if (!exact(['admissionPath', 'admissionSha256', 'expiresAt', 'journalPath', 'mode', 'status']) + || value.mode !== 'apply' || value.status !== 'prepared' + || value.expiresAt !== base?.expiresAt || !hex.test(value.admissionSha256) + || !exactPath(value.admissionPath, 'feature-apply-admission.json') + || value.journalPath !== join(custodyPath, 'feature-apply-journal.jsonl')) fail(); + break; + case 'feature-apply': + if (!exact(['attemptId', 'code', 'sourceRevision', 'status', 'terminal']) + || value.code !== 'FEATURE_SUCCESSOR_COMMITTED' + || value.status !== 'committed' || value.sourceRevision !== source) fail(); + break; + default: + fail(); + } + NODE + } + + run_private() { + phase=$1 + shift + stdout_path="$(dirname "$operating_input")/$phase.stdout.json" + stderr_path="$(dirname "$operating_input")/$phase.stderr" + (umask 077; : > "$stdout_path"; : > "$stderr_path") + chmod 600 "$stdout_path" "$stderr_path" + if ! "$@" >"$stdout_path" 2>"$stderr_path" + then + return 1 + fi + test ! -s "$stderr_path" + validate_result "$phase" "$stdout_path" "${base_stdout:-}" + } + + run_apply_chain() { + export LOCAL_VERCEL_READBACK_INPUT="$operating_input" + export VERCEL_CLI_PATH + if ! node "$VERCEL_CLI_PATH" verify >/dev/null 2>&1 + then + printf '%s\n' '{"code":"SUCCESSOR_VERCEL_READBACK_UNCONFIRMED","status":"failed"}' + return 1 + fi + if ! run_private base-apply node \ + apps/web/scripts/review-media-operating-orchestrator.mjs \ + --mode apply --input "$operating_input" --custody-dir "$custody_dir" + then + printf '%s\n' '{"code":"SUCCESSOR_BASE_OUTCOME_UNCONFIRMED","status":"failed"}' + return 1 + fi + base_stdout="$(dirname "$operating_input")/base-apply.stdout.json" + for phase in prepare-rehearsal rehearsal prepare-apply apply + do + if ! run_private "feature-$phase" node \ + apps/web/scripts/review-media-feature-sql-successor.mjs \ + --mode "$phase" --custody-dir "$custody_dir" + then + printf '%s\n' '{"code":"SUCCESSOR_COMMITTED_FEATURE_HELD","featureStatus":"held","predecessorCode":"SUCCESSOR_COMMITTED","predecessorStatus":"committed","status":"partial"}' + return 1 + fi + done + printf '%s\n' '{"code":"SUCCESSOR_AND_FEATURE_COMMITTED","featureStatus":"committed","predecessorCode":"SUCCESSOR_COMMITTED","predecessorStatus":"committed","status":"committed"}' + } + + lock_read_fd= + lock_write_fd= + lock_pid= + cleanup() { + status=$? + trap - EXIT INT TERM + if [[ -n "$lock_write_fd" ]] + then + printf '%s\n' 'ROLLBACK;' '\quit' >&"$lock_write_fd" 2>/dev/null || true + exec {lock_write_fd}>&- 2>/dev/null || true + fi + if [[ -n "$lock_read_fd" ]] + then + exec {lock_read_fd}<&- 2>/dev/null || true + fi + if [[ -n "$lock_pid" ]] && ! wait "$lock_pid" 2>/dev/null && (( status == 0 )) + then + status=2 + fi + exit "$status" + } + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + lock_diagnostics="$(dirname "$operating_input")/serializer.stderr" + : > "$lock_diagnostics" + chmod 600 "$lock_diagnostics" + coproc SERIALIZER { + PGCONNECT_TIMEOUT=10 PSQLRC=/dev/null psql \ + --no-psqlrc --no-password --quiet --tuples-only --no-align \ + --set=ON_ERROR_STOP=1 "$SUPABASE_DB_URL" 2>"$lock_diagnostics" + } + lock_read_fd=${SERIALIZER[0]} + lock_write_fd=${SERIALIZER[1]} + lock_pid=$SERIALIZER_PID + printf '%s\n' \ + 'BEGIN;' \ + "SET LOCAL lock_timeout = '840s';" \ + "SET LOCAL idle_in_transaction_session_timeout = '900s';" \ + "SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('tzudong:review-media-sql-successor:workflow:v1', 0));" \ + '\echo REVIEW_MEDIA_SQL_SUCCESSOR_LOCKED' >&"$lock_write_fd" + locked=false + while IFS= read -r lock_line <&"$lock_read_fd" + do + if [[ -z "$lock_line" ]] + then + continue + fi + if [[ "$lock_line" = REVIEW_MEDIA_SQL_SUCCESSOR_LOCKED ]] + then + locked=true + break + fi + exit 2 + done + test "$locked" = true + if [[ "$operation" = base-only ]] + then + "$@" + else + test -x "${VERCEL_CLI_PATH:-}" + test "$(dirname "$VERCEL_CLI_PATH")" = "$(dirname "$operating_input")" + run_apply_chain + fi + BASH + chmod 700 "$serialized_runner" + printf 'custody_dir=%s\n' "$custody_dir" >> "$GITHUB_OUTPUT" + printf 'operating_input=%s\n' "$operating_input" >> "$GITHUB_OUTPUT" + printf 'serialized_runner=%s\n' "$serialized_runner" >> "$GITHUB_OUTPUT" + printf 'vercel_readback_adapter=%s\n' "$vercel_readback_adapter" >> "$GITHUB_OUTPUT" + + - name: Observe held successor state + if: inputs.mode == 'observe' + shell: bash + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + GITHUB_TOKEN: ${{ github.token }} + OPERATOR_ACTOR: ${{ github.actor }} + OPERATOR_ACTOR_ID: ${{ github.actor_id }} + OPERATING_INPUT: ${{ steps.custody.outputs.operating_input }} + PRIVATE_CUSTODY_DIR: ${{ steps.custody.outputs.custody_dir }} + SERIALIZED_RUNNER: ${{ steps.custody.outputs.serialized_runner }} + SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} + run: >- + timeout --signal=TERM --kill-after=10s 840s + "$SERIALIZED_RUNNER" + node apps/web/scripts/review-media-operating-orchestrator.mjs + --mode observe + --input "$OPERATING_INPUT" + --custody-dir "$PRIVATE_CUSTODY_DIR" + + - name: Rehearse held successor transaction + if: inputs.mode == 'rehearse' + shell: bash + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + GITHUB_TOKEN: ${{ github.token }} + OPERATOR_ACTOR: ${{ github.actor }} + OPERATOR_ACTOR_ID: ${{ github.actor_id }} + OPERATING_INPUT: ${{ steps.custody.outputs.operating_input }} + PRIVATE_CUSTODY_DIR: ${{ steps.custody.outputs.custody_dir }} + SERIALIZED_RUNNER: ${{ steps.custody.outputs.serialized_runner }} + SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} + run: >- + timeout --signal=TERM --kill-after=10s 840s + "$SERIALIZED_RUNNER" + node apps/web/scripts/review-media-operating-orchestrator.mjs + --mode rehearse + --input "$OPERATING_INPUT" + --custody-dir "$PRIVATE_CUSTODY_DIR" + + - name: Apply explicitly confirmed successor transaction + if: inputs.mode == 'apply' + shell: bash + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + GITHUB_TOKEN: ${{ github.token }} + OPERATOR_ACTOR: ${{ github.actor }} + OPERATOR_ACTOR_ID: ${{ github.actor_id }} + OPERATING_INPUT: ${{ steps.custody.outputs.operating_input }} + LOCAL_VERCEL_BUNDLE_SHA256: ${{ inputs.local_vercel_bundle_sha256 }} + PRIVATE_CUSTODY_DIR: ${{ steps.custody.outputs.custody_dir }} + SERIALIZED_RUNNER: ${{ steps.custody.outputs.serialized_runner }} + SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} + VERCEL_CLI_PATH: ${{ steps.custody.outputs.vercel_readback_adapter }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_VENDOR_CLI_PATH: ${{ steps.vercel-cli.outputs.cli_path }} + run: | + set -euo pipefail + test -n "$VERCEL_TOKEN" + test -x "$VERCEL_VENDOR_CLI_PATH" + test "$("$VERCEL_VENDOR_CLI_PATH" --version)" = "$VERCEL_CLI_VERSION" + timeout --signal=TERM --kill-after=10s 840s \ + "$SERIALIZED_RUNNER" \ + apply-chain "$OPERATING_INPUT" "$PRIVATE_CUSTODY_DIR" diff --git a/.kiro/specs/crawler-pipeline-operational-readiness/platform-modernization-reconciliation.v1.json b/.kiro/specs/crawler-pipeline-operational-readiness/platform-modernization-reconciliation.v1.json index f8838cd532..5e33f1c662 100644 --- a/.kiro/specs/crawler-pipeline-operational-readiness/platform-modernization-reconciliation.v1.json +++ b/.kiro/specs/crawler-pipeline-operational-readiness/platform-modernization-reconciliation.v1.json @@ -3,7 +3,7 @@ "kind": "platform_modernization_reconciliation", "baseCommit": "3f4ca55742aa33869b776291e0d035a16024eef1", "sourceCommit": "880bf06d375dbc6ebe8dcf108419c3f455048a97", - "candidateContentSha256": "4bb32e2d95f15774c9d770f1a63a939960dfdc4e2ca5f43092f44224f7f36df3", + "candidateContentSha256": "def0ced360a157ef95694bfb3ebf5054743a5fdc28e869239cdfee1adaacdc96", "candidateWorkingTreeStateIncluded": true, "sourceDeltaEntryCount": 207, "dispositionCounts": { @@ -313,7 +313,7 @@ "candidatePresentAtInspectionPath": true, "baseGitBlobSha1": null, "sourceGitBlobSha1": "ac09ecd1a0955549a68f08b1c1b0ab9c245824d9", - "candidateGitBlobSha1": "c7f9e9959b9207a50a0e8a9f65d01c8dbe5f055e", + "candidateGitBlobSha1": "e7a7329b773354e7faa6a6b9a101237023296311", "contentState": "candidate_transformed", "disposition": "candidate_transformed_present", "rationale": "The candidate differs from both Git endpoints because current-layout, security, or compatibility hardening has been applied." @@ -579,7 +579,7 @@ "candidatePresentAtInspectionPath": true, "baseGitBlobSha1": null, "sourceGitBlobSha1": "9055a7380a4033b8337f574bdd44c331d78b7ebd", - "candidateGitBlobSha1": "38225d1b3761a101b4caf94eb24853f76d796634", + "candidateGitBlobSha1": "96fe12fafee1fd50c441610b48faf2975aa625c6", "contentState": "candidate_transformed", "disposition": "candidate_transformed_present", "rationale": "The candidate differs from both Git endpoints because current-layout, security, or compatibility hardening has been applied." @@ -1335,7 +1335,7 @@ "candidatePresentAtInspectionPath": true, "baseGitBlobSha1": null, "sourceGitBlobSha1": "e2cd20c357e627ab90adf2f54a6bc958757bb927", - "candidateGitBlobSha1": "06883a9d6e2b2a7ac90b8c446c604fcd118a2a74", + "candidateGitBlobSha1": "4b202ab04e0784aeacb38bab102e288fee1b51b1", "contentState": "candidate_transformed", "disposition": "candidate_transformed_present", "rationale": "The candidate differs from both Git endpoints because current-layout, security, or compatibility hardening has been applied." diff --git a/.taste-skill/direction.json b/.taste-skill/direction.json new file mode 100644 index 0000000000..e6d7c06a8b --- /dev/null +++ b/.taste-skill/direction.json @@ -0,0 +1,337 @@ +{ + "version": 1, + "at": "2026-10-15T12:17:55.215Z", + "seed": 331628995, + "redraws": 0, + "input": { + "brief": "쯔양 영상 기반 맛집의 근거 검수와 수집 운영을 수행하는 한국어 관리자 콘솔", + "sector": "dev-tool", + "tone": "technical", + "have": [ + "product-ui" + ], + "keep": [ + "palette", + "type" + ], + "avoid": [], + "sections": [ + "list", + "proof" + ], + "direction": "dense-tiles" + }, + "imagery": "interface", + "allow": [], + "direction": { + "id": "dense-tiles", + "name": "The well-kept board", + "sectors": [ + "agency", + "studio-portfolio", + "saas", + "dev-tool", + "ai-product", + "culture", + "music", + "other" + ], + "tones": [ + "technical", + "bold" + ], + "needs": [], + "idea": "The whole page is a board of tiles fitted edge to edge with narrow gaps, like a tidy desk seen from above. Each tile is one thing: the name, a statement, a photograph, a number, a link, a short list. There is no hero and no sequence of bands; the page is one continuous composition that the eye roams. It suits subjects with many small true things to show (a studio with work, writing, clients and news; a product with several distinct capabilities) and it replaces the generic bento by having real content in every tile and a strict system for sizes.", + "composition": "A 12-column grid with 8px gaps and 8–12px outer margins, filled completely, no empty cells. Tiles use only four sizes (for example 6×4, 6×2, 3×2, 3×4 in column × row units of about 90px). Every tile is a flat filled rectangle: ground colour one step from the page, or a palette colour, or a photograph covering it. One radius everywhere (0, or 6–10px). The first screen contains: a name tile (the name at display size, the largest type on the page) with the wordmark set large, a statement tile with the description at 28–40px, two or three image tiles and one action tile in the accent colour. Text inside tiles is top-left aligned with 20–24px padding and never centred. Below the first screen the board continues with the same sizes; the last row is the footer made of tiles.", + "typography": "One grotesk in two weights. Inside tiles: a large size for single statements, a small size for lists. No uppercase labels in tile corners.", + "typeClasses": [ + "grotesk-tight", + "geometric" + ], + "colour": "Page ground, tile ground one step lighter or darker, and one or two palette colours on selected tiles. Photographs supply the rest.", + "paletteTags": [ + "dark-neutral", + "light-neutral", + "mono-accent" + ], + "imagery": "Photographs fill whole tiles with `object-fit: cover`; choose images that crop well to both wide and tall tiles.", + "sectionTags": [ + "dense", + "colour-field", + "photo-led" + ], + "motion": "stillness", + "richness": "The fit. A full board with consistent gaps and varied tile contents is satisfying in the way a packed suitcase is.", + "pitfalls": "Tiles are filled, never outlined, and never carry shadows. Do not put an icon and a two-line feature blurb in every tile; vary what tiles hold. Decide the phone layout as its own board: two columns, tiles re-ordered so the name and statement lead.", + "seenIn": [ + "aww-oimachi", + "aww-aspen-search", + "aww-realevate", + "opl-justin-lerner" + ], + "allow": [] + }, + "hero": { + "id": "sheet-with-text-cell", + "name": "Sheet with a text cell", + "archetype": "mosaic", + "directions": [ + "contact-sheet", + "dense-tiles" + ], + "needs": [], + "blueprint": "The whole viewport is a sheet of photographs: six columns with 12px gaps and 12px outer margins, each column a vertical stack of frames at natural ratios with square corners. Each column starts at a different vertical offset (0, -90, -40, -130, -20, -70px) so rows never line up and the top and bottom frames are cropped by the viewport. Every frame has a flat strip 28px tall fused to its bottom edge in a solid palette colour, carrying one line of 13px text (a title or a place); strip colours rotate through four values. There is no header. In columns 3-4, starting at 28% of the height, one cell of the sheet is not a photograph but a filled panel in the tone opposite to the ground, 410px tall, sitting on the grid like any other frame with the same gaps on all sides. Inside it, top-left aligned on 24px padding: the wordmark at 28px, one sentence at 20px on up to three lines, the single action as a filled rectangle, and three text links on one line at the panel's foot.", + "mobile": "The text panel spans the full width as the first block, about 40% of the screen height, with the wordmark at 7vw, the sentence at 17px and the action full width. Under it the sheet resumes in two columns with 8px gaps, the right column started 60px higher than the left, colour strips kept on every frame. The links sit in one row at the panel's foot.", + "headline": "The wordmark at 28px plus one sentence of up to 70 characters inside the panel; nothing is set at display size.", + "motion": "shutter-image", + "seenIn": [ + "opl-proooof" + ] + }, + "type": null, + "palette": null, + "photos": { + "set": "architecture", + "title": "Architecture", + "note": "structure, lines, bridges, facades and building interiors", + "treatment": null, + "others": [ + "sky-and-space" + ], + "photos": [ + { + "id": 855, + "alt": "Small figure walking past a huge bright yellow wall", + "tone": "light", + "colours": [ + "bright yellow", + "black" + ], + "mono": false, + "calm": "left", + "quality": 5, + "ratio": 1.5, + "by": "Rodion Kutsaev", + "page": "https://unsplash.com/photos/IJ25m7fXqtk" + }, + { + "id": 508, + "alt": "Tall pointed vaults and pale columns of a church interior", + "tone": "light", + "colours": [ + "cream", + "warm grey", + "soft orange" + ], + "mono": false, + "calm": "none", + "quality": 5, + "ratio": 0.76, + "by": "Jeff Sheldon", + "page": "https://unsplash.com/photos/u3gES0SUsnI" + }, + { + "id": 1081, + "alt": "Stepped white modern building corner rising into a pale blue sky", + "tone": "light", + "colours": [ + "white", + "pale blue", + "soft grey" + ], + "mono": false, + "calm": "top", + "quality": 5, + "ratio": 1.49, + "by": "Julien Moreau", + "page": "https://unsplash.com/photos/688Fna1pwOQ" + }, + { + "id": 942, + "alt": "White angular concrete building against a black sky", + "tone": "dark", + "colours": [ + "black", + "white", + "pale grey" + ], + "mono": true, + "calm": "top", + "quality": 5, + "ratio": 1.5, + "by": "Carl Nenzen Loven", + "page": "https://unsplash.com/photos/busqfOj6i80" + } + ] + }, + "motion": { + "id": "shutter-image", + "name": "Shutter image", + "description": "The one hero or showcase image opens from an edge with a clip-path over about 1.1 seconds while the picture inside settles from 1.12 scale to rest. Use it once, on the image that matters most; later images simply are there. Recipe: 'Image shutter' in references/motion.md." + }, + "sections": [ + { + "id": "list-day-tabs-banded-agenda", + "role": "list", + "name": "Day tabs, banded agenda", + "shape": "list", + "tags": [ + "dense", + "type-led", + "colour-field" + ], + "directions": [ + "editorial-report", + "product-performs", + "dense-tiles", + "colour-chapters" + ], + "needs": [], + "blueprint": "A two-line heading at clamp(36px, 4vw, 60px), first line in the soft ink and second in full ink, with the booking button on the same row at the far right. 56px below, left: a 150px column of stacked day tabs, each a filled block 150 by 64px with 6px gaps showing the weekday at 16px and the date at 13px; the active tab takes the darkest fill with inverted text. Right, from 190px to the right margin: the day's sessions as alternating tone bands, each 76px tall with an 8px radius and 4px gaps, the two tones one step apart. Inside a band, on fixed column starts: the time at 20px with tabular figures, 20px from the left; the title at 18px in medium weight at 22%, with a one-line note at 13px in the soft ink under it; speaker names right-aligned at 14px beside up to three overlapping 32px round portraits.", + "mobile": "The heading stacks above a full-width button. Day tabs become a horizontal row of equal filled blocks, sticky under the top edge. Each band stacks time over title over speakers with 16px padding and grows to fit; portraits sit at the band's top-right corner.", + "seenIn": [ + "opl-builders-table", + "opl-select26" + ] + }, + { + "id": "proof-large-marks-on-tiles", + "role": "proof", + "name": "Large marks on tiles", + "shape": "grid", + "tags": [ + "dense", + "colour-field" + ], + "directions": [ + "dense-tiles", + "specimen-catalogue", + "colour-chapters", + "one-motif", + "product-performs", + "loud-flat-poster" + ], + "needs": [], + "blueprint": "A board of filled tiles, four across and two or three rows deep, with 8px gaps, spanning the full content width. Each tile is about 3:2, one step lighter or darker than the page ground, with no border. A tile holds one supplied logo, shown far larger than a usual logo strip: about 72px tall, centred, in the page ink as a single-colour SVG. When no logo files exist, the tile holds the name instead, set at 28–32px bold, top-left with 24px padding; never draw a mark. One tile, twice the width of the others and placed first, is in the accent colour and carries the heading: two to four words at about 3vw, top-left. Tile corners follow the page's one radius, or the page's motif shape if it has one. Tiles sit still; there is no hover lift.", + "mobile": "Two tiles per row with 6px gaps, logos at 44px tall. The heading tile runs full width first, about 120px tall. Show eight tiles at most so the board stays under one and a half screens. Names set in type drop to 20px.", + "seenIn": [ + "aww-monolog" + ] + } + ], + "references": [ + { + "id": "aww-realevate", + "name": "Realevate", + "url": "https://realevate.agency/", + "source": "Awwwards Site of the Day", + "kind": "real-estate", + "score": 7.2, + "idea": "The site is a board you zoom out of: the home screen with its slogan marquee becomes one tile beneath four tall colour-coded category cards.", + "mood": [ + "crisp", + "corporate", + "confident", + "bright" + ], + "archetype": "other: marquee-behind-centred-image", + "hero": "White field, all type navy. Top row about 8% down: one link far left, an extended-caps wordmark centred, one link far right. Across the vertical middle a two-word slogan repeats as a marquee at about 15vw in medium weight, cropped by both edges; a square photograph about 19vw wide sits dead centre on top of it, hiding the letters behind. Lower third: a two-line centred subhead near 24px and, beneath it, a thin-outlined rectangular tag in tiny extended capitals. Bottom-left a copyright line; bottom-right an outlined rectangular button beside a solid navy square menu button.", + "type": "Google Sans medium at 218px with -0.02em tracking for the marquee and roughly 16-24px for interface text. Monument Extended in small spaced capitals for the wordmark and price tag. Card titles are rotated 90 degrees and read bottom to top.", + "colour": "#ffffff #1e2a5e #e1e2ea #3a664f #2c1a2e #1c1b1e — Navy on white with no other interface colour in the hero. The overview sits on pale lavender-grey and gives each category a flat card colour: navy, forest green, aubergine, near-black.", + "imagery": "Glossy architectural renders of sea-view terraces, resorts and towers. One perfect square in the hero; landscape crops fill the bottom third of each category card. Square corners, full saturation, no grade.", + "layout": "A one-screen site with two states. Home is symmetrical: corners hold small utilities, the centre holds marquee and image. The overview is a four-column row of tall cards with equal gutters and a scaled-down live copy of the home screen centred beneath.", + "sections": [ + { + "role": "work", + "description": "Pale lavender-grey ground with four equal portrait cards in a row, roughly 1:2 in proportion, about 20px apart with generous outer margins. Each card is a flat colour block for its top 70% and a photograph for the bottom 30%. A small monogram sits top-left; the category name runs vertically up the right side in white at about 28px; a two-line descriptor in tiny white text sits bottom-left of the colour block, just above the image." + }, + { + "role": "other", + "description": "Centred below the card row, spanning the middle two columns, sits a miniature of the home screen: a white rectangle with the tiny nav row, the slogan marquee still running and the square photo in the middle, cropped by the viewport's bottom edge. It acts as a live thumbnail of where the visitor came from and a way back." + } + ], + "steal": [ + "Run a giant slogan marquee through the middle of the viewport and park a square image on top so the words are read as they pass around the picture.", + "Build category cards as a tall colour block over a short photo, with the title rotated up the right edge.", + "Keep utilities in the four corners (two links top, copyright bottom-left, action pair bottom-right) and leave the sides empty." + ] + }, + { + "id": "opl-justin-lerner", + "name": "Justin Lerner", + "url": "https://jlern.com", + "source": "One Page Love", + "kind": "personal-portfolio", + "score": 6.8, + "idea": "The portfolio is a hairline grid of square cells in which the letters of the owner's name are scattered among small animated project emblems, so name and work form one crossword-like board.", + "mood": [ + "playful", + "spare", + "witty", + "hand-made" + ], + "archetype": "grid-index", + "hero": "White page divided by pale grey hairlines into five columns of exact squares (each 20vw), so three full rows and a sliver of a fourth show. No header, navigation or text block. Each cell holds one centred object about half the cell wide: either a single black slab-serif capital at 5.6vw (one in the top-left cell, one top-right, three more scattered through rows two and three) or a small project emblem such as a pixel speech bubble, a photographed bowl, a red robot arm, a wireframe figure, a logo badge or a line-drawn building. One cell holds only a soft blurred cloud. About 90% of the area is white.", + "type": "Museo Slab is the only face on desktop: weight-500 capitals at 80px used as grid letters, with no body copy at all (eleven words on the page). Mobile adds light italic slab project names and small sans year tabs.", + "colour": "#ffffff #e9eae8 #111111 #fff200 #f0563a #4fd0c8 — White ground, grey hairlines, black letters. Colour arrives only in the emblems, each in its own one- or two-colour scheme (yellow, coral, teal, purple). Mobile adds two solid yellow icon tiles.", + "imagery": "Small spot illustrations and logos in deliberately mixed styles (pixel art, line drawing, isometric, wireframe mesh, one cut-out photo), each centred in a square cell with no frame or background, and animated.", + "layout": "A single edge-to-edge grid of 20vw squares with hairline dividers and no outer margin; the whole site is about 1.3 screens. Mobile switches to two columns and adds a year tab in each cell's top-right corner and an italic name under each emblem.", + "sections": [ + { + "role": "work", + "description": "The grid continues for one more row below the fold with five cells holding only emblems. Across frames the emblems are caught in different states: a logo badge collapsing to a small monogram, a scan line moving down the wireframe figure, a pencil travelling across a drawn face, a coin appearing and disappearing. Each cell is a tiny looping or hover-driven animation, with no captions on desktop." + }, + { + "role": "other", + "description": "Mobile header built from the same cells: a small square with the initial on crosshair lines at left, an empty cell, then two solid-yellow square tiles carrying a document icon and a paper-plane icon. Below, the two-column grid gives each project a boxed year tab notched into the top-right corner of its cell and an italic slab project name near the bottom, sometimes overlapping the emblem." + } + ], + "steal": [ + "Spell the name by dropping its letters into scattered cells of a hairline grid and fill the remaining cells with project marks, so identity and index share one structure.", + "Give each project a single small animated emblem centred in a large empty square instead of a screenshot thumbnail.", + "On mobile, notch a small boxed year tab into the corner of each grid cell and label the cell with an italic name at its base." + ] + }, + { + "id": "opl-dialkit", + "name": "DialKit", + "url": "https://www.dialkit.dev/", + "source": "One Page Love", + "kind": "dev-tool", + "score": 7.9, + "idea": "The headline is the demo: three floating dial controls from the product sit on top of the sentence and change its weight, spacing and effects live.", + "mood": [ + "dark", + "precise", + "tactile", + "playful", + "minimal" + ], + "archetype": "type-poster", + "hero": "Near-black viewport with no logo and no nav beyond a two-word audience toggle (active white, inactive grey) in 13px at top-left. A three-line sentence, centred, white, about 9vw with leading near 1.0, occupies the middle 60% of the width and sits optically centred. Three dark glass control chips, each roughly 300 by 80px with large-radius corners, a label on a lighter tab and a monospace value, float over the text at different tilts: one above the first line, one overlapping the start of the last line at lower left, one a segmented off/on switch at lower right. The chips blur the letters beneath them. The bottom third is empty.", + "type": "Mluvka at weight 450 for the 128px headline (-0.02em) and 16px body; system monospace for control values, code and prompts. Section headings are only ~18px semibold, so the scale is one giant sentence followed by documentation-size type.", + "colour": "#111111 #0b0b0b #262626 #ffffff #8a8a8a — Almost pure greyscale: #111 ground, #262626 code panels, white text, grey secondary. Colour arrives only inside demo recordings and syntax highlighting; a faint warm glow rims the control chips.", + "imagery": "Screen recordings of interfaces being tuned with the control panel, shown as rounded tiles bent around a 3D cylinder; plus one large embedded demo card pairing a photo stack with its control panel.", + "layout": "A full-viewport centred hero and a full-width curved carousel, then everything collapses into one centred ~680px documentation column with left-aligned text: small headings, tab rows, code panels, prompt blocks. It reads like a well-set README.", + "sections": [ + { + "role": "gallery", + "description": "A band of six or seven screen-recording tiles mapped onto the inside of a wide horizontal cylinder. Tiles in the centre face the viewer at full size; tiles toward the edges shrink, skew in perspective and fade into black, so the row reads as a curved cinema strip about 95% of the viewport wide and 150px tall, floating in empty black with nothing above or below it." + }, + { + "role": "showcase", + "description": "A single rounded dark card, about 680px wide and 650px tall, split in two. Left: a live demo with a title, a date and two overlapping photographs. Right: a narrow control panel of stacked slider rows, a spring-curve graph and toggles. A text link sits in the card's bottom-left corner. Directly below, a small heading with a tab row of package managers and a one-line code strip." + }, + { + "role": "how-it-works", + "description": "A docs-style stack in the same narrow column: a small semibold heading, a framework tab row (active white, others grey), one paragraph, then a rounded #262626 code panel with syntax colouring. A list of ready-made prompts follows, each a bold one-line label above a monospace prompt set in its own rounded panel, with about 30px between items." + } + ], + "steal": [ + "Place the product's real controls on the headline and let them alter the headline's own type settings.", + "Reduce navigation to a single audience toggle (human or agent) that swaps the page's content mode.", + "Wrap a row of screen recordings around a 3D cylinder so the strip skews and fades at the edges instead of running as a flat marquee." + ] + } + ] +} \ No newline at end of file diff --git a/apps/web/app/admin/banners/page.tsx b/apps/web/app/admin/banners/page.tsx index 8cc1e9fd78..9612547a58 100644 --- a/apps/web/app/admin/banners/page.tsx +++ b/apps/web/app/admin/banners/page.tsx @@ -10,6 +10,7 @@ import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { + AdminBannerMutationError, useAdBannersAdmin, useCreateAdBanner, useUpdateAdBanner, @@ -112,6 +113,126 @@ const matchesSavedBanner = (actual: AdBanner | undefined, expected: AdBanner) => && actual.media_type === expected.media_type && actual.link_url === expected.link_url && JSON.stringify([...actual.display_target].sort()) === JSON.stringify([...expected.display_target].sort())); +type BannerPendingReadback = + | { saved: AdBanner } + | { deletedId: string; mediaCleanupFailed: boolean }; + +type BannerPendingReadbackDecision = + | { kind: 'saved-confirmed'; banner: AdBanner } + | { kind: 'saved-diverged'; latest: AdBanner | null } + | { kind: 'delete-confirmed' } + | { kind: 'delete-diverged'; latest: AdBanner }; + +function getBannerPendingReadbackDecision( + banners: AdBanner[], + pending: BannerPendingReadback, +): BannerPendingReadbackDecision { + if ('saved' in pending) { + const latest = banners.find((banner) => banner.id === pending.saved.id); + return matchesSavedBanner(latest, pending.saved) + ? { kind: 'saved-confirmed', banner: latest! } + : { kind: 'saved-diverged', latest: latest ?? null }; + } + + const latest = banners.find((banner) => banner.id === pending.deletedId); + return latest ? { kind: 'delete-diverged', latest } : { kind: 'delete-confirmed' }; +} + +const MAX_BANNER_TITLE_LENGTH = 100; + +type DefiniteBannerMutationRejectionCode = + | 'banner_revision_conflict' + | 'banner_not_found' + | 'banner_mutation_forbidden' + | 'banner_mutation_invalid'; + +function isDefiniteBannerMutationRejection( + status: number | null, + code: string, +): code is DefiniteBannerMutationRejectionCode { + return (status === 400 && code === 'banner_mutation_invalid') + || (status === 403 && code === 'banner_mutation_forbidden') + || (status === 404 && code === 'banner_not_found') + || (status === 409 && code === 'banner_revision_conflict'); +} + +function getBannerTitleValidationError(title: string) { + const normalizedTitle = title.trim(); + if (!normalizedTitle) return '제목을 입력해주세요'; + if (normalizedTitle.length > MAX_BANNER_TITLE_LENGTH) { + return `제목은 ${MAX_BANNER_TITLE_LENGTH}자 이하로 입력해주세요`; + } + return null; +} + +async function cleanupOwnedBannerUploads( + paths: readonly string[], + remove: (path: string) => Promise, +) { + const results = await Promise.allSettled(paths.map(remove)); + return results.some((result) => result.status === 'rejected'); +} + +async function cleanupDefiniteRejectedBannerUploads( + status: number | null, + code: string, + paths: readonly string[], + remove: (path: string) => Promise, +) { + if (!isDefiniteBannerMutationRejection(status, code)) return null; + return cleanupOwnedBannerUploads(paths, remove); +} + +type BannerRefreshEditorDecision = + | { kind: 'none' | 'unchanged' } + | { kind: 'rebase'; banner: AdBanner } + | { kind: 'close-removed' } + | { kind: 'preserve-conflict'; removed: boolean }; + +type BannerEditorLiveSnapshot = { + revision: number; + editingBanner: AdBanner | null; + isDirty: boolean; +}; + +type BannerRefreshConflict = { + bannerId: string; + latest: AdBanner | null; +}; + +function isBannerRefreshConflictBlocking( + conflict: BannerRefreshConflict | null, + editingBanner: AdBanner | null, +) { + return Boolean(conflict && conflict.bannerId === editingBanner?.id); +} + +function getBannerRefreshEditorDecision( + banners: AdBanner[], + editingBanner: AdBanner | null, + isDirty: boolean, +): BannerRefreshEditorDecision { + if (!editingBanner) return { kind: 'none' }; + const latest = banners.find((banner) => banner.id === editingBanner.id); + if (!latest) return isDirty ? { kind: 'preserve-conflict', removed: true } : { kind: 'close-removed' }; + if (matchesSavedBanner(latest, editingBanner)) return { kind: 'unchanged' }; + return isDirty ? { kind: 'preserve-conflict', removed: false } : { kind: 'rebase', banner: latest }; +} + +async function readBannerRefresh( + refetch: () => Promise, + getLiveSnapshot: () => BannerEditorLiveSnapshot, +) { + const started = getLiveSnapshot(); + const result = await refetch(); + const current = getLiveSnapshot(); + return { + result, + current, + editorChangedDuringRefresh: current.revision !== started.revision, + }; +} + const revokeObjectUrlIfNeeded = (url: string | null) => { if (url?.startsWith('blob:https:') || url?.startsWith('blob:http:')) { URL.revokeObjectURL(url); @@ -219,8 +340,17 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem const [actionResult, setActionResult] = useState<{ status: 'success' | 'warning' | 'error'; message: string } | null>(null); const [initialForm, setInitialForm] = useState(formData); const editorRef = useRef(null); - const [pendingReadback, setPendingReadback] = useState<{ saved: AdBanner } | { deletedId: string; mediaCleanupFailed: boolean } | null>(null); + const [pendingReadback, setPendingReadback] = useState(null); + const [refreshConflict, setRefreshConflict] = useState(null); const isDirty = JSON.stringify(formData) !== JSON.stringify(initialForm) || Boolean(imageFile || videoFile); + const hasRefreshConflict = isBannerRefreshConflictBlocking(refreshConflict, editingBanner); + const editorRevisionRef = useRef(0); + const liveEditorSnapshotRef = useRef({ revision: 0, editingBanner: null, isDirty: false }); + useLayoutEffect(() => { + const revision = editorRevisionRef.current + 1; + editorRevisionRef.current = revision; + liveEditorSnapshotRef.current = { revision, editingBanner, isDirty }; + }, [editingBanner, formData, imageFile, isDirty, isEditorOpen, videoFile]); const isBusy = isUploading || createBanner.isPending || updateBanner.isPending || deleteBanner.isPending; const visibleBanners = useMemo(() => [...banners] .sort((a, b) => sortOrder === 'title' ? a.title.localeCompare(b.title, 'ko') : sortOrder === 'updated' ? Date.parse(b.updated_at) - Date.parse(a.updated_at) : b.priority - a.priority) @@ -275,6 +405,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem setEditingBanner(null); setBannerToDelete(null); setDeleteConfirmation(''); + setRefreshConflict(null); }; const handleOpenExternalLink = (rawUrl: string) => { @@ -316,6 +447,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem setVideoPreview(resolvedUrls?.video_url ?? null); setBannerToDelete(null); setDeleteConfirmation(''); + setRefreshConflict(null); }; const applyEditor = (banner: AdBanner | null, close = false) => { @@ -329,7 +461,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem }; const requestEditor = (banner: AdBanner | null) => { if (isBusy || pendingReadback) return; - if (banner && banner.id === editingBanner?.id) { + if (banner && banner.id === editingBanner?.id && !refreshConflict) { editorRef.current?.focus(); return; } @@ -493,10 +625,19 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem // 폼 제출 const handleSubmit = async () => { + if (hasRefreshConflict) { + toast({ + title: '최신 배너 상태 확인 필요', + description: '현재 입력을 저장하기 전에 목록의 최신 상태를 다시 열어 주세요.', + variant: 'destructive', + }); + return; + } if (isBusy || bannersError || !isDirty || Boolean(pendingReadback)) return; - if (!formData.title.trim()) { + const titleValidationError = getBannerTitleValidationError(formData.title); + if (titleValidationError) { toast({ - title: '제목을 입력해주세요', + title: titleValidationError, variant: 'destructive', }); return; @@ -528,6 +669,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem return; } + const uploadedPaths: string[] = []; try { setIsUploading(true); setPendingEditor(null); @@ -539,6 +681,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem // 새 이미지가 있으면 업로드 if (imageFile) { const uploadResult = await uploadImage.mutateAsync(imageFile); + uploadedPaths.push(uploadResult.path); imageUrl = uploadResult.url; videoUrl = null; // 이미지 업로드 시 영상 제거 } @@ -546,6 +689,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem // 새 영상이 있으면 업로드 if (videoFile) { const uploadResult = await uploadImage.mutateAsync(videoFile); // 동일한 업로드 훅 사용 + uploadedPaths.push(uploadResult.path); videoUrl = uploadResult.url; imageUrl = null; // 영상 업로드 시 이미지 제거 } @@ -558,9 +702,13 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem }; const resolvedUrls = resolveAdBannerPersistenceUrls(dataToSubmit); if (!resolvedUrls) { + const cleanupFailed = await cleanupOwnedBannerUploads( + uploadedPaths, + (path) => deleteImage.mutateAsync(path), + ); toast({ title: '배너 저장 실패', - description: AD_BANNER_URL_VALIDATION_ERROR, + description: `${AD_BANNER_URL_VALIDATION_ERROR}${cleanupFailed ? ' 새 업로드 미디어 일부를 정리하지 못했습니다.' : ''}`, variant: 'destructive', }); return; @@ -572,23 +720,56 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem }; const savedBanner = editingBanner - ? await updateBanner.mutateAsync({ id: editingBanner.id, data: validatedDataToSubmit }) + ? await updateBanner.mutateAsync({ id: editingBanner.id, expectedUpdatedAt: editingBanner.updated_at, data: validatedDataToSubmit }) : await createBanner.mutateAsync(validatedDataToSubmit); openEditPanel(savedBanner); setPendingReadback({ saved: savedBanner }); const readback = await refetchBanners(); - const confirmedBanner = readback.data?.find((banner) => banner.id === savedBanner.id); - const readbackMatches = matchesSavedBanner(confirmedBanner, savedBanner); - if (readback.isError || !readbackMatches) { + if (readback.isError || !Array.isArray(readback.data)) { setActionResult({ status: 'warning', message: '저장은 완료했지만 목록 재확인이 필요합니다. 중복 저장하지 말고 새로고침으로 확인해 주세요.' }); } else { - openEditPanel(confirmedBanner!); + const decision = getBannerPendingReadbackDecision(readback.data, { saved: savedBanner }); setPendingReadback(null); - setActionResult({ status: 'success', message: '저장한 배너 상태를 목록에서 다시 확인했습니다.' }); + if (decision.kind === 'saved-confirmed') { + openEditPanel(decision.banner); + setActionResult({ status: 'success', message: '저장한 배너 상태를 목록에서 다시 확인했습니다.' }); + } else if (decision.kind === 'saved-diverged' && decision.latest) { + openEditPanel(decision.latest); + setActionResult({ status: 'warning', message: '저장 뒤 배너가 다시 변경되어 목록의 최신 상태를 열었습니다.' }); + } else { + resetForm(); + setIsEditorOpen(false); + setActionResult({ status: 'warning', message: '저장 뒤 배너가 목록에서 사라져 상세를 닫았습니다.' }); + } } } catch (error) { console.error('배너 저장 실패:'); - setActionResult({ status: 'error', message: '배너를 저장하지 못했습니다. 입력 내용은 유지됩니다. 목록에서 적용 여부를 확인한 후 다시 시도해 주세요.' }); + const cleanupFailed = error instanceof AdminBannerMutationError + ? await cleanupDefiniteRejectedBannerUploads( + error.status, + error.code, + uploadedPaths, + (path) => deleteImage.mutateAsync(path), + ) + : null; + if (error instanceof AdminBannerMutationError && cleanupFailed !== null) { + if (error.code === 'banner_revision_conflict' || error.code === 'banner_not_found') { + if (editingBanner) setRefreshConflict({ bannerId: editingBanner.id, latest: error.latest }); + setActionResult({ + status: 'warning', + message: error.latest + ? `배너가 다른 관리자에 의해 변경되어 현재 입력을 보존했습니다. 최신 상태를 다시 열어 비교해 주세요.${cleanupFailed ? ' 새 업로드 미디어 일부를 정리하지 못했습니다.' : ''}` + : `편집하던 배너가 사라져 현재 입력을 보존했습니다. 상세를 닫기 전 내용을 확인해 주세요.${cleanupFailed ? ' 새 업로드 미디어 일부를 정리하지 못했습니다.' : ''}`, + }); + } else { + setActionResult({ + status: 'error', + message: `배너 저장 요청이 적용 전에 거부되었습니다. 입력 내용은 유지됩니다.${cleanupFailed ? ' 새 업로드 미디어 일부를 정리하지 못했습니다.' : ''}`, + }); + } + } else { + setActionResult({ status: 'error', message: '배너를 저장하지 못했습니다. 입력 내용은 유지됩니다. 목록에서 적용 여부를 확인한 후 다시 시도해 주세요.' }); + } } finally { setIsUploading(false); } @@ -597,16 +778,17 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem // 삭제 실행 const handleDelete = async () => { if (!bannerToDelete) return; - if (deleteConfirmation !== '배너삭제' || bannerToDelete.id !== editingBanner?.id || isBusy || bannersError || Boolean(pendingReadback)) return; + if (deleteConfirmation !== '배너삭제' || bannerToDelete.id !== editingBanner?.id || isBusy || bannersError || Boolean(pendingReadback) || hasRefreshConflict) return; + const target = bannerToDelete; try { setIsUploading(true); setActionResult(null); - await deleteBanner.mutateAsync(bannerToDelete.id); + const deletedBanner = await deleteBanner.mutateAsync({ id: target.id, expectedUpdatedAt: target.updated_at }); const mediaPaths = [ - bannerToDelete.image_url, - bannerToDelete.video_url, + deletedBanner.image_url, + deletedBanner.video_url, ] .map(resolveAdBannerMediaStoragePath) .filter((path): path is string => path !== null); @@ -617,46 +799,127 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem mediaCleanupFailed = true; console.error('배너 DB 삭제 후 미디어 정리 실패:'); }); - setPendingReadback({ deletedId: bannerToDelete.id, mediaCleanupFailed }); + const pendingDeletion = { deletedId: deletedBanner.id, mediaCleanupFailed }; + setPendingReadback(pendingDeletion); const readback = await refetchBanners(); - const deletionConfirmed = !readback.isError && Array.isArray(readback.data) && !readback.data.some((banner) => banner.id === bannerToDelete.id); - setBannerToDelete(null); setDeleteConfirmation(''); - resetForm(); - if (deletionConfirmed) { + if (!readback.isError && Array.isArray(readback.data)) { + const decision = getBannerPendingReadbackDecision(readback.data, pendingDeletion); setPendingReadback(null); - setIsEditorOpen(false); + if (decision.kind === 'delete-confirmed') { + resetForm(); + setIsEditorOpen(false); + setActionResult({ + status: mediaCleanupFailed ? 'warning' : 'success', + message: mediaCleanupFailed ? '배너 삭제를 확인했습니다. 연결 미디어 일부를 정리하지 못했습니다.' : '배너가 목록에서 삭제된 것을 확인했습니다.', + }); + } else if (decision.kind === 'delete-diverged') { + openEditPanel(decision.latest); + setActionResult({ status: 'warning', message: '삭제 뒤 같은 ID의 배너가 목록에 있어 최신 상태를 열었습니다. 다시 삭제하려면 내용을 확인해 주세요.' }); + } + } else { + setActionResult({ + status: 'warning', + message: '삭제 요청은 완료했지만 목록 재확인이 필요합니다. 다시 삭제하지 말고 새로고침해 주세요.', + }); } - setActionResult({ - status: deletionConfirmed && !mediaCleanupFailed ? 'success' : 'warning', - message: !deletionConfirmed ? '삭제 요청은 완료했지만 목록 재확인이 필요합니다. 다시 삭제하지 말고 새로고침해 주세요.' - : mediaCleanupFailed ? '배너 삭제를 확인했습니다. 연결 미디어 일부를 정리하지 못했습니다.' - : '배너가 목록에서 삭제된 것을 확인했습니다.', - }); } catch (error) { console.error('배너 삭제 실패:'); - setActionResult({ status: 'error', message: '삭제 결과를 확인하지 못했습니다. 목록에서 현재 상태를 확인해 주세요.' }); + if (error instanceof AdminBannerMutationError && (error.code === 'banner_revision_conflict' || error.code === 'banner_not_found')) { + setRefreshConflict({ bannerId: target.id, latest: error.latest }); + setActionResult({ + status: 'warning', + message: error.latest + ? '배너가 다른 관리자에 의해 변경되어 삭제하지 않았습니다. 최신 상태를 다시 열어 확인해 주세요.' + : '배너가 이미 사라져 삭제하지 않았습니다. 현재 상세를 닫고 목록을 확인해 주세요.', + }); + } else { + setActionResult({ status: 'error', message: '삭제 결과를 확인하지 못했습니다. 목록에서 현재 상태를 확인해 주세요.' }); + } } finally { setIsUploading(false); } }; const refreshBannerList = async () => { - const result = await refetchBanners(); + const { result, current, editorChangedDuringRefresh } = await readBannerRefresh( + refetchBanners, + () => liveEditorSnapshotRef.current, + ); const pending = pendingReadback; - if (result.isError || !Array.isArray(result.data) || !pending) return; - if ('saved' in pending) { - const confirmed = result.data?.find((banner) => banner.id === pending.saved.id); - if (!matchesSavedBanner(confirmed, pending.saved)) return; - openEditPanel(confirmed!); - setActionResult({ status: 'success', message: '저장한 배너 상태를 목록에서 다시 확인했습니다.' }); - } else { - if (result.data?.some((banner) => banner.id === pending.deletedId)) return; + if (result.isError || !Array.isArray(result.data)) return; + if (pending) { + const pendingDecision = getBannerPendingReadbackDecision(result.data, pending); + setPendingReadback(null); + if (pendingDecision.kind === 'saved-confirmed') { + openEditPanel(pendingDecision.banner); + setActionResult({ status: 'success', message: '저장한 배너 상태를 목록에서 다시 확인했습니다.' }); + } else if (pendingDecision.kind === 'saved-diverged' && pendingDecision.latest) { + openEditPanel(pendingDecision.latest); + setActionResult({ status: 'warning', message: '저장 뒤 배너가 다시 변경되어 목록의 최신 상태를 열었습니다.' }); + } else if (pendingDecision.kind === 'saved-diverged') { + resetForm(); + setIsEditorOpen(false); + setActionResult({ status: 'warning', message: '저장 뒤 배너가 목록에서 사라져 상세를 닫았습니다.' }); + } else if (pendingDecision.kind === 'delete-diverged') { + openEditPanel(pendingDecision.latest); + setActionResult({ status: 'warning', message: '삭제 뒤 같은 ID의 배너가 목록에 있어 최신 상태를 열었습니다. 다시 삭제하려면 내용을 확인해 주세요.' }); + } else { + resetForm(); + setIsEditorOpen(false); + const mediaCleanupFailed = 'mediaCleanupFailed' in pending && pending.mediaCleanupFailed; + setActionResult({ status: mediaCleanupFailed ? 'warning' : 'success', message: mediaCleanupFailed ? '배너 삭제를 확인했습니다. 연결 미디어 일부를 정리하지 못했습니다.' : '배너가 목록에서 삭제된 것을 확인했습니다.' }); + } + return; + } + + const decision = getBannerRefreshEditorDecision(result.data, current.editingBanner, current.isDirty); + if (editorChangedDuringRefresh) { + const latest = current.editingBanner + ? result.data.find((banner) => banner.id === current.editingBanner?.id) ?? null + : null; + const hasConfirmedConflict = decision.kind === 'rebase' + || decision.kind === 'close-removed' + || decision.kind === 'preserve-conflict'; + if (hasConfirmedConflict && current.editingBanner) { + setRefreshConflict({ bannerId: current.editingBanner.id, latest }); + } + setActionResult({ + status: 'warning', + message: hasConfirmedConflict + ? '새로고침 중 편집기 상태가 바뀌었고 목록의 최신 상태도 달라 현재 입력을 보존했습니다. 최신 상태를 다시 열기 전에는 저장할 수 없습니다.' + : '새로고침 중 선택 또는 입력이 바뀌어 현재 편집 상태를 그대로 보존했습니다.', + }); + return; + } + if (decision.kind === 'rebase') { + openEditPanel(decision.banner); + setActionResult({ status: 'success', message: '열린 배너 상세를 최신 목록 상태로 갱신했습니다.' }); + } else if (decision.kind === 'close-removed') { + resetForm(); setIsEditorOpen(false); - setActionResult({ status: pending.mediaCleanupFailed ? 'warning' : 'success', message: pending.mediaCleanupFailed ? '배너 삭제를 확인했습니다. 연결 미디어 일부를 정리하지 못했습니다.' : '배너가 목록에서 삭제된 것을 확인했습니다.' }); + setActionResult({ status: 'warning', message: '선택한 배너가 목록에서 사라져 상세를 닫았습니다.' }); + } else if (decision.kind === 'preserve-conflict') { + if (current.editingBanner) { + setRefreshConflict({ + bannerId: current.editingBanner.id, + latest: result.data.find((banner) => banner.id === current.editingBanner?.id) ?? null, + }); + } + setActionResult({ + status: 'warning', + message: decision.removed + ? '편집 중인 배너가 목록에서 사라졌습니다. 입력을 보존했습니다. 다시 저장하지 말고 목록 상태를 확인해 주세요.' + : '편집 중인 배너가 다른 상태로 갱신되었습니다. 입력을 보존했습니다. 저장 전에 변경 내용을 다시 확인해 주세요.', + }); } - setPendingReadback(null); + }; + + const reopenRefreshConflict = () => { + if (!refreshConflict) return; + if (refreshConflict.latest) applyEditor(refreshConflict.latest); + else applyEditor(null, true); }; const inspectorContent = ( @@ -666,13 +929,17 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem } + {refreshConflict &&
+ 목록의 최신 배너 상태와 현재 편집 내용이 다릅니다. 현재 입력은 보존되며 다시 기준을 잡기 전에는 저장할 수 없습니다. + +
} {isEditorOpen && actionResult &&

{actionResult.message}

}

{!isEditorOpen ? '목록에서 배너를 선택하세요.' : isDirty ? '저장하지 않은 변경' : editingBanner ? '저장된 배너' : '제목과 표시 위치를 입력하세요.'}

- {isEditorOpen &&
} + {isEditorOpen &&
}
{pendingReadback &&
이전 변경의 상태를 먼저 확인해 주세요.
} @@ -745,7 +1012,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem {isDirty &&

변경 내용을 저장하거나 되돌린 후 삭제할 수 있습니다.

}
{ setBannerToDelete(editingBanner); setDeleteConfirmation(event.target.value); }} placeholder="배너삭제" className="bg-background" aria-label="배너 삭제 확인 문구" /> - @@ -769,7 +1036,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem )}
- 전체 {bannersLoading ? : formatBannerCount(sortedBanners.length, '개')} · 활성 {bannersLoading ? : formatBannerCount(activeBannerCount, '개')} · 비활성 {bannersLoading ? : formatBannerCount(inactiveBannerCount, '개')} · 데스크톱 {bannersLoading ? : formatBannerCount(sidebarTargetCount)} · 모바일 {bannersLoading ? : formatBannerCount(mobileTargetCount)}} actions={
@@ -790,13 +1057,13 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem
- - - - {(searchQuery || statusFilter !== 'all' || targetFilter !== 'all') && } + + + + {(searchQuery || statusFilter !== 'all' || targetFilter !== 'all') && }
{bannersError &&
배너 목록을 불러오지 못했습니다.{banners.length > 0 && ' 이전 목록을 표시합니다.'}
}
@@ -838,7 +1105,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem

{banner.title}

- {banner.is_active ? '활성' : '비활성'} + {banner.is_active ? '활성' : '비활성'}

{banner.description || '설명 없음'} · 우선순위 {banner.priority}

@@ -853,7 +1120,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem })} - + {visibleBanners.map((banner) => { const isSelected = editingBanner?.id === banner.id; const resolvedUrls = resolveAdBannerPersistenceUrls(banner); @@ -862,7 +1129,7 @@ function BannerManagementPage({ embedded, onInitialContentReady }: BannerManagem {resolvedUrls?.image_url ? : {banner.title}{banner.description || '설명 없음'} - + ; diff --git a/apps/web/app/admin/evaluations/page.tsx b/apps/web/app/admin/evaluations/page.tsx index 8d01b205b9..3a1d5be8b1 100644 --- a/apps/web/app/admin/evaluations/page.tsx +++ b/apps/web/app/admin/evaluations/page.tsx @@ -2316,9 +2316,6 @@ function AdminEvaluationPage({ {recordViewsInvalidated &&
변경 후 최신 정보를 다시 확인해야 합니다.
}
{!recordViewsInvalidated && !legacyEvaluationLoad && pageReadError && !showSubmissionView ?
검수 목록 조회 실패
: null} - {showSubmissionView && submissionInitialTab !== 'reviews' &&
- -
} {showSubmissionView ? ( /* 사용자 제보 목록 검수 뷰 */ setSubmissionEditorOpen(true)}>제보 수정 + ) : undefined} /> ) : isAlternateView ? ( ; + const title = typeof data.title === "string" ? data.title.trim() : ""; + const description = data.description === undefined || data.description === null + ? null + : typeof data.description === "string" ? data.description : null; + const displayTarget = Array.isArray(data.display_target) + && data.display_target.every((target): target is DisplayTarget => target === "sidebar" || target === "mobile_popup") + ? data.display_target + : null; + + if ( + !title + || title.length > 100 + || (data.description !== undefined && data.description !== null && typeof data.description !== "string") + || typeof data.is_active !== "boolean" + || !Number.isInteger(data.priority) + || (data.priority as number) < 0 + || (data.priority as number) > 1000 + || !displayTarget + ) return null; + + const urls = resolveAdBannerPersistenceUrls({ + image_url: data.image_url, + video_url: data.video_url, + media_type: data.media_type, + link_url: data.link_url, + }); + if (!urls) return null; + + return { + title, + description, + ...urls, + is_active: data.is_active, + priority: data.priority as number, + display_target: [...displayTarget], + }; +} + +async function readMutationBody(request: NextRequest) { + const parsed = await readBoundedJsonRequest(request, MAX_BANNER_MUTATION_REQUEST_BYTES); + if (!parsed.ok || !parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) return null; + return parsed.value as BannerMutationBody; +} + +async function readCurrentBanner( + supabase: ReturnType, + id: string, +) { + return supabase + .from("ad_banners") + .select(AD_BANNER_SELECT) + .eq("id", id) + .maybeSingle(); +} + +async function mutationMissResponse( + supabase: ReturnType, + id: string, +) { + const { data, error } = await readCurrentBanner(supabase, id); + if (error) return privateNoStoreJson({ code: "banner_readback_failed" }, { status: 500 }); + if (!data) return privateNoStoreJson({ code: "banner_not_found" }, { status: 404 }); + return privateNoStoreJson({ code: "banner_revision_conflict", banner: data }, { status: 409 }); +} + export async function GET() { const auth = await requireAdmin(); - if (!auth.ok) return auth.response; + if (!auth.ok) { + auth.response.headers.set("Cache-Control", PRIVATE_NO_STORE); + return auth.response; + } try { const supabase = createSupabaseServiceRoleClient(); @@ -32,13 +134,90 @@ export async function GET() { .select(AD_BANNER_SELECT) .order("priority", { ascending: false }); - if (error) { - return NextResponse.json({ code: "banner_read_failed" }, { status: 500 }); + if (error) return privateNoStoreJson({ code: "banner_read_failed" }, { status: 500 }); + return privateNoStoreJson({ banners: data ?? [] }); + } catch { + return privateNoStoreJson({ code: "banner_read_failed" }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + const auth = await requireAdmin(); + if (!auth.ok) { + auth.response.headers.set("Cache-Control", PRIVATE_NO_STORE); + return auth.response; + } + if (!isTrustedSameOriginMutation(request)) { + return privateNoStoreJson({ code: "banner_mutation_forbidden" }, { status: 403 }); + } + + try { + const body = await readMutationBody(request); + const id = body?.id; + const expectedUpdatedAt = body?.expectedUpdatedAt; + const data = parseBannerMutationData(body?.data); + if (!isUuid(id) || !isObservedRevision(expectedUpdatedAt) || !data) { + return privateNoStoreJson({ code: "banner_mutation_invalid" }, { status: 400 }); } - return NextResponse.json({ banners: data ?? [] }); + const supabase = createSupabaseServiceRoleClient(); + const { data: updated, error } = await supabase + .from("ad_banners") + .update({ + title: data.title, + description: data.description ?? null, + image_url: data.image_url ?? null, + video_url: data.video_url ?? null, + media_type: data.media_type, + link_url: data.link_url || null, + is_active: data.is_active, + priority: data.priority, + display_target: [...data.display_target], + }) + .eq("id", id) + .eq("updated_at", expectedUpdatedAt) + .select(AD_BANNER_SELECT) + .maybeSingle(); + + if (error) return privateNoStoreJson({ code: "banner_update_failed" }, { status: 500 }); + if (!updated) return mutationMissResponse(supabase, id); + return privateNoStoreJson({ banner: updated }); } catch { - return NextResponse.json({ code: "banner_read_failed" }, { status: 500 }); + return privateNoStoreJson({ code: "banner_update_failed" }, { status: 500 }); } +} +export async function DELETE(request: NextRequest) { + const auth = await requireAdmin(); + if (!auth.ok) { + auth.response.headers.set("Cache-Control", PRIVATE_NO_STORE); + return auth.response; + } + if (!isTrustedSameOriginMutation(request)) { + return privateNoStoreJson({ code: "banner_mutation_forbidden" }, { status: 403 }); + } + + try { + const body = await readMutationBody(request); + const id = body?.id; + const expectedUpdatedAt = body?.expectedUpdatedAt; + if (!isUuid(id) || !isObservedRevision(expectedUpdatedAt)) { + return privateNoStoreJson({ code: "banner_mutation_invalid" }, { status: 400 }); + } + + const supabase = createSupabaseServiceRoleClient(); + const { data: deleted, error } = await supabase + .from("ad_banners") + .delete() + .eq("id", id) + .eq("updated_at", expectedUpdatedAt) + .select(AD_BANNER_SELECT) + .maybeSingle(); + + if (error) return privateNoStoreJson({ code: "banner_delete_failed" }, { status: 500 }); + if (!deleted) return mutationMissResponse(supabase, id); + return privateNoStoreJson({ deletedBanner: deleted }); + } catch { + return privateNoStoreJson({ code: "banner_delete_failed" }, { status: 500 }); + } } diff --git a/apps/web/app/api/admin/ocr-receipts/process/route.ts b/apps/web/app/api/admin/ocr-receipts/process/route.ts index fda0470afd..eb20bda189 100644 --- a/apps/web/app/api/admin/ocr-receipts/process/route.ts +++ b/apps/web/app/api/admin/ocr-receipts/process/route.ts @@ -1,149 +1,22 @@ -import { spawn } from 'child_process'; -import * as crypto from 'crypto'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; import { NextResponse } from 'next/server'; -import { - downloadReviewVerification, - REVIEW_VERIFICATION_BUCKET, - LEGACY_REVIEW_VERIFICATION_BUCKET, -} from '@/lib/reviews/private-verification'; -import { canDiscardReceiptReplacement } from '@/lib/ocr/receipt-replacement-reconciliation'; -import { createAdminVerificationCleanupDependencies, drainAdminVerificationCleanup, uploadStagedAdminVerificationImage } from '@/lib/reviews/admin-verification-cleanup'; -import { buildReviewPhotoObjectPath, getOwnedReviewPhotoObjectPath } from '@/lib/review-photo-url'; - -import { - PRIVACY_UNSAFE_VALUE_REASON, - PrivacyUnsafeValueError, - assertPrivacySafe, -} from '@/lib/privacy/sanitize'; import { requireAdmin } from '@/lib/auth/require-admin'; import { - GUARDED_MUTATION_CONFIRMATION, buildGuardedMutationRequiredResponse, isGuardedMutationConfirmationValid, isInlineOcrProcessEnabled, - getGuardedMutationErrorName, } from '@/lib/admin/guarded-mutation-contract'; -import { - AdminReceiptImageSecurityError, - canonicalizeReceiptImage, - cleanupAdminReceiptTempRun, - createAdminReceiptTempRun, - createPrivateReceiptTempDirectory, - downloadPrivateReceiptObject, - getReceiptImageMimeTypeFromSignature, - readContainedPrivateReceiptFile, - writeExclusivePrivateReceiptFile, -} from '@/lib/ocr/admin-receipt-image-security'; -import { callGeminiReceiptOcr } from '@/lib/ocr/gemini'; -import type { ReceiptOcrData } from '@/lib/ocr/types'; import { readBoundedJsonRequest } from '@/lib/security/bounded-json-request'; import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; -import { createSupabaseServiceRoleClient } from '@/lib/supabase/service-role'; -import { createSupabaseStorageServerClient } from '@/lib/supabase/storage-server'; +import { FEATURE_DATABASE_HELD_CODE, FEATURE_DATABASE_HELD_MESSAGE } from '@/lib/runtime-feature-readiness-contract'; +import { readFeatureDatabaseReadiness } from '@/lib/server/runtime-feature-readiness'; +import { processAdminReceiptOcr } from '@/lib/ocr/admin-receipt-processor'; export const runtime = 'nodejs'; - -const GEMINI_API_KEY = process.env.GEMINI_API_KEY!; const MAX_REQUEST_BYTES = 4 * 1024; -const MAX_PREPROCESS_RESULT_BYTES = 16 * 1024; -const PREPROCESS_DEADLINE_MS = 30_000; -const MAX_RECEIPT_TEXT_LENGTH = 120; -const MAX_RECEIPT_ITEMS = 30; -const MAX_RECEIPT_AMOUNT = 10_000_000; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; -const TIME_PATTERN = /^\d{2}:\d{2}$/; -const PHONE_LIKE_PATTERN = /(?:\+?82[-.\s]?)?0?1[0-9][-.\s]?\d{3,4}[-.\s]?\d{4}|0[2-6][-.\s]?\d{3,4}[-.\s]?\d{4}/; -const RECEIPT_SCHEMA_VERSION = 'receipt_summary_v1'; -const SAFE_OCR_FAILURE_CODES = new Set([ - 'not_receipt', - 'unreadable', - 'low_quality', - 'low_confidence', - 'ocr_failed', -]); -const OCR_ERROR_MESSAGES = { - INVALID_REQUEST: 'OCR 요청 정보를 확인할 수 없습니다.', - OCR_PROCESSING_FAILED: 'OCR 처리에 실패했습니다.', - OCR_PROVIDER_UNAVAILABLE: 'OCR 제공자를 사용할 수 없습니다.', - OCR_READBACK_FAILED: 'OCR 처리 결과를 확인하지 못했습니다.', - PRIVACY_UNSAFE_VALUE: '민감정보가 포함된 OCR 결과는 저장할 수 없습니다.', - RECEIPT_NOT_FOUND: '영수증 사진이 없는 리뷰입니다.', - REVIEW_NOT_FOUND: '리뷰를 찾을 수 없습니다.', -} as const; - +const OCR_ERROR_MESSAGES = { INVALID_REQUEST: 'OCR 요청 정보를 확인할 수 없습니다.' } as const; type OcrErrorCode = keyof typeof OCR_ERROR_MESSAGES; -type InlineOcrProcessBody = { - reviewId: string; - guardedMutationConfirmation: string; -}; -type ReceiptItem = { - name: string; - price: number | null; -}; -type ReceiptPersistenceData = { - schema_version: typeof RECEIPT_SCHEMA_VERSION; - status: 'processed'; - store_name?: string; - date?: string; - time?: string; - total_amount?: number; - category?: string; - items?: ReceiptItem[]; - item_count: number; - confidence?: number; - duplicate_of?: string; -}; -type ReceiptFailureData = { - schema_version: typeof RECEIPT_SCHEMA_VERSION; - status: 'failed'; - failure_code: string; -}; -type OcrReviewUpdate = { - receipt_hash: string | null; - receipt_data: ReceiptPersistenceData | ReceiptFailureData; - is_duplicate: boolean; - ocr_processed_at: string; -}; - -class OcrPersistenceError extends Error {} -class OcrUnsafeReceiptValueError extends Error {} - -const routeDirname = path.dirname(fileURLToPath(import.meta.url)); -const preprocessScriptPath = path.resolve( - /* turbopackIgnore: true */ routeDirname, - '../../../../../../../backend/geminiCLI-ocr-receipts/preprocess_receipt.py', -); - -const OCR_PROMPT = `한국 음식점 영수증/배달앱 주문서 OCR 전문가입니다. - -## 핵심 지침 - -### 1. 가게명 추출 (가장 중요!) -- 배달앱(쿠팡이츠, 배달의민족, 요기요 등) 영수증은 "주문매장", "가맹점", "상호" 필드를 우선 참조하세요. -- 일반 영수증은 상단 로고/상호명 영역에서 추출하세요. - -### 2. 메뉴 항목 완전 추출 -- 모든 주문 항목을 items 배열에 이름과 가격으로 포함하세요. -- 옵션, 변경사항, 이벤트와 0원 서비스 항목도 포함하세요. - -### 3. 금액 및 시간 추출 -- 총결제금액, 합계, 결제금액 필드를 우선 참조하세요. -- date는 YYYY-MM-DD, time은 HH:MM 형식으로 반환하세요. - -## 응답 형식 (JSON만 반환) -성공 시: { "store_name": "가게명", "date": "YYYY-MM-DD", "time": "HH:MM", "total_amount": 0, "items": [{ "name": "메뉴명", "price": 0 }], "confidence": 0.0 } -실패 시: { "error": "not_receipt / unreadable / low_quality", "confidence": 0.0 }`; - -function getSupabaseAdmin() { - return createSupabaseServiceRoleClient(); -} - -function getSupabaseStorageAdmin() { - return createSupabaseStorageServerClient(); -} +type InlineOcrProcessBody = { reviewId: string; guardedMutationConfirmation: string }; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); @@ -186,398 +59,15 @@ function hasGuardedMutationConfirmation(body: InlineOcrProcessBody): boolean { return isGuardedMutationConfirmationValid(body.guardedMutationConfirmation); } -function buildInlineOcrGuardedMutation( - correlationId: string, - readback: Record, -) { - return { - domain: 'ocr_receipt', - action: 'inline_process', - confirmation: GUARDED_MUTATION_CONFIRMATION, - readback, - audit: { - source: 'local-inline-ocr-process', - correlationId, - }, - }; -} - -function getSafeOcrFailureCode(value: unknown): string { - return typeof value === 'string' && SAFE_OCR_FAILURE_CODES.has(value) - ? value - : 'ocr_failed'; -} - -function normalizeReceiptText(value: unknown, maxLength = MAX_RECEIPT_TEXT_LENGTH): string | undefined { - if (value === undefined) return undefined; - if (typeof value !== 'string') throw new OcrUnsafeReceiptValueError(); - - const normalized = value - .replace(/[\u0000-\u001F\u007F]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (!normalized || normalized.length > maxLength || PHONE_LIKE_PATTERN.test(normalized)) { - throw new OcrUnsafeReceiptValueError(); - } - return normalized; -} - -function normalizeReceiptAmount(value: unknown): number | undefined { - if (value === undefined) return undefined; - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value > MAX_RECEIPT_AMOUNT) { - throw new OcrUnsafeReceiptValueError(); - } - return value; -} - -function normalizeReceiptItems(value: unknown): ReceiptItem[] | undefined { - if (value === undefined) return undefined; - if (!Array.isArray(value) || value.length > MAX_RECEIPT_ITEMS) { - throw new OcrUnsafeReceiptValueError(); - } - - return value.map((item) => { - if (!isRecord(item)) throw new OcrUnsafeReceiptValueError(); - const name = normalizeReceiptText(item.name); - const price = item.price === null ? null : normalizeReceiptAmount(item.price); - if (!name || price === undefined) { - throw new OcrUnsafeReceiptValueError(); - } - return { name, price }; - }); -} - -function normalizeReceiptConfidence(value: unknown): number | undefined { - if (value === undefined) return undefined; - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) { - throw new OcrUnsafeReceiptValueError(); - } - return Math.round(value * 100) / 100; -} - -function buildReceiptPersistenceData(ocrData: ReceiptOcrData): ReceiptPersistenceData { - const storeName = normalizeReceiptText(ocrData.store_name); - const date = normalizeReceiptText(ocrData.date, 10); - const time = normalizeReceiptText(ocrData.time, 5); - const totalAmount = normalizeReceiptAmount(ocrData.total_amount); - const category = normalizeReceiptText(ocrData.category, 32); - const items = normalizeReceiptItems(ocrData.items); - const confidence = normalizeReceiptConfidence(ocrData.confidence); - - if ((date && !DATE_PATTERN.test(date)) || (time && !TIME_PATTERN.test(time))) { - throw new OcrUnsafeReceiptValueError(); - } - - const receiptData: ReceiptPersistenceData = { - schema_version: RECEIPT_SCHEMA_VERSION, - status: 'processed', - item_count: items?.length ?? 0, - }; - if (storeName) receiptData.store_name = storeName; - if (date) receiptData.date = date; - if (time) receiptData.time = time; - if (totalAmount !== undefined) receiptData.total_amount = totalAmount; - if (category) receiptData.category = category; - if (items?.length) receiptData.items = items; - if (confidence !== undefined) receiptData.confidence = confidence; - - assertPrivacySafe(receiptData); - return receiptData; -} - -function buildReceiptFailureData(errorCode: string): ReceiptFailureData { - const receiptData: ReceiptFailureData = { - schema_version: RECEIPT_SCHEMA_VERSION, - status: 'failed', - failure_code: errorCode, - }; - assertPrivacySafe(receiptData); - return receiptData; -} - -function assertOcrInputSafe(ocrData: ReceiptOcrData): void { - assertPrivacySafe({ - store_name: ocrData.store_name, - date: ocrData.date, - time: ocrData.time, - total_amount: ocrData.total_amount, - category: ocrData.category, - review_draft: ocrData.review_draft, - items: ocrData.items, - confidence: ocrData.confidence, - }); -} - -function generateReceiptHash(data: ReceiptPersistenceData): string { - const hashInput = `${data.store_name}|${data.date}|${data.time}|${data.total_amount}`; - return crypto.createHash('sha256').update(hashInput).digest('hex'); -} - -function hasExpectedReceiptData( - value: unknown, - expected: ReceiptPersistenceData | ReceiptFailureData, -): boolean { - if (!isRecord(value)) return false; - assertPrivacySafe(value); - - const expectedEntries = Object.entries(expected); - const actualKeys = Object.keys(value); - return actualKeys.length === expectedEntries.length - && expectedEntries.every( - ([key, expectedValue]) => Object.prototype.hasOwnProperty.call(value, key) - && JSON.stringify(value[key]) === JSON.stringify(expectedValue), - ); -} - -function hasExpectedReviewReadback( - value: unknown, - reviewId: string, - expected: OcrReviewUpdate, -): boolean { - return isRecord(value) - && value.id === reviewId - && value.receipt_hash === expected.receipt_hash - && value.is_duplicate === expected.is_duplicate - && typeof value.ocr_processed_at === 'string' - && Date.parse(value.ocr_processed_at) === Date.parse(expected.ocr_processed_at) - && hasExpectedReceiptData(value.receipt_data, expected.receipt_data); -} - -async function persistOcrResult( - supabase: ReturnType, - reviewId: string, - update: OcrReviewUpdate, -): Promise { - assertPrivacySafe(update); - - const { data: readback, error } = await supabase - .from('reviews') - .update(update) - .eq('id', reviewId) - .select('id, receipt_hash, receipt_data, is_duplicate, ocr_processed_at') - .maybeSingle(); - - if (error || !hasExpectedReviewReadback(readback, reviewId, update)) { - throw new OcrPersistenceError(); - } -} - -type PreprocessResult = { - warped?: string; -}; - -function runPythonPreprocess(inputPath: string, outputDir: string): Promise { - return new Promise((resolve, reject) => { - const python = process.platform === 'win32' ? 'python' : 'python3'; - const proc = spawn(python, [/* turbopackIgnore: true */ preprocessScriptPath, inputPath, outputDir]); - const stdoutChunks: Buffer[] = []; - let stdoutBytes = 0; - let settled = false; - - const fail = () => { - if (settled) return; - settled = true; - clearTimeout(timeout); - reject(new OcrPersistenceError()); - }; - const succeed = (result: PreprocessResult) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - resolve(result); - }; - const timeout = setTimeout(() => { - try { - proc.kill(); - } catch { - // The fixed preprocessing deadline has already failed closed. - } - fail(); - }, PREPROCESS_DEADLINE_MS); - - proc.stdout.on('data', (chunk: Buffer) => { - if (settled) return; - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - stdoutBytes += bytes.byteLength; - if (stdoutBytes > MAX_PREPROCESS_RESULT_BYTES) { - try { - proc.kill(); - } catch { - // The bounded stdout rejection is already final. - } - fail(); - return; - } - stdoutChunks.push(bytes); - }); - proc.stderr.on('data', () => { - // Drain stderr without retaining potentially sensitive receipt text. - }); - proc.on('error', fail); - proc.on('close', (code) => { - if (settled) return; - if (code !== 0) { - fail(); - return; - } - try { - const result = JSON.parse(Buffer.concat(stdoutChunks, stdoutBytes).toString('utf8').trim()) as unknown; - if (!isRecord(result) || Object.keys(result).some((key) => key !== 'warped')) { - fail(); - return; - } - if (result.warped === undefined) { - succeed({}); - return; - } - if ( - typeof result.warped !== 'string' - || result.warped.length === 0 - || result.warped.length > 1_024 - ) { - fail(); - return; - } - succeed({ warped: result.warped }); - } catch { - fail(); - } - }); - }); -} - -function assertSafeReceiptObjectPath(value: unknown): asserts value is string { - if ( - typeof value !== 'string' - || value.length === 0 - || value.length > 1_024 - || value.startsWith('/') - || value.includes('\\') - || value.includes('\u0000') - || value.split('/').some((segment) => !segment || segment === '.' || segment === '..') - ) { - throw new OcrPersistenceError(); - } -} - -function buildReplacementReceiptObjectPath(oldObjectPath: string, reviewId: string): string { - const ownerId = oldObjectPath.split('/')[0]; - const newObjectPath = buildReviewPhotoObjectPath({ ownerId, reviewId, purpose: 'verification' }, `ocr-${crypto.randomUUID()}.jpg`); - if (!newObjectPath) throw new OcrPersistenceError(); - assertSafeReceiptObjectPath(newObjectPath); - return newObjectPath; -} - -function hasExpectedReplacementReadback( - value: unknown, - reviewId: string, - expectedObjectPath: string, -): boolean { - return isRecord(value) - && value.id === reviewId - && value.verification_photo === expectedObjectPath; -} - -async function replaceReceiptWithCompressedObject( - supabase: ReturnType, - storageAdmin: ReturnType, - reviewId: string, - oldStoredValue: string, - oldObjectPath: string, - canonicalImage: Buffer, -): Promise { - const newObjectPath = buildReplacementReceiptObjectPath(oldObjectPath, reviewId); - const storage = storageAdmin.from(REVIEW_VERIFICATION_BUCKET); - const cleanup = createAdminVerificationCleanupDependencies(supabase, storageAdmin); - let databaseUpdated = false; - let replacementRemoved = false; - let replacementStateIndeterminate = false; - let uploadConfirmed = false; - - try { - const accepted = await uploadStagedAdminVerificationImage(cleanup, reviewId, oldObjectPath.split('/')[0], newObjectPath, () => storage.upload(newObjectPath, canonicalImage, { - cacheControl: '3600', - contentType: 'image/jpeg', - upsert: false, - })); - if (!accepted) { - replacementStateIndeterminate = true; - throw new OcrPersistenceError(); - } - uploadConfirmed = true; - - const uploadedImage = await downloadPrivateReceiptObject(() => storage.download(newObjectPath)); - if ( - uploadedImage.mimeType !== 'image/jpeg' - || uploadedImage.bytes.byteLength !== canonicalImage.byteLength - || !uploadedImage.bytes.equals(canonicalImage) - ) { - throw new OcrPersistenceError(); - } - - // SQL integration dependency: the authoritative review-media UPDATE - // trigger must enqueue the old normalized key in the same transaction - // that replaces this reference. A transport error is never rollback - // proof, even when a following read still observes the old value. - let definiteNoMatch = false; - try { - const { data: updateReadback, error: updateError } = await supabase - .from('reviews') - .update({ verification_photo: newObjectPath }) - .eq('id', reviewId) - .eq('verification_photo', oldStoredValue) - .select('id, verification_photo') - .maybeSingle(); - if (!updateError && hasExpectedReplacementReadback(updateReadback, reviewId, newObjectPath)) { - databaseUpdated = true; - } else if (!updateError && updateReadback === null) { - definiteNoMatch = true; - } - } catch { /* authoritative readback below */ } - - if (!databaseUpdated) { - const { data: currentReadback, error: currentReadbackError } = await supabase - .from('reviews') - .select('id, verification_photo') - .eq('id', reviewId) - .maybeSingle(); - - if (!currentReadbackError && hasExpectedReplacementReadback(currentReadback, reviewId, newObjectPath)) { - databaseUpdated = true; - } else if (canDiscardReceiptReplacement( - definiteNoMatch, Boolean(currentReadbackError), currentReadback, reviewId, newObjectPath, - )) { - if (!(await drainAdminVerificationCleanup(cleanup, reviewId, oldObjectPath.split('/')[0]))) { - throw new OcrPersistenceError(); - } - replacementRemoved = true; - } else { - replacementStateIndeterminate = true; - } - if (!databaseUpdated) throw new OcrPersistenceError(); - } - - // The authoritative review-media UPDATE enqueues the original. Only - // its durable retirement fence may remove it after all references end. - // A privileged direct removal here would break another legacy review. - return newObjectPath; - } catch { - if ( - uploadConfirmed - && !databaseUpdated - && !replacementRemoved - && !replacementStateIndeterminate - && !(await drainAdminVerificationCleanup(cleanup, reviewId, oldObjectPath.split('/')[0])) - ) { - throw new OcrPersistenceError(); - } - throw new OcrPersistenceError(); - } -} - export async function POST(request: Request) { const auth = await requireAdmin(); if (!auth.ok) return auth.response; + if ((await readFeatureDatabaseReadiness({ ownerId: auth.userId })).status !== 'ready') { + return noStoreJson( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 409 }, + ); + } if (!isTrustedSameOriginMutation(request)) { return noStoreJson( @@ -613,198 +103,5 @@ export async function POST(request: Request) { ); } - const correlationId = `ocr-inline-${crypto.randomUUID()}`; - - if (!GEMINI_API_KEY?.trim()) { - return NextResponse.json( - { - code: 'OCR_PROVIDER_UNAVAILABLE', - error: OCR_ERROR_MESSAGES.OCR_PROVIDER_UNAVAILABLE, - guardedMutation: buildInlineOcrGuardedMutation(correlationId, { - reviewId: body.reviewId, - applied: false, - providerConfigured: false, - }), - }, - { status: 503, headers: { 'Cache-Control': 'no-store' } }, - ); - } - - const supabase = getSupabaseAdmin(); - const storageAdmin = getSupabaseStorageAdmin(); - let tempRun: ReturnType | null = null; - - try { - const { data: review, error: fetchError } = await supabase - .from('reviews') - .select('id, user_id, verification_photo') - .eq('id', body.reviewId) - .single(); - - if (fetchError || !review) return errorResponse('REVIEW_NOT_FOUND', 404); - if (!review.verification_photo) return errorResponse('RECEIPT_NOT_FOUND', 400); - const receiptObjectPath = getOwnedReviewPhotoObjectPath(review.verification_photo, { - ownerId: review.user_id, - reviewId: body.reviewId, - purpose: 'verification', - }); - if (!receiptObjectPath) return errorResponse('RECEIPT_NOT_FOUND', 400); - assertSafeReceiptObjectPath(receiptObjectPath); - - const cleanup = createAdminVerificationCleanupDependencies(supabase, storageAdmin); - // Reconcile prior committed replacements before another download/provider - // call. An absent migration or pending public object fails closed. - if (!await drainAdminVerificationCleanup(cleanup, body.reviewId, review.user_id)) throw new OcrPersistenceError(); - - const receiptDownload = await downloadReviewVerification(storageAdmin, receiptObjectPath); - const downloadedImage = await downloadPrivateReceiptObject(() => Promise.resolve(receiptDownload)); - const canonicalStorageImage = await canonicalizeReceiptImage( - downloadedImage.bytes, - downloadedImage.mimeType, - ); - if (receiptDownload.bucket === LEGACY_REVIEW_VERIFICATION_BUCKET - || canonicalStorageImage.bytes.byteLength < downloadedImage.bytes.byteLength) { - await replaceReceiptWithCompressedObject( - supabase, - storageAdmin, - body.reviewId, - review.verification_photo, - receiptObjectPath, - canonicalStorageImage.bytes, - ); - if (!await drainAdminVerificationCleanup(cleanup, body.reviewId, review.user_id)) throw new OcrPersistenceError(); - } - - tempRun = createAdminReceiptTempRun(); - const tempInputPath = writeExclusivePrivateReceiptFile( - tempRun, - 'input.jpg', - canonicalStorageImage.bytes, - ); - const preprocessOutputDir = createPrivateReceiptTempDirectory(tempRun, 'stages'); - - let preprocessResult: PreprocessResult; - try { - preprocessResult = await runPythonPreprocess(tempInputPath, preprocessOutputDir); - } catch { - preprocessResult = {}; - } - - let providerImage = canonicalStorageImage; - if (preprocessResult.warped) { - const preprocessedBytes = readContainedPrivateReceiptFile(tempRun, preprocessResult.warped); - const preprocessedMimeType = getReceiptImageMimeTypeFromSignature(preprocessedBytes); - if (!preprocessedMimeType) throw new OcrPersistenceError(); - providerImage = await canonicalizeReceiptImage(preprocessedBytes, preprocessedMimeType); - } - - const imageBase64 = providerImage.bytes.toString('base64'); - const ocrResult = await callGeminiReceiptOcr({ - apiKey: GEMINI_API_KEY, - imageBase64, - mimeType: 'image/jpeg', - prompt: OCR_PROMPT, - signal: request.signal, - }); - const ocrData = ocrResult.data; - - assertOcrInputSafe(ocrData); - const receiptData = buildReceiptPersistenceData(ocrData); - const confidence = receiptData.confidence ?? 0; - - if (ocrData.error || confidence < 0.5) { - const failureData = buildReceiptFailureData( - ocrData.error ? getSafeOcrFailureCode(ocrData.error) : 'low_confidence', - ); - await persistOcrResult(supabase, body.reviewId, { - receipt_hash: null, - receipt_data: failureData, - is_duplicate: false, - ocr_processed_at: new Date().toISOString(), - }); - - return NextResponse.json( - { - success: false, - code: failureData.failure_code, - message: 'OCR 처리 실패', - guardedMutation: buildInlineOcrGuardedMutation(correlationId, { - reviewId: body.reviewId, - applied: true, - readbackVerified: true, - status: 'failed', - }), - }, - { headers: { 'Cache-Control': 'no-store' } }, - ); - } - - const hasHashInputs = receiptData.store_name - && receiptData.date - && receiptData.time - && receiptData.total_amount !== undefined; - const receiptHash = hasHashInputs ? generateReceiptHash(receiptData) : null; - let duplicateOfId: string | null = null; - - if (receiptHash) { - const { data: existingReviews, error: duplicateLookupError } = await supabase - .from('reviews') - .select('id') - .eq('receipt_hash', receiptHash) - .neq('id', body.reviewId) - .limit(1); - if (duplicateLookupError) throw new OcrPersistenceError(); - - const candidateId = existingReviews?.[0]?.id; - if (candidateId !== undefined) { - if (typeof candidateId !== 'string' || !UUID_PATTERN.test(candidateId)) { - throw new OcrPersistenceError(); - } - duplicateOfId = candidateId; - } - } - - if (duplicateOfId) receiptData.duplicate_of = duplicateOfId; - assertPrivacySafe(receiptData); - - await persistOcrResult(supabase, body.reviewId, { - receipt_hash: duplicateOfId ? null : receiptHash, - receipt_data: receiptData, - is_duplicate: Boolean(duplicateOfId), - ocr_processed_at: new Date().toISOString(), - }); - - return NextResponse.json( - { - success: true, - isDuplicate: Boolean(duplicateOfId), - message: duplicateOfId ? 'OCR 처리 완료 (중복 의심)' : 'OCR 처리 완료', - guardedMutation: buildInlineOcrGuardedMutation(correlationId, { - reviewId: body.reviewId, - applied: true, - readbackVerified: true, - status: 'processed', - isDuplicate: Boolean(duplicateOfId), - }), - }, - { headers: { 'Cache-Control': 'no-store' } }, - ); - } catch (error) { - console.error('[admin/ocr-receipts/process] inline process failed', { - domain: 'ocr_receipt', - action: 'inline_process', - step: 'unexpected', - correlationId, - errorName: getGuardedMutationErrorName(error), - }); - if (error instanceof PrivacyUnsafeValueError || error instanceof OcrUnsafeReceiptValueError) { - return errorResponse(PRIVACY_UNSAFE_VALUE_REASON, 422); - } - if (error instanceof AdminReceiptImageSecurityError || error instanceof OcrPersistenceError) { - return errorResponse('OCR_READBACK_FAILED', 503); - } - return errorResponse('OCR_PROCESSING_FAILED', 500); - } finally { - if (tempRun) cleanupAdminReceiptTempRun(tempRun); - } + return processAdminReceiptOcr({ reviewId: body.reviewId, signal: request.signal }); } diff --git a/apps/web/app/api/admin/ocr-receipts/rerun/route.ts b/apps/web/app/api/admin/ocr-receipts/rerun/route.ts index 887960521d..291014523c 100644 --- a/apps/web/app/api/admin/ocr-receipts/rerun/route.ts +++ b/apps/web/app/api/admin/ocr-receipts/rerun/route.ts @@ -1,10 +1,6 @@ -/** - * 단일 리뷰 OCR 재실행 API - * - * POST /api/admin/ocr-receipts/rerun - * - 해당 리뷰의 OCR 데이터를 초기화하고 GitHub Actions 워크플로우 트리거 - */ +/** Single-review OCR rerun and explicit indeterminate-result recovery. */ +import crypto from 'node:crypto'; import { NextResponse } from 'next/server'; import { requireAdmin } from '@/lib/auth/require-admin'; import { createSupabaseServiceRoleClient } from '@/lib/supabase/service-role'; @@ -16,24 +12,54 @@ import { } from '@/lib/admin/guarded-mutation-contract'; import { readBoundedJsonRequest } from '@/lib/security/bounded-json-request'; import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; +import { FEATURE_DATABASE_HELD_CODE, FEATURE_DATABASE_HELD_MESSAGE } from '@/lib/runtime-feature-readiness-contract'; +import { readFeatureDatabaseReadiness } from '@/lib/server/runtime-feature-readiness'; +import { + buildReceiptOcrWorkflowInputs, + buildBoundReceiptOcrRecoveryWorkflowInputs, + dispatchReceiptOcrWorkflow, + RECEIPT_OCR_WORKFLOW_FILE, +} from '@/lib/ocr/workflow-dispatch'; +import { + isResetReceiptOcrState, + resetReceiptOcrRowWithCas, +} from '@/lib/ocr/admin-receipt-reset'; +import { + ReceiptOcrRecoveryCapabilityHeldError, + ReceiptOcrRecoveryPersistenceError, + applyReceiptOcrIndeterminateRecovery, + assertReceiptOcrRecoveryCapabilityReady, + isReceiptOcrRecoveryOperationId, + isReceiptOcrRecoverySnapshotHash, + previewReceiptOcrIndeterminateRecovery, + type ReceiptOcrRecoveryApply, + ADMIN_RECEIPT_OCR_PREPROCESS_VERSION, + ADMIN_RECEIPT_OCR_PROMPT_VERSION, +} from '@/lib/ocr/admin-receipt-recovery'; +import { RECEIPT_OCR_NORMALIZATION_VERSION } from '@/lib/ocr/receipt-normalization'; export const runtime = 'nodejs'; -// 환경 변수 const GITHUB_TOKEN = process.env.GITHUB_TOKEN!; const GITHUB_OWNER = process.env.GITHUB_OWNER!; const GITHUB_REPO = process.env.GITHUB_REPO!; - const MAX_OCR_RERUN_REQUEST_BYTES = 4 * 1024; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PREVIEW_ACTION = 'preview_provider_indeterminate' as const; +const RECOVERY_ACTION = 'recover_provider_indeterminate' as const; + +type RerunRequest = { + action?: typeof PREVIEW_ACTION | typeof RECOVERY_ACTION; + reviewId?: string; + recoveryOperationId?: string; + expectedGeneration?: string; + inputSnapshotHash?: string; + guardedMutationConfirmation?: string; +}; -function hasGuardedMutationConfirmation( - request: Request, - body: { guardedMutationConfirmation?: string }, -): boolean { - return ( - isGuardedMutationConfirmationValid(body.guardedMutationConfirmation) || - isGuardedMutationConfirmationValid(request.headers.get('x-admin-guarded-mutation-confirmation')) - ); +function hasGuardedMutationConfirmation(request: Request, body: RerunRequest): boolean { + return isGuardedMutationConfirmationValid(body.guardedMutationConfirmation) + || isGuardedMutationConfirmationValid(request.headers.get('x-admin-guarded-mutation-confirmation')); } function noStoreJson(body: unknown, init: ResponseInit = {}) { @@ -42,6 +68,19 @@ function noStoreJson(body: unknown, init: ResponseInit = {}) { return response; } +function recoveryConflictCode(status: Exclude) { + if (status === 'processing') return 'OCR_RECOVERY_PROCESSING'; + if (status === 'not_indeterminate') return 'OCR_RECOVERY_NOT_INDETERMINATE'; + if (status === 'stale_operation') return 'OCR_RECOVERY_OPERATION_STALE'; + return 'OCR_RECOVERY_CONFLICT'; +} + +function recoveryConflictMessage(status: Exclude) { + if (status === 'processing') return '기존 OCR 처리가 진행 중입니다. 처리 상태를 확인해 주세요.'; + if (status === 'not_indeterminate') return '복구할 불확실 OCR 결과가 확인되지 않았습니다.'; + return 'OCR 복구 상태가 변경되었습니다. 최신 상태를 다시 확인해 주세요.'; +} + export async function POST(request: Request) { try { const auth = await requireAdmin(); @@ -49,234 +88,329 @@ export async function POST(request: Request) { auth.response.headers.set('Cache-Control', 'no-store'); return auth.response; } - - if (!isTrustedSameOriginMutation(request)) { + if ((await readFeatureDatabaseReadiness({ ownerId: auth.userId })).status !== 'ready') { return noStoreJson( - { error: '요청을 처리할 수 없습니다.' }, - { status: 403 } + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 409 }, ); } + if (!isTrustedSameOriginMutation(request)) { + return noStoreJson({ error: '요청을 처리할 수 없습니다.' }, { status: 403 }); + } const requestBody = await readBoundedJsonRequest(request, MAX_OCR_RERUN_REQUEST_BYTES); - if ( - !requestBody.ok || - !requestBody.value || - typeof requestBody.value !== 'object' || - Array.isArray(requestBody.value) - ) { - return noStoreJson( - { error: '요청 본문이 올바르지 않습니다.' }, - { status: 400 } - ); + if (!requestBody.ok || !requestBody.value || typeof requestBody.value !== 'object' + || Array.isArray(requestBody.value)) { + return noStoreJson({ error: '요청 본문이 올바르지 않습니다.' }, { status: 400 }); + } + const body = requestBody.value as RerunRequest; + const reviewId = body.reviewId; + if (!reviewId || !UUID.test(reviewId) + || (body.action !== undefined && body.action !== PREVIEW_ACTION && body.action !== RECOVERY_ACTION)) { + return noStoreJson({ error: '요청 본문이 올바르지 않습니다.' }, { status: 400 }); } - const body = requestBody.value as { - reviewId?: string; - guardedMutationConfirmation?: string; - }; - const { reviewId } = body; - if (!reviewId) { - return noStoreJson( - { error: '리뷰 ID가 필요합니다.' }, - { status: 400 } - ); + const supabase = createSupabaseServiceRoleClient(); + if (body.action === PREVIEW_ACTION) { + try { + const recovery = await previewReceiptOcrIndeterminateRecovery(supabase, reviewId); + return noStoreJson({ success: true, recovery }); + } catch (error) { + if (error instanceof ReceiptOcrRecoveryCapabilityHeldError) { + return noStoreJson( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 503 }, + ); + } + throw error; + } + } + + try { + await assertReceiptOcrRecoveryCapabilityReady(supabase); + } catch (error) { + if (error instanceof ReceiptOcrRecoveryCapabilityHeldError) { + return noStoreJson( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 503 }, + ); + } + throw error; } if (!hasGuardedMutationConfirmation(request, body)) { return noStoreJson( - buildGuardedMutationRequiredResponse('ocr_receipt', 'rerun'), - { status: 400 } + buildGuardedMutationRequiredResponse( + 'ocr_receipt', + body.action === RECOVERY_ACTION ? 'recover_provider_indeterminate' : 'rerun', + ), + { status: 400 }, ); } + if (body.action === RECOVERY_ACTION + && (!isReceiptOcrRecoveryOperationId(body.recoveryOperationId) + || !isReceiptOcrRecoveryOperationId(body.expectedGeneration) + || !isReceiptOcrRecoverySnapshotHash(body.inputSnapshotHash))) { + return noStoreJson({ error: 'OCR 복구 요청이 올바르지 않습니다.' }, { status: 400 }); + } - const correlationId = `ocr-rerun-${reviewId}-${Date.now()}`; - - const supabase = createSupabaseServiceRoleClient(); - - // 1. 리뷰 존재 여부 확인 및 롤백용 OCR 스냅샷 확보 + const correlationId = crypto.randomUUID(); const { data: review, error: fetchError } = await supabase .from('reviews') .select('id, verification_photo, ocr_processed_at, receipt_data, receipt_hash, is_duplicate') .eq('id', reviewId) .single(); - if (fetchError || !review) { - return NextResponse.json( - { error: '리뷰를 찾을 수 없습니다.' }, - { status: 404 } - ); + return noStoreJson({ error: '리뷰를 찾을 수 없습니다.' }, { status: 404 }); } - if (!review.verification_photo) { - return NextResponse.json( - { error: '영수증 사진이 없는 리뷰입니다.' }, - { status: 400 } + return noStoreJson({ error: '영수증 사진이 없는 리뷰입니다.' }, { status: 400 }); + } + + const previousOcrState = { + ocr_processed_at: review.ocr_processed_at, + receipt_data: review.receipt_data, + receipt_hash: review.receipt_hash, + is_duplicate: review.is_duplicate, + }; + const resetAlreadyPending = isResetReceiptOcrState({ id: reviewId, ...previousOcrState }); + if (body.action === RECOVERY_ACTION && !resetAlreadyPending) { + return noStoreJson( + { code: 'OCR_RECOVERY_CONFLICT', error: 'OCR 복구 상태가 변경되었습니다. 최신 상태를 다시 확인해 주세요.' }, + { status: 409 }, ); } + if (body.action === undefined && resetAlreadyPending) { + try { + const recovery = await previewReceiptOcrIndeterminateRecovery(supabase, reviewId); + if (recovery.status !== 'not_indeterminate') { + const code = recovery.status === 'processing' + ? 'OCR_RECOVERY_PROCESSING' + : recovery.status === 'eligible' || recovery.status === 'recovery_in_progress' + ? 'OCR_RECOVERY_REQUIRED' + : 'OCR_RECOVERY_CONFLICT'; + const error = recovery.status === 'processing' + ? '기존 OCR 처리가 진행 중입니다. 처리 상태를 확인해 주세요.' + : recovery.status === 'eligible' || recovery.status === 'recovery_in_progress' + ? '불확실 OCR 결과는 복구 확인 후 다시 실행할 수 있습니다.' + : 'OCR 복구 상태가 변경되었습니다. 최신 상태를 다시 확인해 주세요.'; + return noStoreJson({ code, error, dispatchSkipped: true }, { status: 409 }); + } + } catch (error) { + if (error instanceof ReceiptOcrRecoveryCapabilityHeldError) { + return noStoreJson( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE, dispatchSkipped: true }, + { status: 503 }, + ); + } + if (error instanceof ReceiptOcrRecoveryPersistenceError) { + return noStoreJson( + { code: 'OCR_RECOVERY_READBACK_FAILED', error: 'OCR 복구 상태를 확인하지 못했습니다.', dispatchSkipped: true }, + { status: 503 }, + ); + } + throw error; + } + } - // 2. GitHub Actions 워크플로우 사전 확인: 인증/권한 실패 시 OCR 데이터를 먼저 지우지 않습니다. + // No row or generation may change before this external preflight succeeds. if (!GITHUB_TOKEN || !GITHUB_OWNER || !GITHUB_REPO) { - return NextResponse.json( + return noStoreJson( { - error: 'GitHub 환경 변수가 설정되지 않아 OCR 데이터를 초기화하지 않았습니다.', + code: 'OCR_WORKFLOW_PREFLIGHT_FAILED', + error: '워크플로우 설정을 확인하지 못해 OCR 상태를 변경하지 않았습니다.', step: 'workflow-preflight', resetSkipped: true, }, - { status: 503 } + { status: 503 }, + ); + } + + let workflowInputs: Record; + try { + workflowInputs = await buildReceiptOcrWorkflowInputs([reviewId]); + } catch { + return noStoreJson( + { + code: 'OCR_WORKFLOW_CONFIGURATION_UNAVAILABLE', + error: 'OCR 워크플로우 설정을 확인하지 못해 OCR 상태를 변경하지 않았습니다.', + step: 'workflow-config', + resetSkipped: true, + }, + { status: 503 }, ); } const workflowPreflightResponse = await fetch( - `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/ocr-review-receipts.yml`, + `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/${RECEIPT_OCR_WORKFLOW_FILE}`, { method: 'GET', headers: { 'Authorization': `Bearer ${GITHUB_TOKEN}`, 'Accept': 'application/vnd.github.v3+json', }, - } + }, ); - if (!workflowPreflightResponse.ok) { - await workflowPreflightResponse.text().catch(() => null); + await workflowPreflightResponse.body?.cancel().catch(() => undefined); console.error('[admin/ocr-receipts/rerun] guarded mutation preflight failed', { - domain: 'ocr_receipt', - action: 'rerun', - step: 'workflow-preflight', - status: workflowPreflightResponse.status, - correlationId, + domain: 'ocr_receipt', action: body.action ?? 'rerun', step: 'workflow-preflight', + code: 'OCR_WORKFLOW_PREFLIGHT_FAILED', correlationId, }); - return NextResponse.json( + return noStoreJson( { - error: `GitHub Actions 권한 확인 실패: ${workflowPreflightResponse.status}. OCR 데이터를 초기화하지 않았습니다.`, + code: 'OCR_WORKFLOW_PREFLIGHT_FAILED', + error: '워크플로우 사전 확인에 실패해 OCR 상태를 변경하지 않았습니다.', step: 'workflow-preflight', resetSkipped: true, }, - { status: workflowPreflightResponse.status === 401 || workflowPreflightResponse.status === 403 ? 502 : 503 } + { status: workflowPreflightResponse.status === 401 || workflowPreflightResponse.status === 403 ? 502 : 503 }, ); } + try { + await assertReceiptOcrRecoveryCapabilityReady(supabase); + } catch (error) { + if (error instanceof ReceiptOcrRecoveryCapabilityHeldError) { + return noStoreJson( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE, dispatchSkipped: true }, + { status: 503 }, + ); + } + throw error; + } - // 3. OCR 데이터 초기화 (재처리 대상으로 만들기) - const previousOcrState = { - ocr_processed_at: review.ocr_processed_at, - receipt_data: review.receipt_data, - receipt_hash: review.receipt_hash, - is_duplicate: review.is_duplicate, - }; - const { error: updateError } = await supabase - .from('reviews') - .update({ - ocr_processed_at: null, - receipt_data: null, - receipt_hash: null, - is_duplicate: false, - }) - .eq('id', reviewId); - - if (updateError) { - console.error('[admin/ocr-receipts/rerun] guarded mutation reset failed', { - domain: 'ocr_receipt', - action: 'rerun', - step: 'ocr-reset', - correlationId, - errorName: getGuardedMutationErrorName(updateError), - }); - return NextResponse.json( - { error: 'OCR 초기화에 실패했습니다.', step: 'ocr-reset' }, - { status: 500 } - ); + let resetApplied = false; + let recoveryReadback: Extract | null = null; + if (body.action === RECOVERY_ACTION) { + try { + const recovery = await applyReceiptOcrIndeterminateRecovery(supabase, { + actorUserId: auth.userId, + reviewId, + recoveryOperationId: body.recoveryOperationId!, + expectedGeneration: body.expectedGeneration!, + inputSnapshotHash: body.inputSnapshotHash!, + modelPolicy: JSON.parse(workflowInputs.modelPolicy) as string[], + thinkingLevel: workflowInputs.thinkingLevel, + promptVersion: ADMIN_RECEIPT_OCR_PROMPT_VERSION, + preprocessVersion: ADMIN_RECEIPT_OCR_PREPROCESS_VERSION, + normalizationVersion: RECEIPT_OCR_NORMALIZATION_VERSION, + }); + if (recovery.status !== 'rotated') { + return noStoreJson( + { code: recoveryConflictCode(recovery.status), error: recoveryConflictMessage(recovery.status), dispatchSkipped: true }, + { status: 409 }, + ); + } + recoveryReadback = recovery; + workflowInputs = buildBoundReceiptOcrRecoveryWorkflowInputs({ + reviewId, + recoveryOperationId: recovery.operationId, + modelPolicy: recovery.modelPolicy, + thinkingLevel: recovery.thinkingLevel, + }); + } catch (error) { + if (error instanceof ReceiptOcrRecoveryCapabilityHeldError) { + return noStoreJson( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE, dispatchSkipped: true }, + { status: 503 }, + ); + } + if (error instanceof ReceiptOcrRecoveryPersistenceError) { + return noStoreJson( + { code: 'OCR_RECOVERY_READBACK_FAILED', error: 'OCR 복구 상태를 확인하지 못했습니다.', dispatchSkipped: true }, + { status: 503 }, + ); + } + throw error; + } + } else if (!resetAlreadyPending) { + const resetResult = await resetReceiptOcrRowWithCas(supabase, { id: reviewId, ...previousOcrState }); + if (resetResult !== 'applied') { + console.error('[admin/ocr-receipts/rerun] guarded mutation reset failed', { + domain: 'ocr_receipt', action: 'rerun', step: 'ocr-reset', correlationId, + code: resetResult === 'conflict' ? 'OCR_RESET_CONFLICT' : 'OCR_RESET_FAILED', + }); + return noStoreJson( + { code: resetResult === 'conflict' ? 'OCR_RESET_CONFLICT' : 'OCR_RESET_FAILED', error: 'OCR 상태가 변경되어 초기화하지 않았습니다.', step: 'ocr-reset' }, + { status: resetResult === 'conflict' ? 409 : 503 }, + ); + } + resetApplied = true; } - // 4. GitHub Actions 워크플로우 트리거 + const dispatch = await dispatchReceiptOcrWorkflow({ + url: `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/${RECEIPT_OCR_WORKFLOW_FILE}/dispatches`, + token: GITHUB_TOKEN, + inputs: workflowInputs, + }); - const response = await fetch( - `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/ocr-review-receipts.yml/dispatches`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${GITHUB_TOKEN}`, - 'Accept': 'application/vnd.github.v3+json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ref: 'main', - inputs: { - reviewId: reviewId, // 특정 리뷰 ID 전달 - }, - }), - } - ); + const readback = { + resetApplied, + resetAlreadyPending, + resetReadbackVerified: true, + resetPreserved: true, + reviewId, + ...(recoveryReadback ? { + recoveryOperationId: recoveryReadback.operationId, + previousGeneration: recoveryReadback.previousGeneration, + generation: recoveryReadback.generation, + inputSnapshotHash: recoveryReadback.inputSnapshotHash, + recoveryIdempotent: recoveryReadback.idempotent, + providerOutcomeCertain: false, + } : {}), + }; - if (!response.ok) { - await response.text().catch(() => null); + if (dispatch.status !== 'accepted') { console.error('[admin/ocr-receipts/rerun] guarded mutation dispatch failed', { - domain: 'ocr_receipt', - action: 'rerun', - step: 'workflow-dispatch', - status: response.status, - correlationId, + domain: 'ocr_receipt', action: body.action ?? 'rerun', step: 'workflow-dispatch', + code: 'OCR_WORKFLOW_DISPATCH_INDETERMINATE', correlationId, }); - const { error: rollbackError } = await supabase - .from('reviews') - .update(previousOcrState) - .eq('id', reviewId); - return NextResponse.json( + return noStoreJson( { - error: `GitHub Actions 트리거 실패: ${response.status}`, + code: 'OCR_WORKFLOW_DISPATCH_INDETERMINATE', + error: '워크플로우 접수 여부를 확인하지 못했습니다. 처리 상태를 확인한 뒤 다시 시도해 주세요.', step: 'workflow-dispatch', - resetRolledBack: !rollbackError, - rollbackError: rollbackError ? 'OCR 초기화 롤백에 실패했습니다. 수동 확인이 필요합니다.' : null, + dispatchIndeterminate: true, guardedMutation: { - domain: 'ocr_receipt', - action: 'rerun', + domain: 'ocr_receipt', action: body.action ?? 'rerun', confirmation: GUARDED_MUTATION_CONFIRMATION, readback: { - resetApplied: true, - resetRolledBack: !rollbackError, - workflowDispatched: false, - reviewId, - }, - audit: { - source: 'github-actions-workflow-dispatch', - correlationId, + ...readback, + workflowDispatched: null, + workflowDispatchAccepted: null, + workflowDeliveryVerified: false, }, + audit: { source: 'github-actions-workflow-dispatch', correlationId }, }, }, - { status: response.status === 401 || response.status === 403 ? 502 : response.status } + { status: dispatch.httpStatus === 401 || dispatch.httpStatus === 403 ? 502 : 503 }, ); } - return NextResponse.json({ + return noStoreJson({ success: true, - message: 'OCR 재실행이 시작되었습니다. 약 30~40초 후 결과가 반영됩니다.', + message: body.action === RECOVERY_ACTION + ? 'OCR 복구 요청이 접수되었습니다. 처리 상태에서 결과를 확인해 주세요.' + : 'OCR 재실행 요청이 접수되었습니다. 처리 상태에서 결과를 확인해 주세요.', reviewId, guardedMutation: { - domain: 'ocr_receipt', - action: 'rerun', + domain: 'ocr_receipt', action: body.action ?? 'rerun', confirmation: GUARDED_MUTATION_CONFIRMATION, readback: { - resetApplied: true, + ...readback, workflowDispatched: true, - reviewId, - }, - audit: { - source: 'github-actions-workflow-dispatch', - correlationId, + workflowDispatchAccepted: true, + workflowDeliveryVerified: false, }, + audit: { source: 'github-actions-workflow-dispatch', correlationId }, }, }); - - } catch (err) { + } catch (error) { console.error('[admin/ocr-receipts/rerun] guarded mutation failed', { - domain: 'ocr_receipt', - action: 'rerun', - step: 'unexpected', - errorName: getGuardedMutationErrorName(err), + domain: 'ocr_receipt', action: 'rerun', step: 'unexpected', + errorName: getGuardedMutationErrorName(error), }); - return NextResponse.json( - { error: 'OCR 재실행을 시작하지 못했습니다.' }, - { status: 500 } - ); + return noStoreJson({ error: 'OCR 재실행을 시작하지 못했습니다.' }, { status: 500 }); } } diff --git a/apps/web/app/api/admin/ocr-receipts/reset-all/route.ts b/apps/web/app/api/admin/ocr-receipts/reset-all/route.ts index 06ba50f284..0aad357492 100644 --- a/apps/web/app/api/admin/ocr-receipts/reset-all/route.ts +++ b/apps/web/app/api/admin/ocr-receipts/reset-all/route.ts @@ -2,7 +2,7 @@ * 전체 리뷰 OCR 초기화 및 재실행 API * * POST /api/admin/ocr-receipts/reset-all - * - 모든 리뷰의 OCR 데이터를 초기화하고 GitHub Actions 워크플로우 트리거 + * - 대기 중인 리뷰 또는 최대 50개의 명시적 리뷰만 워크플로우와 함께 초기화 */ import { NextResponse } from 'next/server'; @@ -16,6 +16,19 @@ import { } from '@/lib/admin/guarded-mutation-contract'; import { readBoundedJsonRequest } from '@/lib/security/bounded-json-request'; import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; +import { FEATURE_DATABASE_HELD_CODE, FEATURE_DATABASE_HELD_MESSAGE } from '@/lib/runtime-feature-readiness-contract'; +import { readFeatureDatabaseReadiness } from '@/lib/server/runtime-feature-readiness'; +import { + buildReceiptOcrWorkflowInputs, + dispatchReceiptOcrWorkflow, + RECEIPT_OCR_WORKFLOW_FILE, +} from '@/lib/ocr/workflow-dispatch'; +import { + OCR_RESET_PROJECTION, + resetReceiptOcrRowsWithCas, + resetReceiptOcrRowWithCas, + type ReceiptOcrResetSnapshot, +} from '@/lib/ocr/admin-receipt-reset'; export const runtime = 'nodejs'; @@ -27,6 +40,7 @@ const GITHUB_REPO = process.env.GITHUB_REPO!; const OCR_RESET_ALL_CONFIRMATION = 'OCR초기화'; const MAX_OCR_RESET_ALL_REQUEST_BYTES = 4 * 1024; +const MAX_OCR_RESET_BATCH = 50; function hasGuardedMutationConfirmation( request: Request, @@ -52,6 +66,13 @@ export async function POST(request: Request) { return auth.response; } + if ((await readFeatureDatabaseReadiness({ ownerId: auth.userId })).status !== 'ready') { + return noStoreJson( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 409 }, + ); + } + if (!isTrustedSameOriginMutation(request)) { return noStoreJson( { error: '요청을 처리할 수 없습니다.' }, @@ -102,7 +123,7 @@ export async function POST(request: Request) { const correlationId = `ocr-reset-all-${Date.now()}`; - const workflowUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/ocr-review-receipts.yml`; + const workflowUrl = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/${RECEIPT_OCR_WORKFLOW_FILE}`; const githubHeaders = { 'Authorization': `Bearer ${GITHUB_TOKEN}`, 'Accept': 'application/vnd.github.v3+json', @@ -115,7 +136,7 @@ export async function POST(request: Request) { }); if (!workflowPreflightResponse.ok) { - await workflowPreflightResponse.text().catch(() => null); + await workflowPreflightResponse.body?.cancel().catch(() => undefined); console.error('[admin/ocr-receipts/reset-all] guarded mutation preflight failed', { domain: 'ocr_receipt', action: 'reset_all', @@ -131,68 +152,132 @@ export async function POST(request: Request) { const supabase = createSupabaseServiceRoleClient(); - // 1. 영수증 사진이 있는 리뷰 수 조회 - const { count } = await supabase + // Preserve already-reset rows first. A retry therefore re-dispatches + // the same generations instead of rotating them again. + const { data: pendingRows, error: pendingError } = await supabase .from('reviews') - .select('id', { count: 'exact', head: true }) - .not('verification_photo', 'is', null); + .select(OCR_RESET_PROJECTION) + .not('verification_photo', 'is', null) + .is('ocr_processed_at', null) + .is('receipt_data', null) + .is('receipt_hash', null) + .eq('is_duplicate', false) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .limit(MAX_OCR_RESET_BATCH); - // 2. 모든 리뷰의 OCR 데이터 초기화 - const { error: resetError } = await supabase - .from('reviews') - .update({ - ocr_processed_at: null, - receipt_data: null, - receipt_hash: null, - is_duplicate: false, - }) - .not('verification_photo', 'is', null); - - if (resetError) { - console.error('[admin/ocr-receipts/reset-all] guarded mutation reset failed', { + if (pendingError) { + return noStoreJson( + { error: 'OCR 대기 배치를 확인하지 못했습니다.', resetSkipped: true }, + { status: 503 }, + ); + } + + let targetRows = (pendingRows ?? []) as ReceiptOcrResetSnapshot[]; + let resetApplied = false; + if (targetRows.length === 0) { + const { data: resetCandidates, error: candidateError } = await supabase + .from('reviews') + .select(OCR_RESET_PROJECTION) + .not('verification_photo', 'is', null) + .or('ocr_processed_at.not.is.null,receipt_data.not.is.null,receipt_hash.not.is.null,is_duplicate.eq.true,is_duplicate.is.null') + .order('ocr_processed_at', { ascending: true, nullsFirst: true }) + .order('id', { ascending: true }) + .limit(MAX_OCR_RESET_BATCH); + if (candidateError) { + return noStoreJson( + { error: 'OCR 초기화 대상을 확인하지 못했습니다.', resetSkipped: true }, + { status: 503 }, + ); + } + targetRows = (resetCandidates ?? []) as ReceiptOcrResetSnapshot[]; + } + + if (targetRows.length === 0) { + return noStoreJson({ + success: true, + message: '재실행할 영수증 OCR이 없습니다.', + guardedMutation: { + domain: 'ocr_receipt', action: 'reset_all', confirmation: GUARDED_MUTATION_CONFIRMATION, + readback: { resetApplied: false, workflowDispatched: false, affectedCount: 0 }, + audit: { source: 'bounded-ocr-reset-preview', correlationId }, + }, + }); + } + + const targetReviewIds = targetRows.map((row) => row.id); + let workflowInputs: Record; + try { + workflowInputs = await buildReceiptOcrWorkflowInputs(targetReviewIds); + } catch { + return noStoreJson( + { error: 'OCR 워크플로우 설정을 확인하지 못했습니다.', resetSkipped: true }, + { status: 503 }, + ); + } + + const resetOutcome = await resetReceiptOcrRowsWithCas( + targetRows, + (snapshot) => resetReceiptOcrRowWithCas(supabase, snapshot), + ); + if (resetOutcome.status !== 'complete') { + resetApplied = resetOutcome.appliedReviewIds.length > 0; + console.error('[admin/ocr-receipts/reset-all] guarded mutation reset conflict', { domain: 'ocr_receipt', action: 'reset_all', step: 'ocr-reset', correlationId, - errorName: getGuardedMutationErrorName(resetError), + resetResult: resetOutcome.status, + appliedCount: resetOutcome.appliedReviewIds.length, }); - return NextResponse.json( - { error: 'OCR 초기화에 실패했습니다.' }, - { status: 500 } + return noStoreJson( + { + error: 'OCR 상태가 동시에 변경되어 workflow를 시작하지 않았습니다. 처리 상태를 확인한 뒤 다시 시도해 주세요.', + partialFailure: resetApplied, + resetApplied, + resetPreserved: true, + dispatchSkipped: true, + readbackRequired: true, + affectedCount: resetOutcome.appliedReviewIds.length, + }, + { status: resetOutcome.status === 'conflict' ? 409 : 503 }, ); } + resetApplied = resetOutcome.appliedReviewIds.length > 0; - // 3. GitHub Actions 워크플로우 트리거 - - const response = await fetch(`${workflowUrl}/dispatches`, { - method: 'POST', - headers: githubHeaders, - body: JSON.stringify({ ref: 'main' }), + const dispatch = await dispatchReceiptOcrWorkflow({ + url: `${workflowUrl}/dispatches`, + token: GITHUB_TOKEN, + inputs: workflowInputs, }); - if (!response.ok) { - await response.text().catch(() => null); + if (dispatch.status !== 'accepted') { console.error('[admin/ocr-receipts/reset-all] guarded mutation dispatch failed', { domain: 'ocr_receipt', action: 'reset_all', step: 'workflow-dispatch', - status: response.status, + status: dispatch.httpStatus, correlationId, }); - return NextResponse.json( + return noStoreJson( { - error: `GitHub Actions 트리거 실패: ${response.status}`, + error: 'GitHub Actions 접수 여부를 확인하지 못했습니다. 처리 상태를 확인한 뒤 다시 시도해 주세요.', partialFailure: true, - resetApplied: true, + dispatchIndeterminate: true, + resetApplied, readbackRequired: true, guardedMutation: { domain: 'ocr_receipt', action: 'reset_all', confirmation: GUARDED_MUTATION_CONFIRMATION, readback: { - resetApplied: true, - workflowDispatched: false, - affectedCount: count || 0, + resetApplied, + resetReadbackVerified: true, + resetPreserved: true, + workflowDispatched: null, + workflowDispatchAccepted: null, + workflowDeliveryVerified: false, + affectedCount: targetReviewIds.length, }, audit: { source: 'github-actions-workflow-dispatch', @@ -200,21 +285,24 @@ export async function POST(request: Request) { }, }, }, - { status: response.status } + { status: dispatch.httpStatus === 401 || dispatch.httpStatus === 403 ? 502 : 503 } ); } - return NextResponse.json({ + return noStoreJson({ success: true, - message: `모든 리뷰(${count || 0}개)의 OCR을 초기화하고 재실행을 시작했습니다.`, + message: `영수증 OCR ${targetReviewIds.length}개 배치의 재실행 요청이 접수되었습니다.`, guardedMutation: { domain: 'ocr_receipt', action: 'reset_all', confirmation: GUARDED_MUTATION_CONFIRMATION, readback: { - resetApplied: true, + resetApplied, + resetReadbackVerified: true, workflowDispatched: true, - affectedCount: count || 0, + workflowDispatchAccepted: true, + workflowDeliveryVerified: false, + affectedCount: targetReviewIds.length, }, audit: { source: 'github-actions-workflow-dispatch', diff --git a/apps/web/app/api/admin/ocr-receipts/route.ts b/apps/web/app/api/admin/ocr-receipts/route.ts index 65fa8fa68d..90df6bf4e9 100644 --- a/apps/web/app/api/admin/ocr-receipts/route.ts +++ b/apps/web/app/api/admin/ocr-receipts/route.ts @@ -16,6 +16,14 @@ import { } from '@/lib/admin/guarded-mutation-contract'; import { readBoundedJsonRequest } from '@/lib/security/bounded-json-request'; import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; +import { FEATURE_DATABASE_HELD_CODE, FEATURE_DATABASE_HELD_MESSAGE } from '@/lib/runtime-feature-readiness-contract'; +import { readFeatureDatabaseReadiness } from '@/lib/server/runtime-feature-readiness'; +import { + buildReceiptOcrWorkflowInputs, + dispatchReceiptOcrWorkflow, + RECEIPT_OCR_WORKFLOW_FILE, + RECEIPT_OCR_WORKFLOW_REF, +} from '@/lib/ocr/workflow-dispatch'; export const runtime = 'nodejs'; @@ -51,6 +59,13 @@ export async function POST(request: Request) { return auth.response; } + if ((await readFeatureDatabaseReadiness({ ownerId: auth.userId })).status !== 'ready') { + return noStoreJson( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 409 }, + ); + } + if (!isTrustedSameOriginMutation(request)) { return noStoreJson( { error: '요청을 처리할 수 없습니다.' }, @@ -82,54 +97,64 @@ export async function POST(request: Request) { } if (!GITHUB_TOKEN || !GITHUB_OWNER || !GITHUB_REPO) { - return NextResponse.json( + return noStoreJson( { error: 'GitHub 환경 변수가 설정되지 않았습니다.' }, - { status: 500 } + { status: 503 } ); } const correlationId = `ocr-dispatch-${Date.now()}`; + let workflowInputs: Record; + try { + workflowInputs = await buildReceiptOcrWorkflowInputs(); + } catch (error) { + console.error('[admin/ocr-receipts] guarded mutation workflow config failed', { + domain: 'ocr_receipt', + action: 'dispatch_workflow', + step: 'workflow-config', + correlationId, + errorName: getGuardedMutationErrorName(error), + }); + return noStoreJson( + { error: 'OCR 워크플로 설정을 확인할 수 없습니다.' }, + { status: 503 }, + ); + } // GitHub Actions workflow_dispatch 트리거 - const response = await fetch( - `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/ocr-review-receipts.yml/dispatches`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${GITHUB_TOKEN}`, - 'Accept': 'application/vnd.github.v3+json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ ref: 'main' }), - } - ); + const dispatch = await dispatchReceiptOcrWorkflow({ + url: `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/${RECEIPT_OCR_WORKFLOW_FILE}/dispatches`, + token: GITHUB_TOKEN, + inputs: workflowInputs, + }); - if (!response.ok) { - await response.text().catch(() => null); + if (dispatch.status !== 'accepted') { console.error('[admin/ocr-receipts] guarded mutation dispatch failed', { domain: 'ocr_receipt', action: 'dispatch_workflow', step: 'workflow-dispatch', - status: response.status, + status: dispatch.httpStatus, correlationId, }); - return NextResponse.json( - { error: `GitHub Actions 트리거 실패: ${response.status}` }, - { status: response.status } + return noStoreJson( + { error: 'OCR 워크플로 접수 여부를 확인하지 못했습니다.', dispatchIndeterminate: true }, + { status: dispatch.httpStatus === 401 || dispatch.httpStatus === 403 ? 502 : 503 } ); } - return NextResponse.json({ + return noStoreJson({ success: true, - message: 'OCR 처리가 시작되었습니다.', + message: 'OCR 워크플로 요청이 접수되었습니다.', guardedMutation: { domain: 'ocr_receipt', action: 'dispatch_workflow', confirmation: GUARDED_MUTATION_CONFIRMATION, readback: { workflowDispatched: true, - workflow: 'ocr-review-receipts.yml', - ref: 'main', + accepted: true, + workflowDeliveryVerified: false, + workflow: RECEIPT_OCR_WORKFLOW_FILE, + ref: RECEIPT_OCR_WORKFLOW_REF, }, audit: { source: 'github-actions-workflow-dispatch', @@ -145,7 +170,7 @@ export async function POST(request: Request) { step: 'unexpected', errorName: getGuardedMutationErrorName(err), }); - return NextResponse.json( + return noStoreJson( { error: 'OCR 처리를 시작하지 못했습니다.' }, { status: 500 } ); @@ -156,7 +181,15 @@ export async function POST(request: Request) { export async function GET() { try { const auth = await requireAdmin(); - if (!auth.ok) return auth.response; + if (!auth.ok) { + auth.response.headers.set('Cache-Control', 'no-store'); + return auth.response; + } + + const readiness = await readFeatureDatabaseReadiness({ ownerId: auth.userId }); + if (readiness.status !== 'ready') { + return noStoreJson({ readiness, pending: 0, duplicate: 0, processed: 0 }); + } const supabase = createSupabaseServiceRoleClient(); @@ -176,7 +209,8 @@ export async function GET() { .select('id', { count: 'exact', head: true }) .not('ocr_processed_at', 'is', null); - return NextResponse.json({ + return noStoreJson({ + readiness, pending: pending || 0, duplicate: duplicate || 0, processed: processed || 0 @@ -189,7 +223,7 @@ export async function GET() { step: 'status-read', errorName: getGuardedMutationErrorName(err), }); - return NextResponse.json( + return noStoreJson( { error: 'OCR 처리 상태를 조회하지 못했습니다.' }, { status: 500 } ); diff --git a/apps/web/app/api/admin/operating-readback/storyboard-policy/readback-response.ts b/apps/web/app/api/admin/operating-readback/storyboard-policy/readback-response.ts new file mode 100644 index 0000000000..06f6ff966b --- /dev/null +++ b/apps/web/app/api/admin/operating-readback/storyboard-policy/readback-response.ts @@ -0,0 +1,126 @@ +import 'server-only'; + +import { NextResponse } from 'next/server'; + +import { requireAdmin } from '@/lib/auth/require-admin'; +import { evaluateStoryboardRuntimePolicy } from '@/lib/admin/storyboard/runtime-policy'; + +const TZUDONG_VERCEL_PROJECT_ID = 'prj_sau35J5uUtShIQ9OKofRtOVVnTSl'; +const DEPLOYMENT_ID = /^dpl_[A-Za-z0-9]+$/; +const GIT_SHA = /^[0-9a-f]{40}$/; +const PRIVATE_HEADERS = Object.freeze({ + 'Cache-Control': 'private, no-store, max-age=0', + Pragma: 'no-cache', +}); + +const POLICY_SOURCE_PATHS = Object.freeze([ + ['declared-models', 'apps/web/lib/admin/storyboard/gemini-models.ts'], + ['production-request-admission', 'apps/web/lib/admin/storyboard/production-contract.ts'], + ['worker-heartbeat-admission', 'apps/web/lib/admin/storyboard/production-worker-auth.ts'], + ['outbound-worker-execution', 'apps/web/lib/admin/storyboard/outbound-worker.ts'], + ['production-store-admission', 'apps/web/lib/admin/storyboard/production-store.ts'], + ['production-api-admission', 'apps/web/lib/admin/storyboard/production-api.ts'], + ['gemini-client-execution', 'apps/web/lib/admin/storyboard/gemini-client.ts'], + ['feature-database-admission', 'apps/web/lib/server/runtime-feature-readiness.ts'], +] as const); + +type AdminResult = Awaited>; +type RequireAdmin = () => Promise; + +function fixedResponse(code: string, status: number) { + return NextResponse.json({ code }, { headers: PRIVATE_HEADERS, status }); +} + +function fixedAuthFailure(response: NextResponse) { + const status = response.status === 401 ? 401 : 403; + return fixedResponse( + status === 401 ? 'STORYBOARD_POLICY_READBACK_UNAUTHORIZED' : 'STORYBOARD_POLICY_READBACK_FORBIDDEN', + status, + ); +} + +function readRuntimeIdentity(environment: NodeJS.ProcessEnv) { + const deploymentId = environment.VERCEL_DEPLOYMENT_ID?.trim() ?? ''; + const sourceCommit = environment.VERCEL_GIT_COMMIT_SHA?.trim() ?? ''; + if (environment.VERCEL !== '1' + || environment.VERCEL_ENV !== 'production' + || environment.VERCEL_TARGET_ENV !== 'production' + || environment.VERCEL_PROJECT_ID !== TZUDONG_VERCEL_PROJECT_ID + || environment.VERCEL_GIT_COMMIT_REF !== 'main' + || !DEPLOYMENT_ID.test(deploymentId) + || !GIT_SHA.test(sourceCommit)) { + return null; + } + return Object.freeze({ + branch: 'main' as const, + deploymentId, + projectId: TZUDONG_VERCEL_PROJECT_ID, + sourceCommit, + }); +} + +export { evaluateStoryboardRuntimePolicy }; + +type PolicyEvaluation = ReturnType; + +function responseAfterAuthentication( + auth: AdminResult, + environment: NodeJS.ProcessEnv, + evaluatePolicy: (sourceBindingSha256: string) => PolicyEvaluation, + sourceBindingSha256: string, +) { + if (!auth.ok) return fixedAuthFailure(auth.response); + + const deployment = readRuntimeIdentity(environment); + if (!deployment) return fixedResponse('STORYBOARD_POLICY_READBACK_RUNTIME_UNAVAILABLE', 503); + + let evaluated: PolicyEvaluation; + try { + evaluated = evaluatePolicy(sourceBindingSha256); + } catch { + return fixedResponse('STORYBOARD_POLICY_READBACK_POLICY_UNAVAILABLE', 503); + } + const executionPaths = POLICY_SOURCE_PATHS.map(([id, sourcePath]) => ({ + canonicalPolicySha256: evaluated.canonicalPolicySha256, + deploymentId: deployment.deploymentId, + id, + sourceCommit: deployment.sourceCommit, + sourcePath, + })); + + return NextResponse.json({ + schemaVersion: 1, + deployment, + policy: { + ...evaluated.canonicalPolicy, + canonicalPolicySha256: evaluated.canonicalPolicySha256, + }, + executionPaths, + verification: { + databaseCutover: 'not-verified', + outboundProviderCall: 'not-performed', + providerAccess: 'not-verified', + runtimePolicyEvaluated: true, + }, + }, { headers: PRIVATE_HEADERS, status: 200 }); +} + +export async function createStoryboardPolicyReadbackResponse({ + environment = process.env, + evaluatePolicy = evaluateStoryboardRuntimePolicy, + requireAdminImpl = requireAdmin, + sourceBindingSha256 = process.env.STORYBOARD_RUNTIME_SOURCE_BINDING_SHA256 ?? '', +}: { + environment?: NodeJS.ProcessEnv; + evaluatePolicy?: (sourceBindingSha256: string) => PolicyEvaluation; + requireAdminImpl?: RequireAdmin; + sourceBindingSha256?: string; +} = {}) { + let auth: AdminResult; + try { + auth = await requireAdminImpl(); + } catch { + return fixedResponse('STORYBOARD_POLICY_READBACK_AUTH_UNAVAILABLE', 503); + } + return responseAfterAuthentication(auth, environment, evaluatePolicy, sourceBindingSha256); +} diff --git a/apps/web/app/api/admin/operating-readback/storyboard-policy/route.ts b/apps/web/app/api/admin/operating-readback/storyboard-policy/route.ts new file mode 100644 index 0000000000..465fe89450 --- /dev/null +++ b/apps/web/app/api/admin/operating-readback/storyboard-policy/route.ts @@ -0,0 +1,8 @@ +import { createStoryboardPolicyReadbackResponse } from './readback-response'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + return createStoryboardPolicyReadbackResponse(); +} diff --git a/apps/web/app/api/admin/storyboard/production/[id]/images/route.ts b/apps/web/app/api/admin/storyboard/production/[id]/images/route.ts index a77841f168..c1bb4d0b7c 100644 --- a/apps/web/app/api/admin/storyboard/production/[id]/images/route.ts +++ b/apps/web/app/api/admin/storyboard/production/[id]/images/route.ts @@ -1,5 +1,5 @@ -import { storyboardProductionApi } from '@/lib/admin/storyboard/production-api'; +import { retiredStoryboardApi } from '@/lib/admin/storyboard/retired-api'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export const POST = storyboardProductionApi.imagePOST; +export const POST = retiredStoryboardApi; diff --git a/apps/web/app/api/admin/users/route.ts b/apps/web/app/api/admin/users/route.ts index e73dfdce40..ace2106e0f 100644 --- a/apps/web/app/api/admin/users/route.ts +++ b/apps/web/app/api/admin/users/route.ts @@ -9,6 +9,9 @@ export const runtime = 'nodejs'; const DEFAULT_PER_PAGE = 80; const MAX_PER_PAGE = 200; +const MAX_FILTER_SCAN_PAGES = 5; +type AdminUserFilter = 'all' | 'admin' | 'disabled' | 'unconfirmed'; +type AdminUserSort = 'newest' | 'lastSignIn' | 'name'; const ADMIN_USER_CREATION_ONBOARDING_REQUIRED = { code: 'ADMIN_USER_CREATION_ONBOARDING_REQUIRED', error: '새 계정은 개인정보 온보딩 가입 절차를 통해서만 만들 수 있습니다.', @@ -18,6 +21,26 @@ function normalizeSearch(value: string | null) { return (value ?? '').trim().toLowerCase(); } +function normalizePage(value: string | null) { + const parsed = Number(value ?? '1'); + return Number.isFinite(parsed) ? Math.max(Math.floor(parsed), 1) : 1; +} + +function normalizePerPage(value: string | null) { + const parsed = Number(value ?? String(DEFAULT_PER_PAGE)); + return Number.isFinite(parsed) + ? Math.min(Math.max(Math.floor(parsed), 1), MAX_PER_PAGE) + : DEFAULT_PER_PAGE; +} + +function normalizeFilter(value: string | null): AdminUserFilter { + return value === 'admin' || value === 'disabled' || value === 'unconfirmed' ? value : 'all'; +} + +function normalizeSort(value: string | null): AdminUserSort { + return value === 'lastSignIn' || value === 'name' ? value : 'newest'; +} + function isDisabledUser(user: Pick) { if (!user.banned_until) return false; const bannedUntil = Date.parse(user.banned_until); @@ -98,6 +121,21 @@ function buildManagedUser(user: User, metadata: AdminUserManagementMetadataRow | }; } +type ManagedUser = ReturnType; + +function matchesAdminUserFilter(user: ManagedUser, filter: AdminUserFilter) { + if (filter === 'admin') return user.isAdmin; + if (filter === 'disabled') return user.isDisabled; + if (filter === 'unconfirmed') return !user.emailConfirmedAt; + return true; +} + +function compareManagedUsers(a: ManagedUser, b: ManagedUser, sort: AdminUserSort) { + if (sort === 'name') return a.nickname.localeCompare(b.nickname, 'ko'); + const field = sort === 'lastSignIn' ? 'lastSignInAt' : 'createdAt'; + return (Date.parse(b[field] ?? '') || 0) - (Date.parse(a[field] ?? '') || 0); +} + export async function GET(request: NextRequest) { try { const auth = await requireAdmin(); @@ -110,12 +148,15 @@ export async function GET(request: NextRequest) { } const supabase = createSupabaseServiceRoleClient(); const search = normalizeSearch(searchParams.get('search')); - const page = Math.max(Number(searchParams.get('page') ?? '1') || 1, 1); - const requestedPerPage = Number(searchParams.get('perPage') ?? String(DEFAULT_PER_PAGE)) || DEFAULT_PER_PAGE; - const perPage = Math.min(Math.max(requestedPerPage, 1), MAX_PER_PAGE); + const page = normalizePage(searchParams.get('page')); + const perPage = normalizePerPage(searchParams.get('perPage')); + const filter = normalizeFilter(searchParams.get('filter')); + const sort = normalizeSort(searchParams.get('sort')); const userPages = []; let listedTotal = 0; + let isPartial = false; + const needsBoundedFilterScan = exactUserId === null && Boolean(search || filter !== 'all' || sort !== 'newest'); if (exactUserId !== null) { const { data, error } = await supabase.auth.admin.getUserById(exactUserId); @@ -125,14 +166,15 @@ export async function GET(request: NextRequest) { } if (data.user.id.toLowerCase() !== exactUserId.toLowerCase()) throw new Error('ADMIN_USER_READBACK_INVALID'); userPages.push(data.user); listedTotal = 1; - } else if (search) { - for (let searchPage = 1; searchPage <= 5; searchPage += 1) { + } else if (needsBoundedFilterScan) { + for (let searchPage = 1; searchPage <= MAX_FILTER_SCAN_PAGES; searchPage += 1) { const { data, error } = await supabase.auth.admin.listUsers({ page: searchPage, perPage: MAX_PER_PAGE }); if (error) throw error; userPages.push(...(data.users ?? [])); listedTotal = data.total ?? listedTotal; - if (!data.users || data.users.length < MAX_PER_PAGE) break; + if (!data.users || data.users.length < MAX_PER_PAGE || (listedTotal > 0 && userPages.length >= listedTotal)) break; } + isPartial = listedTotal > userPages.length; } else { const { data, error } = await supabase.auth.admin.listUsers({ page, perPage }); if (error) throw error; @@ -144,7 +186,7 @@ export async function GET(request: NextRequest) { const userIds = authUsers.map((user) => user.id); const metadataMap = await fetchUserManagementMetadata(supabase, userIds); - const users = authUsers + const managedUsers = authUsers .map((user) => buildManagedUser(user, metadataMap.get(user.id))) .filter((user) => { if (exactUserId !== null || !search) return true; @@ -152,14 +194,25 @@ export async function GET(request: NextRequest) { .join(' ') .toLowerCase() .includes(search); - }); + }) + .filter((user) => exactUserId !== null || matchesAdminUserFilter(user, filter)) + .sort((a, b) => compareManagedUsers(a, b, sort)); + const filteredTotal = managedUsers.length; + const users = needsBoundedFilterScan + ? managedUsers.slice((page - 1) * perPage, page * perPage) + : managedUsers; + const total = exactUserId !== null ? users.length : needsBoundedFilterScan ? filteredTotal : listedTotal; return NextResponse.json( { users, page, perPage, - total: exactUserId !== null || search ? users.length : listedTotal, + total, + sourceTotal: exactUserId !== null ? users.length : listedTotal, + isPartial, + hasPreviousPage: page > 1, + hasNextPage: page * perPage < total, summary: { loadedUsers: users.length, adminUsers: users.filter((user) => user.isAdmin).length, @@ -176,7 +229,7 @@ export async function GET(request: NextRequest) { step: 'list', errorName: getAdminSafeErrorName(error), }); - return NextResponse.json({ error: '사용자 목록을 불러오지 못했습니다.' }, { status: 500 }); + return NextResponse.json({ error: '사용자 목록을 불러오지 못했습니다.' }, { status: 500, headers: { 'Cache-Control': 'no-store' } }); } } diff --git a/apps/web/app/api/ocr/extract/route.ts b/apps/web/app/api/ocr/extract/route.ts index f07b4d80bc..12e50f6f27 100644 --- a/apps/web/app/api/ocr/extract/route.ts +++ b/apps/web/app/api/ocr/extract/route.ts @@ -4,18 +4,32 @@ import crypto from 'crypto'; import sharp from 'sharp'; import { debugLog } from '@/lib/debug-log'; import { assertPrivacySafe } from '@/lib/privacy/sanitize'; -import { callGeminiReceiptOcr, GEMINI_OCR_FALLBACK_MODEL, GeminiOcrError } from '@/lib/ocr/gemini'; +import { + callGeminiReceiptOcrWithCredentialFallback, + GEMINI_OCR_FALLBACK_MODEL, + GeminiOcrError, + getGeminiOcrInferenceConfigSnapshot, +} from '@/lib/ocr/gemini'; import { RECEIPT_OCR_EXTRACTION_PROMPT, RECEIPT_OCR_PREPROCESS_VERSION, RECEIPT_OCR_PROMPT_VERSION, } from '@/lib/ocr/receipt-prompt'; -import { resolveOcrAiRuntimeConfig, type OcrAiRuntimeConfigCandidate } from '@/lib/ocr/runtime-config'; +import { getOcrModelPolicy, resolveOcrAiRuntimeConfig } from '@/lib/ocr/runtime-config'; import { buildReceiptOcrEnvelope, - flattenReceiptOcrEnvelope, RECEIPT_OCR_NORMALIZATION_VERSION, } from '@/lib/ocr/receipt-normalization'; +import { + DurableReceiptOcrCapabilityHeldError, + DurableReceiptOcrPendingError, + buildDurableReceiptOcrPayload, + buildOcrInputContextHash, + createSupabaseDurableReceiptOcrStore, + executeDurableReceiptOcr, + RECEIPT_OCR_DEFAULT_GENERATION, + toPublicReceiptOcrResponse, +} from '@/lib/ocr/durable-result'; import { findOcrRestaurantMatches } from '@/lib/ocr/restaurant-matching'; import { buildOcrSuccessLogMetadata, @@ -38,6 +52,9 @@ import { readBoundedOcrFormData, } from '@/lib/ocr/request-security'; import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; +import { createSupabaseServiceRoleClient } from '@/lib/supabase/service-role'; +import { FEATURE_DATABASE_HELD_CODE, FEATURE_DATABASE_HELD_MESSAGE } from '@/lib/runtime-feature-readiness-contract'; +import { readFeatureDatabaseReadiness } from '@/lib/server/runtime-feature-readiness'; // --- 설정 --- @@ -81,6 +98,13 @@ async function optimizeImage(buffer: Buffer): Promise<{ optimized: Buffer; savin const OCR_PROMPT = RECEIPT_OCR_EXTRACTION_PROMPT; +class OcrHttpError extends Error { + constructor(public readonly status: number, public readonly publicMessage: string) { + super(publicMessage); + this.name = 'OcrHttpError'; + } +} + @@ -108,6 +132,12 @@ export async function POST(req: Request) { } const { supabase, user } = auth; authenticatedUserId = user.id; + if ((await readFeatureDatabaseReadiness({ ownerId: user.id })).status !== 'ready') { + return NextResponse.json( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 409, headers: { 'Cache-Control': 'no-store' } }, + ); + } const multipart = await readBoundedOcrFormData(req); if (!multipart.ok) { @@ -125,14 +155,12 @@ export async function POST(req: Request) { const providerCandidates = [aiRuntime, ...aiRuntime.fallbackCandidates]; const runnableCandidates = providerCandidates.filter(candidate => hasRunnableOcrCredentials([candidate])); const effectiveCandidates = runnableCandidates.length ? runnableCandidates : providerCandidates; - failureModel = `${effectiveCandidates[0]?.model || effectiveCandidates[0]?.models[0] || GEMINI_OCR_FALLBACK_MODEL}:fail`; - failureProvider = effectiveCandidates[0]?.provider ?? 'unknown'; - if (!hasRunnableOcrCredentials(runnableCandidates)) { - return NextResponse.json( - { error: 'Gemini OCR API 키가 설정되지 않았습니다.' }, - { status: 500 } - ); - } + const candidate = effectiveCandidates[0]; + const modelPolicy = candidate ? getOcrModelPolicy(candidate) : [GEMINI_OCR_FALLBACK_MODEL]; + const inferenceConfig = getGeminiOcrInferenceConfigSnapshot(); + const model = modelPolicy[0]; + failureModel = `${model}:fail`; + failureProvider = candidate?.provider ?? 'unknown'; if (!file) { return NextResponse.json({ error: '이미지가 제공되지 않았습니다' }, { status: 400 }); @@ -159,105 +187,129 @@ export async function POST(req: Request) { ); } - // 공급자 호출 전에 DB 원자 예약으로 동시 요청도 일일 한도를 넘지 못하게 한다. - try { - const quota = await checkOcrDailyQuota({ - quotaClient: ocrSupabase as never, - operationId: crypto.randomUUID(), - }); - - if (quota.exceeded) { - return NextResponse.json( - { error: `일일 무료 분석 한도(${OCR_DAILY_QUOTA}회)를 초과했습니다. 내일 00시에 초기화됩니다.` }, - { status: 429 } - ); - } - } catch { - return NextResponse.json( - { error: 'OCR 사용 한도를 확인할 수 없습니다.' }, - { status: 503, headers: { 'Cache-Control': 'no-store' } }, - ); - } - - // [비용 절감] 5. 이미지 압축 - const { optimized, savings } = await optimizeImage(buffer); - const base64Image = optimized.toString('base64'); - - let ocrResult: Awaited> | null = null; - let usedCandidate: OcrAiRuntimeConfigCandidate | null = null; - let failedAttemptCount = 0; - for (const candidate of effectiveCandidates) { - req.signal.throwIfAborted(); - const credentials = getRunnableCredentials({ candidate, routingMode: aiRuntime.routingMode }); - for (const credential of credentials) { - req.signal.throwIfAborted(); + if (!candidate) throw new OcrHttpError(503, 'OCR 실행 구성을 확인할 수 없습니다.'); + const credentials = getRunnableCredentials({ candidate, routingMode: aiRuntime.routingMode }); + const freshArtifacts: { current: { + optimized: Buffer; + savings: string; + ocrResult: Awaited>; + envelope: ReturnType; + restaurantMatches: Awaited>; + } | null } = { current: null }; + const durable = await executeDurableReceiptOcr({ + store: createSupabaseDurableReceiptOcrStore(createSupabaseServiceRoleClient() as never), + binding: { + userId: user.id, + imageHash, + inputContextHash: buildOcrInputContextHash(imageHash, selectedRestaurantContext, inferenceConfig), + provider: candidate.provider, + modelPolicy, + generation: RECEIPT_OCR_DEFAULT_GENERATION, + promptVersion: RECEIPT_OCR_PROMPT_VERSION, + preprocessVersion: RECEIPT_OCR_PREPROCESS_VERSION, + normalizationVersion: RECEIPT_OCR_NORMALIZATION_VERSION, + }, + operationId: crypto.randomUUID(), + forceRefresh, + beforeProvider: async () => { + if (!credentials[0]) throw new OcrHttpError(500, 'Gemini OCR API 키가 설정되지 않았습니다.'); + let quota: Awaited>; try { - ocrResult = await callGeminiReceiptOcr({ - apiKey: credential.apiKey, - imageBase64: base64Image, - mimeType: 'image/jpeg', - prompt: OCR_PROMPT, - env: { ...process.env, GEMINI_OCR_MODEL: candidate.models.join(',') }, - signal: req.signal, + quota = await checkOcrDailyQuota({ + quotaClient: ocrSupabase as never, + operationId: crypto.randomUUID(), }); - usedCandidate = candidate; - break; } catch { - req.signal.throwIfAborted(); - failedAttemptCount += 1; - if (aiRuntime.routingMode === 'manual') { - throw new Error('OCR_MANUAL_PROVIDER_FAILED'); - } + throw new OcrHttpError(503, 'OCR 사용 한도를 확인할 수 없습니다.'); } - } - if (ocrResult) break; - } - if (!ocrResult || !usedCandidate) { - throw new Error('OCR_PROVIDERS_FAILED'); - } - const restaurantMatches = await findOcrRestaurantMatches({ - receiptStoreName: ocrResult.data.store_name, - selectedRestaurant: selectedRestaurantContext, - ...createRestaurantLookupCallbacks(ocrSupabase as never), - }); - const envelope = buildReceiptOcrEnvelope({ - provider: usedCandidate.provider, - model: ocrResult.model, - attempts: ocrResult.attempts, - data: ocrResult.data, - matchedRestaurantCandidates: restaurantMatches.candidates, + if (quota.exceeded) { + throw new OcrHttpError(429, `일일 무료 분석 한도(${OCR_DAILY_QUOTA}회)를 초과했습니다. 내일 00시에 초기화됩니다.`); + } + }, + runProvider: async () => { + req.signal.throwIfAborted(); + const { optimized, savings } = await optimizeImage(buffer!); + const ocrResult = await callGeminiReceiptOcrWithCredentialFallback({ + credentials, + imageBase64: optimized.toString('base64'), + mimeType: 'image/jpeg', + prompt: OCR_PROMPT, + env: { ...process.env, GEMINI_OCR_MODEL: modelPolicy.join(',') }, + inferenceConfig, + signal: req.signal, + }); + const restaurantMatches = await findOcrRestaurantMatches({ + receiptStoreName: ocrResult.data.store_name, + selectedRestaurant: selectedRestaurantContext, + ...createRestaurantLookupCallbacks(ocrSupabase as never), + }); + const envelope = buildReceiptOcrEnvelope({ + provider: candidate.provider, + model: ocrResult.model, + attempts: ocrResult.attempts, + data: ocrResult.data, + matchedRestaurantCandidates: restaurantMatches.candidates, + }); + freshArtifacts.current = { optimized, savings, ocrResult, envelope, restaurantMatches }; + const payload = buildDurableReceiptOcrPayload({ provider: candidate.provider, model: ocrResult.model, envelope }); + assertPrivacySafe(payload); + return payload; + }, }); - const responsePayload = flattenReceiptOcrEnvelope(envelope); + const responsePayload = toPublicReceiptOcrResponse(durable); // 성공 감사 로그에는 원본 OCR, 보정 결과, 자격 증명 출처를 저장하지 않습니다. - const successLogMetadata = buildOcrSuccessLogMetadata({ + if (freshArtifacts.current) { + const fresh = freshArtifacts.current; + const successLogMetadata = buildOcrSuccessLogMetadata({ fileSize: file.size, - compressedSize: optimized.length, - savings, - provider: usedCandidate.provider, - model: ocrResult.model, + compressedSize: fresh.optimized.length, + savings: fresh.savings, + provider: candidate.provider, + model: fresh.ocrResult.model, promptVersion: RECEIPT_OCR_PROMPT_VERSION, preprocessVersion: RECEIPT_OCR_PREPROCESS_VERSION, routingMode: aiRuntime.routingMode, normalizationVersion: RECEIPT_OCR_NORMALIZATION_VERSION, - fallbackUsed: usedCandidate.provider !== aiRuntime.provider || failedAttemptCount > 0, + fallbackUsed: candidate.provider !== aiRuntime.provider, forceRefresh, - envelope, - restaurantLookupStats: restaurantMatches.stats, + envelope: fresh.envelope, + restaurantLookupStats: fresh.restaurantMatches.stats, }); assertPrivacySafe(successLogMetadata); const { error: logError } = await ocrLogsTable.insert({ user_id: user.id, image_hash: imageHash, - model_used: ocrResult.model, + model_used: fresh.ocrResult.model, success: true, metadata: successLogMetadata, } as never); if (logError) console.error('OCR Log Insert Error:'); + } - return NextResponse.json(responsePayload); + return NextResponse.json(responsePayload, { headers: { 'Cache-Control': 'no-store' } }); } catch (error: unknown) { + if (error instanceof DurableReceiptOcrCapabilityHeldError) { + return NextResponse.json( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 503, headers: { 'Cache-Control': 'no-store' } }, + ); + } + if (error instanceof OcrHttpError) { + return NextResponse.json({ error: error.publicMessage }, { + status: error.status, + headers: { 'Cache-Control': 'no-store' }, + }); + } + if (error instanceof DurableReceiptOcrPendingError) { + return NextResponse.json({ + code: error.state === 'processing' ? 'OCR_RESULT_PROCESSING' : 'OCR_RESULT_INDETERMINATE', + error: error.state === 'processing' + ? '같은 영수증 분석이 진행 중입니다. 잠시 후 다시 확인해주세요.' + : '이전 분석 결과를 확인할 수 없어 자동 재호출하지 않았습니다. 관리자에게 다시 분석을 요청해주세요.', + }, { status: 409, headers: { 'Cache-Control': 'no-store' } }); + } const failureCode = error instanceof GeminiOcrError ? 'GEMINI_OCR_FAILED' : 'OCR_PROCESSING_FAILED'; diff --git a/apps/web/app/api/ocr/extract/stream/route.ts b/apps/web/app/api/ocr/extract/stream/route.ts index c9df295d63..01e6d82f7d 100644 --- a/apps/web/app/api/ocr/extract/stream/route.ts +++ b/apps/web/app/api/ocr/extract/stream/route.ts @@ -1,18 +1,38 @@ import crypto from 'crypto'; import sharp from 'sharp'; import { assertPrivacySafe } from '@/lib/privacy/sanitize'; -import { callGeminiReceiptOcr, GEMINI_OCR_FALLBACK_MODEL, GeminiOcrError } from '@/lib/ocr/gemini'; +import { + callGeminiReceiptOcrWithCredentialFallback, + GEMINI_OCR_FALLBACK_MODEL, + GeminiOcrError, + getGeminiOcrInferenceConfigSnapshot, + type GeminiOcrInferenceConfigSnapshot, +} from '@/lib/ocr/gemini'; import { RECEIPT_OCR_EXTRACTION_PROMPT, RECEIPT_OCR_PREPROCESS_VERSION, RECEIPT_OCR_PROMPT_VERSION, } from '@/lib/ocr/receipt-prompt'; -import { resolveOcrAiRuntimeConfig, type OcrAiRuntimeConfigCandidate } from '@/lib/ocr/runtime-config'; +import { + getOcrModelPolicy, + resolveOcrAiRuntimeConfig, + type OcrAiRuntimeConfigCandidate, + type OcrCredentialCandidate, +} from '@/lib/ocr/runtime-config'; import { buildReceiptOcrEnvelope, - flattenReceiptOcrEnvelope, RECEIPT_OCR_NORMALIZATION_VERSION, } from '@/lib/ocr/receipt-normalization'; +import { + DurableReceiptOcrCapabilityHeldError, + DurableReceiptOcrPendingError, + buildDurableReceiptOcrPayload, + buildOcrInputContextHash, + createSupabaseDurableReceiptOcrStore, + executeDurableReceiptOcr, + RECEIPT_OCR_DEFAULT_GENERATION, + toPublicReceiptOcrResponse, +} from '@/lib/ocr/durable-result'; import { findOcrRestaurantMatches } from '@/lib/ocr/restaurant-matching'; import { buildOcrSuccessLogMetadata, @@ -35,6 +55,9 @@ import { readBoundedOcrFormData, } from '@/lib/ocr/request-security'; import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; +import { createSupabaseServiceRoleClient } from '@/lib/supabase/service-role'; +import { FEATURE_DATABASE_HELD_CODE, FEATURE_DATABASE_HELD_MESSAGE } from '@/lib/runtime-feature-readiness-contract'; +import { readFeatureDatabaseReadiness } from '@/lib/server/runtime-feature-readiness'; export const runtime = 'nodejs'; @@ -98,19 +121,23 @@ async function optimizeImage(buffer: Buffer): Promise<{ optimized: Buffer; savin async function runStreamingOcrCandidate(input: { candidate: OcrAiRuntimeConfigCandidate; + credentials: OcrCredentialCandidate[]; imageBase64: string; + inferenceConfig: GeminiOcrInferenceConfigSnapshot; signal: AbortSignal; send: (event: string, payload: Record) => void; }) { - const { candidate, imageBase64, signal, send } = input; - if (!candidate.apiKey) throw new Error(`${candidate.provider} OCR API 키가 설정되지 않았습니다.`); + const { candidate, credentials, imageBase64, inferenceConfig, signal, send } = input; + if (!credentials[0]) throw new Error(`${candidate.provider} OCR API 키가 설정되지 않았습니다.`); + const modelPolicy = getOcrModelPolicy(candidate); - const geminiResult = await callGeminiReceiptOcr({ - apiKey: candidate.apiKey, + const geminiResult = await callGeminiReceiptOcrWithCredentialFallback({ + credentials, imageBase64, mimeType: 'image/jpeg', prompt: RECEIPT_OCR_EXTRACTION_PROMPT, - env: { ...process.env, GEMINI_OCR_MODEL: candidate.models.join(',') }, + env: { ...process.env, GEMINI_OCR_MODEL: modelPolicy.join(',') }, + inferenceConfig, signal, }); send('model_attempt', { attempt: geminiResult.attempts.at(-1) }); @@ -140,6 +167,12 @@ export async function POST(req: Request) { }); } const { supabase, user } = auth; + if ((await readFeatureDatabaseReadiness({ ownerId: user.id })).status !== 'ready') { + return new Response(JSON.stringify({ code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }), { + status: 409, + headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' }, + }); + } const multipart = await readBoundedOcrFormData(req); if (!multipart.ok) { @@ -157,12 +190,10 @@ export async function POST(req: Request) { const providerCandidates = [aiRuntime, ...aiRuntime.fallbackCandidates]; const runnableCandidates = providerCandidates.filter(candidate => hasRunnableOcrCredentials([candidate])); const effectiveCandidates = runnableCandidates.length ? runnableCandidates : providerCandidates; - if (!hasRunnableOcrCredentials(runnableCandidates)) { - return new Response(JSON.stringify({ error: 'Gemini OCR API 키가 설정되지 않았습니다.' }), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }); - } + const candidate = effectiveCandidates[0]; + const modelPolicy = candidate ? getOcrModelPolicy(candidate) : [GEMINI_OCR_FALLBACK_MODEL]; + const inferenceConfig = getGeminiOcrInferenceConfigSnapshot(); + const model = modelPolicy[0]; if (!file) { return new Response(JSON.stringify({ error: '이미지가 제공되지 않았습니다' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } @@ -191,43 +222,59 @@ export async function POST(req: Request) { }); } - try { - const quota = await checkOcrDailyQuota({ - quotaClient: ocrSupabase as never, - operationId: crypto.randomUUID(), - }); - - if (quota.exceeded) { - return new Response(JSON.stringify({ error: `일일 무료 분석 한도(${OCR_DAILY_QUOTA}회)를 초과했습니다. 내일 00시에 초기화됩니다.` }), { - status: 429, - headers: { 'Content-Type': 'application/json' }, - }); - } - } catch { - return new Response(JSON.stringify({ error: 'OCR 사용 한도를 확인할 수 없습니다.' }), { - status: 503, - headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' }, - }); - } - return createSseResponse(async (send) => { - let failedAttemptCount = 0; + let providerStarted = false; let lastFailedProvider = aiRuntime.provider; - let lastFailedModel = aiRuntime.model || aiRuntime.models[0] || GEMINI_OCR_FALLBACK_MODEL; + let lastFailedModel = model; try { - send('progress', { message: '영수증 이미지를 읽기 좋게 압축하고 있어요.', stage: 'preprocess' }); - const { optimized, savings } = await optimizeImage(buffer); - send('progress', { message: 'AI 모델이 영수증을 분석하기 시작했어요.', stage: 'model_start', savings }); - - for (const candidate of effectiveCandidates) { - if (req.signal.aborted) return; - const credentials = getRunnableCredentials({ candidate, routingMode: aiRuntime.routingMode }); - for (const credential of credentials) { - if (req.signal.aborted) return; - try { + if (!candidate) throw new Error('OCR_CONFIG_UNAVAILABLE'); + const credentials = getRunnableCredentials({ candidate, routingMode: aiRuntime.routingMode }); + const freshArtifacts: { current: { + optimized: Buffer; + savings: string; + result: Awaited>; + envelope: ReturnType; + restaurantMatches: Awaited>; + } | null } = { current: null }; + const durable = await executeDurableReceiptOcr({ + store: createSupabaseDurableReceiptOcrStore(createSupabaseServiceRoleClient() as never), + binding: { + userId: user.id, + imageHash, + inputContextHash: buildOcrInputContextHash(imageHash, selectedRestaurantContext, inferenceConfig), + provider: candidate.provider, + modelPolicy, + generation: RECEIPT_OCR_DEFAULT_GENERATION, + promptVersion: RECEIPT_OCR_PROMPT_VERSION, + preprocessVersion: RECEIPT_OCR_PREPROCESS_VERSION, + normalizationVersion: RECEIPT_OCR_NORMALIZATION_VERSION, + }, + operationId: crypto.randomUUID(), + forceRefresh, + beforeProvider: async () => { + if (!credentials[0]) throw new Error('OCR_CREDENTIAL_UNAVAILABLE'); + let quota: Awaited>; + try { + quota = await checkOcrDailyQuota({ + quotaClient: ocrSupabase as never, + operationId: crypto.randomUUID(), + }); + } catch { + throw new Error('OCR_QUOTA_UNAVAILABLE'); + } + if (quota.exceeded) throw new Error('OCR_QUOTA_EXCEEDED'); + }, + runProvider: async () => { + if (req.signal.aborted) throw new DOMException('aborted', 'AbortError'); + send('progress', { message: '영수증 이미지를 읽기 좋게 압축하고 있어요.', stage: 'preprocess' }); + const { optimized, savings } = await optimizeImage(buffer); + send('progress', { message: 'AI 모델이 영수증을 분석하기 시작했어요.', stage: 'model_start', savings }); + providerStarted = true; const result = await runStreamingOcrCandidate({ - candidate: { ...candidate, apiKey: credential.apiKey }, + candidate, + credentials, imageBase64: optimized.toString('base64'), + inferenceConfig, signal: req.signal, send, }); @@ -243,51 +290,74 @@ export async function POST(req: Request) { data: result.data, matchedRestaurantCandidates: restaurantMatches.candidates, }); - const responsePayload = flattenReceiptOcrEnvelope(envelope); - send('field_patch', { data: responsePayload, model: result.model, final: true }); + freshArtifacts.current = { optimized, savings, result, envelope, restaurantMatches }; + const payload = buildDurableReceiptOcrPayload({ provider: candidate.provider, model: result.model, envelope }); + assertPrivacySafe(payload); + return payload; + }, + }); + const responsePayload = toPublicReceiptOcrResponse(durable); + send('field_patch', { data: responsePayload, model: durable.payload.model, final: true }); + if (freshArtifacts.current) { + const fresh = freshArtifacts.current; const successLogMetadata = buildOcrSuccessLogMetadata({ fileSize: file.size, - compressedSize: optimized.length, - savings, + compressedSize: fresh.optimized.length, + savings: fresh.savings, provider: candidate.provider, - model: result.model, + model: fresh.result.model, promptVersion: RECEIPT_OCR_PROMPT_VERSION, preprocessVersion: RECEIPT_OCR_PREPROCESS_VERSION, routingMode: aiRuntime.routingMode, normalizationVersion: RECEIPT_OCR_NORMALIZATION_VERSION, - fallbackUsed: candidate.provider !== aiRuntime.provider || failedAttemptCount > 0, + fallbackUsed: candidate.provider !== aiRuntime.provider, forceRefresh, - envelope, - restaurantLookupStats: restaurantMatches.stats, + envelope: fresh.envelope, + restaurantLookupStats: fresh.restaurantMatches.stats, }); assertPrivacySafe(successLogMetadata); const { error: logError } = await ocrLogsTable.insert({ user_id: user.id, image_hash: imageHash, - model_used: result.model, + model_used: fresh.result.model, success: true, metadata: successLogMetadata, } as never); if (logError) { send('progress', { message: '분석은 완료됐지만 분석 로그 저장은 실패했어요.', stage: 'log_warning' }); } - send('done', { data: responsePayload, model: result.model, attempts: result.attempts }); - return; - } catch { - failedAttemptCount += 1; - lastFailedProvider = candidate.provider; - lastFailedModel = candidate.models[0] ?? candidate.model ?? GEMINI_OCR_FALLBACK_MODEL; - if (aiRuntime.routingMode === 'manual') { - throw new Error('OCR_MANUAL_PROVIDER_FAILED'); - } - send('model_attempt', { attempt: { model: candidate.model, ok: false, elapsedMs: 0, error: 'provider fallback' } }); - } - } } - - throw new Error('OCR_PROVIDERS_FAILED'); + send('done', { data: responsePayload, model: durable.payload.model, attempts: freshArtifacts.current?.result.attempts ?? [] }); } catch (error) { + if (error instanceof DurableReceiptOcrCapabilityHeldError) { + send('error', { + code: FEATURE_DATABASE_HELD_CODE, + message: FEATURE_DATABASE_HELD_MESSAGE, + terminal: true, + status: 503, + }); + return; + } + if (error instanceof DurableReceiptOcrPendingError) { + send('error', { + code: error.state === 'processing' ? 'OCR_RESULT_PROCESSING' : 'OCR_RESULT_INDETERMINATE', + message: error.state === 'processing' + ? '같은 영수증 분석이 진행 중입니다. 잠시 후 다시 확인해주세요.' + : '이전 분석 결과를 확인할 수 없어 자동 재호출하지 않았습니다. 관리자에게 다시 분석을 요청해주세요.', + terminal: true, + status: 409, + }); + return; + } + if (error instanceof Error && ['OCR_CREDENTIAL_UNAVAILABLE', 'OCR_QUOTA_UNAVAILABLE', 'OCR_QUOTA_EXCEEDED'].includes(error.message)) { + const status = error.message === 'OCR_QUOTA_EXCEEDED' ? 429 : error.message === 'OCR_QUOTA_UNAVAILABLE' ? 503 : 500; + const message = error.message === 'OCR_QUOTA_EXCEEDED' + ? `일일 무료 분석 한도(${OCR_DAILY_QUOTA}회)를 초과했습니다. 내일 00시에 초기화됩니다.` + : error.message === 'OCR_QUOTA_UNAVAILABLE' ? 'OCR 사용 한도를 확인할 수 없습니다.' : 'Gemini OCR API 키가 설정되지 않았습니다.'; + send('error', { message, terminal: true, status }); + return; + } const failureCode = error instanceof GeminiOcrError ? 'GEMINI_OCR_FAILED' : 'OCR_PROCESSING_FAILED'; @@ -295,7 +365,7 @@ export async function POST(req: Request) { const failureMetadata = { error_code: failureCode, provider: lastFailedProvider, - attempt_count: failedAttemptCount, + attempt_count: providerStarted ? 1 : 0, }; assertPrivacySafe(failureMetadata); await ocrLogsTable.insert({ diff --git a/apps/web/app/api/ocr/results/status/route.ts b/apps/web/app/api/ocr/results/status/route.ts new file mode 100644 index 0000000000..346b0ad972 --- /dev/null +++ b/apps/web/app/api/ocr/results/status/route.ts @@ -0,0 +1,120 @@ +import crypto from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { NextResponse } from 'next/server'; + +import { + RECEIPT_OCR_DEFAULT_GENERATION, + buildOcrInputContextHash, + createSupabaseDurableReceiptOcrStore, + pollDurableReceiptOcrResult, + toPublicReceiptOcrResponse, +} from '@/lib/ocr/durable-result'; +import { GEMINI_OCR_FALLBACK_MODEL, getGeminiOcrInferenceConfigSnapshot } from '@/lib/ocr/gemini'; +import { RECEIPT_OCR_PREPROCESS_VERSION, RECEIPT_OCR_PROMPT_VERSION } from '@/lib/ocr/receipt-prompt'; +import { RECEIPT_OCR_NORMALIZATION_VERSION } from '@/lib/ocr/receipt-normalization'; +import { hasRunnableOcrCredentials, parseSelectedRestaurantContext } from '@/lib/ocr/route-helpers'; +import { getOcrModelPolicy, resolveOcrAiRuntimeConfig } from '@/lib/ocr/runtime-config'; +import { + authenticateOcrRequest, + getOcrUploadRejectionForRequest, + readBoundedOcrFormData, + readOcrImageFile, +} from '@/lib/ocr/request-security'; +import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; +import { createSupabaseServiceRoleClient } from '@/lib/supabase/service-role'; +import { FEATURE_DATABASE_HELD_CODE, FEATURE_DATABASE_HELD_MESSAGE } from '@/lib/runtime-feature-readiness-contract'; +import { readFeatureDatabaseReadiness } from '@/lib/server/runtime-feature-readiness'; + +export const runtime = 'nodejs'; + +const headers = { 'Cache-Control': 'private, no-store, max-age=0', Pragma: 'no-cache' }; +const READ_ATTEMPTS = 4; +const READ_INTERVAL_MS = 500; + +export async function POST(request: Request) { + if (!isTrustedSameOriginMutation(request)) { + return NextResponse.json({ error: '허용되지 않은 요청입니다.' }, { status: 403, headers }); + } + const uploadRejection = getOcrUploadRejectionForRequest(request.headers); + if (uploadRejection) { + return NextResponse.json({ error: uploadRejection.error }, { status: uploadRejection.status, headers }); + } + const auth = await authenticateOcrRequest(request); + if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status, headers }); + if ((await readFeatureDatabaseReadiness({ ownerId: auth.user.id })).status !== 'ready') { + return NextResponse.json( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 409, headers }, + ); + } + + try { + const multipart = await readBoundedOcrFormData(request); + if (!multipart.ok) return NextResponse.json({ error: multipart.error }, { status: multipart.status, headers }); + const file = multipart.formData.get('image'); + if (!(file instanceof File)) return NextResponse.json({ error: '이미지가 제공되지 않았습니다' }, { status: 400, headers }); + const image = await readOcrImageFile(file); + if (!image.ok) return NextResponse.json({ error: image.error }, { status: image.status, headers }); + + const aiRuntime = await resolveOcrAiRuntimeConfig(); + const providerCandidates = [aiRuntime, ...aiRuntime.fallbackCandidates]; + const runnableCandidates = providerCandidates.filter((candidate) => hasRunnableOcrCredentials([candidate])); + const candidate = (runnableCandidates.length ? runnableCandidates : providerCandidates)[0]; + const modelPolicy = candidate ? getOcrModelPolicy(candidate) : [GEMINI_OCR_FALLBACK_MODEL]; + const inferenceConfig = getGeminiOcrInferenceConfigSnapshot(); + const imageHash = crypto.createHash('sha256').update(image.buffer).digest('hex'); + const binding = { + userId: auth.user.id, + imageHash, + inputContextHash: buildOcrInputContextHash( + imageHash, + parseSelectedRestaurantContext(multipart.formData), + inferenceConfig, + ), + provider: candidate?.provider ?? 'gemini' as const, + modelPolicy, + generation: RECEIPT_OCR_DEFAULT_GENERATION, + promptVersion: RECEIPT_OCR_PROMPT_VERSION, + preprocessVersion: RECEIPT_OCR_PREPROCESS_VERSION, + normalizationVersion: RECEIPT_OCR_NORMALIZATION_VERSION, + }; + const store = createSupabaseDurableReceiptOcrStore(createSupabaseServiceRoleClient() as never); + + const result = await pollDurableReceiptOcrResult({ + store, + binding, + attempts: READ_ATTEMPTS, + wait: () => delay(READ_INTERVAL_MS, undefined, { signal: request.signal }), + }); + if (result.status === 'succeeded') { + return NextResponse.json(toPublicReceiptOcrResponse({ ...result, cacheReused: true }), { status: 200, headers }); + } + if (result.status === 'indeterminate') { + return NextResponse.json( + { code: 'OCR_RESULT_INDETERMINATE', error: '이전 분석 결과를 확인할 수 없습니다.' }, + { status: 409, headers }, + ); + } + if (result.status === 'processing') { + return NextResponse.json( + { code: 'OCR_RESULT_PROCESSING', error: '같은 영수증 분석이 진행 중입니다.' }, + { status: 202, headers }, + ); + } + + return NextResponse.json( + { code: 'OCR_RESULT_NOT_FOUND', error: '분석 결과를 찾지 못했습니다.' }, + { status: 404, headers }, + ); + } catch (error) { + const aborted = request.signal.aborted || (error instanceof Error && error.name === 'AbortError'); + return NextResponse.json( + { + code: aborted ? 'OCR_STATUS_CANCELLED' : 'OCR_STATUS_UNAVAILABLE', + error: aborted ? '결과 확인을 중단했습니다.' : '저장된 분석 결과를 확인하지 못했습니다.', + }, + { status: aborted ? 408 : 503, headers }, + ); + } +} diff --git a/apps/web/app/api/ocr/reviews/save/route.ts b/apps/web/app/api/ocr/reviews/save/route.ts new file mode 100644 index 0000000000..18d9b2e25d --- /dev/null +++ b/apps/web/app/api/ocr/reviews/save/route.ts @@ -0,0 +1,234 @@ +import { NextResponse } from 'next/server'; +import { getCanonicalReviewPhotoObjectPath } from '@/lib/review-photo-url'; +import { authenticateOcrRequest } from '@/lib/ocr/request-security'; +import { + type ReviewStorageObject, + ReviewStorageVerificationError, + verifyReviewStorageForReceipt, +} from '@/lib/ocr/review-storage-verification'; +import { readBoundedJsonRequest } from '@/lib/security/bounded-json-request'; +import { isTrustedSameOriginMutation } from '@/lib/security/same-origin-mutation'; +import { createSupabaseServiceRoleClient } from '@/lib/supabase/service-role'; +import { FEATURE_DATABASE_HELD_CODE, FEATURE_DATABASE_HELD_MESSAGE } from '@/lib/runtime-feature-readiness-contract'; +import { readFeatureDatabaseReadiness } from '@/lib/server/runtime-feature-readiness'; +import { REVIEW_FOOD_PHOTO_MAX_COUNT } from '@/lib/reviews/review-save-operation'; + +export const runtime = 'nodejs'; + +const MAX_REQUEST_BYTES = 32 * 1024; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CATEGORIES = new Set([ + '치킨', '중식', '돈까스·회', '피자', '패스트푸드', '찜·탕', '족발·보쌈', '분식', + '카페·디저트', '한식', '고기', '양식', '아시안', '야식', '도시락', +]); + +type ReviewSaveBody = { + reviewId: string; + restaurantId: string; + title: string; + content: string; + visitedAt: string; + verificationPhoto: string; + foodPhotos: string[]; + categories: string[]; + ocrResultRef: string; + ocrResultRevision: string; +}; + +function noStoreJson(body: unknown, init: ResponseInit = {}) { + const response = NextResponse.json(body, init); + response.headers.set('Cache-Control', 'no-store'); + return response; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function parseStorageObject(value: unknown): ReviewStorageObject | null { + if (!isRecord(value) + || typeof value.id !== 'string' + || typeof value.bucket_id !== 'string' + || typeof value.name !== 'string' + || (value.owner_id !== null && typeof value.owner_id !== 'string') + || (value.version !== null && typeof value.version !== 'string') + || typeof value.updated_at !== 'string' + || (value.archived_at !== null && typeof value.archived_at !== 'string') + || typeof value.is_delete_marker !== 'boolean' + || (value.metadata !== null && !isRecord(value.metadata))) { + return null; + } + return { + id: value.id, + bucketId: value.bucket_id, + name: value.name, + ownerId: value.owner_id, + version: value.version, + updatedAt: value.updated_at, + metadata: value.metadata, + archivedAt: value.archived_at, + isDeleteMarker: value.is_delete_marker, + }; +} + +function parseBody(value: unknown): ReviewSaveBody | null { + if (!isRecord(value)) return null; + const keys = Object.keys(value); + const expected = [ + 'reviewId', 'restaurantId', 'title', 'content', 'visitedAt', 'verificationPhoto', + 'foodPhotos', 'categories', 'ocrResultRef', 'ocrResultRevision', + ]; + if (keys.length !== expected.length || keys.some((key) => !expected.includes(key))) return null; + if (!UUID_PATTERN.test(String(value.reviewId)) + || !UUID_PATTERN.test(String(value.restaurantId)) + || !UUID_PATTERN.test(String(value.ocrResultRef)) + || !UUID_PATTERN.test(String(value.ocrResultRevision)) + || typeof value.title !== 'string' + || !value.title.trim() + || value.title.length > 200 + || typeof value.content !== 'string' + || value.content.trim().length < 20 + || value.content.length > 4_000 + || typeof value.visitedAt !== 'string' + || !Number.isFinite(Date.parse(value.visitedAt)) + || typeof value.verificationPhoto !== 'string' + || !Array.isArray(value.foodPhotos) + || value.foodPhotos.length < 1 + || value.foodPhotos.length > REVIEW_FOOD_PHOTO_MAX_COUNT + || !value.foodPhotos.every((path) => typeof path === 'string') + || !Array.isArray(value.categories) + || value.categories.length < 1 + || value.categories.length > CATEGORIES.size + || !value.categories.every((category) => typeof category === 'string' && CATEGORIES.has(category)) + || new Set(value.categories).size !== value.categories.length) { + return null; + } + return value as ReviewSaveBody; +} + +export async function POST(request: Request) { + if (!isTrustedSameOriginMutation(request)) { + return noStoreJson({ error: '요청을 처리할 수 없습니다.' }, { status: 403 }); + } + const auth = await authenticateOcrRequest(request); + if (!auth.ok) return noStoreJson({ error: auth.error }, { status: auth.status }); + if ((await readFeatureDatabaseReadiness({ ownerId: auth.user.id })).status !== 'ready') { + return noStoreJson( + { code: FEATURE_DATABASE_HELD_CODE, error: FEATURE_DATABASE_HELD_MESSAGE }, + { status: 409 }, + ); + } + + const parsed = await readBoundedJsonRequest(request, MAX_REQUEST_BYTES); + const body = parsed.ok ? parseBody(parsed.value) : null; + if (!body) { + return noStoreJson({ error: '리뷰 저장 정보를 확인할 수 없습니다.' }, { status: parsed.ok ? 400 : parsed.code === 'BODY_TOO_LARGE' ? 413 : 400 }); + } + + const verification = getCanonicalReviewPhotoObjectPath(body.verificationPhoto, { + ownerId: auth.user.id, + reviewId: body.reviewId, + purpose: 'verification', + }); + const food = body.foodPhotos.map((path) => getCanonicalReviewPhotoObjectPath(path, { + ownerId: auth.user.id, + reviewId: body.reviewId, + purpose: 'food', + })); + if (!verification || food.some((path) => !path)) { + return noStoreJson({ error: '리뷰 사진 경로를 확인할 수 없습니다.' }, { status: 400 }); + } + + try { + const service = createSupabaseServiceRoleClient(); + const verifiedStorage = await verifyReviewStorageForReceipt({ + ownerId: auth.user.id, + verificationPhoto: verification, + foodPhotos: food as string[], + adapter: { + async loadObjects(bucket, names) { + const purpose = bucket === 'review-verifications' ? 'verification' : bucket === 'review-photos' ? 'food' : null; + if (!purpose) throw new ReviewStorageVerificationError(); + const lookup = await service.rpc('get_owned_review_storage_objects' as never, { + p_user_id: auth.user.id, + p_review_id: body.reviewId, + p_purpose: purpose, + p_names: names, + } as never) as unknown as { data: unknown; error: unknown }; + const { data, error } = lookup; + if (error || !Array.isArray(data)) throw new ReviewStorageVerificationError(); + const rows = data.map(parseStorageObject); + if (rows.some((row) => row === null)) throw new ReviewStorageVerificationError(); + return rows as ReviewStorageObject[]; + }, + async download(bucket, name) { + const { data, error } = await service.storage.from(bucket).download(name); + if (error || !data) throw new ReviewStorageVerificationError(); + return data; + }, + }, + }); + const { data, error } = await service.rpc('create_review_with_receipt_ocr' as never, { + p_review_id: body.reviewId, + p_user_id: auth.user.id, + p_restaurant_id: body.restaurantId, + p_title: body.title.trim(), + p_content: body.content.trim(), + p_visited_at: body.visitedAt, + p_verification_photo: verification, + p_food_photos: food, + p_categories: body.categories, + p_ocr_result_id: body.ocrResultRef, + p_ocr_result_revision: body.ocrResultRevision, + p_verification_object_id: verifiedStorage.verificationObjectId, + p_verification_object_version: verifiedStorage.verificationObjectVersion, + p_verification_object_updated_at: verifiedStorage.verificationObjectUpdatedAt, + p_verification_photo_sha256: verifiedStorage.verificationPhotoSha256, + p_user_corrections: { + restaurant_id: body.restaurantId, + visited_at: body.visitedAt, + categories: body.categories, + }, + } as never); + + if (error) { + const code = (error as { code?: string }).code; + return noStoreJson( + { error: 'OCR 검증 결과와 함께 리뷰를 저장하지 못했습니다.' }, + { status: code === '23505' ? 409 : code === '42501' || code === '22023' ? 422 : 503 }, + ); + } + if (data !== body.reviewId) { + return noStoreJson({ error: '리뷰 저장 결과를 확인하지 못했습니다.' }, { status: 503 }); + } + + const { data: readback, error: readbackError } = await service + .from('reviews') + .select('id, user_id, restaurant_id, verification_photo, food_photos, receipt_data, ocr_processed_at') + .eq('id', body.reviewId) + .eq('user_id', auth.user.id) + .maybeSingle(); + const receiptData = isRecord(readback?.receipt_data) ? readback.receipt_data : null; + if (readbackError + || readback?.id !== body.reviewId + || readback?.restaurant_id !== body.restaurantId + || readback?.verification_photo !== verification + || !Array.isArray(readback?.food_photos) + || readback.food_photos.length !== food.length + || !readback.food_photos.every((path, index) => path === food[index]) + || receiptData?.ocr_result_id !== body.ocrResultRef + || receiptData?.ocr_result_revision !== body.ocrResultRevision + || typeof readback?.ocr_processed_at !== 'string') { + return noStoreJson({ error: '리뷰 저장 결과를 확인하지 못했습니다.' }, { status: 503 }); + } + + return noStoreJson({ + success: true, + reviewId: body.reviewId, + ocrResultRef: body.ocrResultRef, + ocrResultRevision: body.ocrResultRevision, + }); + } catch { + return noStoreJson({ error: 'OCR 검증 결과와 함께 리뷰를 저장하지 못했습니다.' }, { status: 503 }); + } +} diff --git a/apps/web/app/api/runtime-compatibility/features/route.ts b/apps/web/app/api/runtime-compatibility/features/route.ts new file mode 100644 index 0000000000..52d8362171 --- /dev/null +++ b/apps/web/app/api/runtime-compatibility/features/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; + +import { FEATURE_DATABASE_HELD } from '@/lib/runtime-feature-readiness-contract'; +import { readFeatureDatabaseReadiness } from '@/lib/server/runtime-feature-readiness'; +import { createClient } from '@/lib/supabase/server'; + +const headers = Object.freeze({ 'Cache-Control': 'private, no-store, max-age=0', Pragma: 'no-cache' }); + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const supabase = await createClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + if (error || !user) { + return NextResponse.json(FEATURE_DATABASE_HELD, { headers, status: 401 }); + } + return NextResponse.json(await readFeatureDatabaseReadiness({ ownerId: user.id }), { headers, status: 200 }); + } catch { + return NextResponse.json(FEATURE_DATABASE_HELD, { headers, status: 503 }); + } +} diff --git a/apps/web/app/api/runtime-compatibility/storyboard/route.ts b/apps/web/app/api/runtime-compatibility/storyboard/route.ts new file mode 100644 index 0000000000..8cfa026721 --- /dev/null +++ b/apps/web/app/api/runtime-compatibility/storyboard/route.ts @@ -0,0 +1,8 @@ +import { createPublicStoryboardCompatibilityResponse } from './runtime-response'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export function GET() { + return createPublicStoryboardCompatibilityResponse(); +} diff --git a/apps/web/app/api/runtime-compatibility/storyboard/runtime-response.ts b/apps/web/app/api/runtime-compatibility/storyboard/runtime-response.ts new file mode 100644 index 0000000000..070e6d5078 --- /dev/null +++ b/apps/web/app/api/runtime-compatibility/storyboard/runtime-response.ts @@ -0,0 +1,69 @@ +import 'server-only'; + +import { NextResponse } from 'next/server'; + +import { evaluateStoryboardRuntimePolicy } from '@/lib/admin/storyboard/runtime-policy'; + +const TZUDONG_VERCEL_PROJECT_ID = 'prj_sau35J5uUtShIQ9OKofRtOVVnTSl'; +const DEPLOYMENT_ID = /^dpl_[A-Za-z0-9]+$/; +const GIT_SHA = /^[0-9a-f]{40}$/; +const NO_STORE_HEADERS = Object.freeze({ + 'Cache-Control': 'no-store, max-age=0', + Pragma: 'no-cache', +}); +const SCHEMA_VERSION = 1; +const KIND = 'storyboard-runtime-compatibility'; + +function unavailable() { + return NextResponse.json( + { schemaVersion: SCHEMA_VERSION, kind: 'storyboard-runtime-compatibility-unavailable' }, + { headers: NO_STORE_HEADERS, status: 503 }, + ); +} + +function runtimeIdentity(environment: NodeJS.ProcessEnv) { + const deploymentId = environment.VERCEL_DEPLOYMENT_ID?.trim() ?? ''; + const commit = environment.VERCEL_GIT_COMMIT_SHA?.trim() ?? ''; + if (environment.VERCEL !== '1' + || environment.VERCEL_ENV !== 'production' + || environment.VERCEL_TARGET_ENV !== 'production' + || environment.VERCEL_PROJECT_ID !== TZUDONG_VERCEL_PROJECT_ID + || environment.VERCEL_GIT_COMMIT_REF !== 'main' + || !DEPLOYMENT_ID.test(deploymentId) + || !GIT_SHA.test(commit)) return null; + return Object.freeze({ commit, deploymentId }); +} + +/** Evaluates code-shared admission only. It performs no provider, database, or network operation. */ +export function publicStoryboardCanonicalPolicySha256(sourceBindingSha256: string): string { + return evaluateStoryboardRuntimePolicy(sourceBindingSha256).canonicalPolicySha256; +} + +export function createPublicStoryboardCompatibilityResponse({ + environment = process.env, + policySha256 = publicStoryboardCanonicalPolicySha256, + sourceBindingSha256 = process.env.STORYBOARD_RUNTIME_SOURCE_BINDING_SHA256 ?? '', +}: { + environment?: NodeJS.ProcessEnv; + policySha256?: (sourceBindingSha256: string) => string; + sourceBindingSha256?: string; +} = {}) { + const identity = runtimeIdentity(environment); + if (!identity) return unavailable(); + + let canonicalPolicySha256: string; + try { + canonicalPolicySha256 = policySha256(sourceBindingSha256); + } catch { + return unavailable(); + } + if (!/^[0-9a-f]{64}$/.test(canonicalPolicySha256)) return unavailable(); + + return NextResponse.json({ + schemaVersion: SCHEMA_VERSION, + kind: KIND, + deploymentId: identity.deploymentId, + commit: identity.commit, + canonicalPolicySha256, + }, { headers: NO_STORE_HEADERS, status: 200 }); +} diff --git a/apps/web/app/insights/insights-client.tsx b/apps/web/app/insights/insights-client.tsx index 160feb096b..9f1da9f9c1 100644 --- a/apps/web/app/insights/insights-client.tsx +++ b/apps/web/app/insights/insights-client.tsx @@ -4,7 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type MouseEven import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { hierarchy, treemap, treemapResquarify, type HierarchyRectangularNode } from 'd3-hierarchy'; -import { Lightbulb } from 'lucide-react'; +import { AlertTriangle, Lightbulb } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { AdminEmbeddedModuleShell } from '@/components/admin/AdminEmbeddedModuleShell'; import { Card, CardContent } from '@/components/ui/card'; @@ -73,6 +73,7 @@ type TreemapTileInteraction = (leaf: TreemapLeafNode, event: MouseEvent( const TREEMAP_COLORS = ['#414554', '#35764e', '#2f9e4f', '#30cc5a']; +const TREEMAP_LIGHT_FOREGROUND = '#ffffff'; +const TREEMAP_DARK_FOREGROUND = '#17202f'; + +function getHexColorRelativeLuminance(color: string): number { + const match = /^#([0-9a-f]{6})$/i.exec(color); + if (!match) return 0; + + const channels = [0, 2, 4].map((offset) => { + const channel = Number.parseInt(match[1].slice(offset, offset + 2), 16) / 255; + return channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; + }); + + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; +} + +function getColorContrastRatio(firstColor: string, secondColor: string): number { + const firstLuminance = getHexColorRelativeLuminance(firstColor); + const secondLuminance = getHexColorRelativeLuminance(secondColor); + const lighter = Math.max(firstLuminance, secondLuminance); + const darker = Math.min(firstLuminance, secondLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +function getTreemapTextColor(backgroundColor: string): string { + const lightContrast = getColorContrastRatio(backgroundColor, TREEMAP_LIGHT_FOREGROUND); + const darkContrast = getColorContrastRatio(backgroundColor, TREEMAP_DARK_FOREGROUND); + return darkContrast >= lightContrast ? TREEMAP_DARK_FOREGROUND : TREEMAP_LIGHT_FOREGROUND; +} const VIEW_MODE_OPTIONS: { value: ViewMode; label: string }[] = [ { value: 'all', label: '비율' }, @@ -565,6 +596,7 @@ const TreemapTiles = memo(function TreemapTiles({ const tileArea = Math.max(1, width * height); const tileInnerHeight = Math.max(6, height - 6); const tileBaseSize = Math.sqrt(tileArea); + const foregroundColor = getTreemapTextColor(leaf.color); const metricFont = Math.max(10, Math.min(54, Math.floor(tileBaseSize * 0.19))); const percentFont = Math.max(8, Math.min(24, Math.floor(metricFont * 0.6))); const miniFont = Math.max(8, Math.min(20, Math.floor(metricFont * 0.86))); @@ -573,6 +605,7 @@ const TreemapTiles = memo(function TreemapTiles({ if (width < 16 || height < 16) { return { leaf, + foregroundColor, x: Math.trunc(cell.x0), y: Math.trunc(cell.y0), width: Math.trunc(width), @@ -616,6 +649,7 @@ const TreemapTiles = memo(function TreemapTiles({ return { leaf, + foregroundColor, x: Math.trunc(cell.x0), y: Math.trunc(cell.y0), width: Math.trunc(width), @@ -660,8 +694,7 @@ const TreemapTiles = memo(function TreemapTiles({
-
⚠️
+
배너 목록
배너상태노출 위치우선순위
배너상태노출 위치우선순위
{banner.is_active ? '활성' : '비활성'}{banner.is_active ? '활성' : '비활성'}
{banner.display_target.includes('sidebar') && 데스크톱 배너}{banner.display_target.includes('mobile_popup') && 모바일 팝업}
{banner.priority}
({
{hasMoreRows ? ( -
+
{/* taste-skill-ignore: sticky table footer */} 추가 행 표시 중 {formatNumber(rows.length)}/{formatNumber(totalRows)}
) : null} @@ -3745,8 +3745,8 @@ function AdminDashboardPanelBodySkeleton({ data-admin-dashboard-dynamic-skeleton="bubble" aria-hidden="true" > -
-
+
+
{[ ["left-[9%] top-[58%] h-10 w-10", "bg-teal-100 dark:bg-teal-950/45"], ["left-[25%] top-[38%] h-14 w-14", "bg-teal-100/80 dark:bg-teal-950/35"], @@ -3784,9 +3784,9 @@ function AdminDashboardPanelBodySkeleton({ aria-hidden="true" >
-
-
-
+
+
+
카드 순서 @@ -3942,7 +3942,7 @@ function AdminDashboardManagementSkeleton() { type="button" variant="outline" size="sm" - className="h-7 shrink-0 rounded-full px-2 text-2xs" + className="h-8 shrink-0 rounded-md px-2.5 text-xs" disabled > 초기화 @@ -4288,7 +4288,7 @@ function AdminDashboardCardTitle({
) : null}
-
-