diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f0e793fa..ce516981 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,10 @@ "Bash(npx oxlint:*)", "WebSearch", "WebFetch(domain:tanstack.com)", - "Bash(node:*)" + "Bash(node:*)", + "Bash(deno test *)", + "Bash(git commit -m ' *)", + "Bash(pnpm vitest *)" ], "deny": [] } diff --git a/.github/workflows/_db_migrate.yml b/.github/workflows/_db_migrate.yml index 9bf70249..efa6bb59 100644 --- a/.github/workflows/_db_migrate.yml +++ b/.github/workflows/_db_migrate.yml @@ -28,9 +28,7 @@ jobs: - uses: actions/checkout@v4 - - uses: supabase/setup-cli@v1 - with: - version: 2.58.5 + - uses: supabase/setup-cli@v2 - name: Push migrations env: diff --git a/.github/workflows/_deploy_edge_functions.yml b/.github/workflows/_deploy_edge_functions.yml index 1ed6b573..55db2176 100644 --- a/.github/workflows/_deploy_edge_functions.yml +++ b/.github/workflows/_deploy_edge_functions.yml @@ -28,13 +28,11 @@ jobs: - uses: actions/checkout@v4 - - uses: supabase/setup-cli@v1 - with: - version: 2.58.5 + - uses: supabase/setup-cli@v2 - name: Deploy functions env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} PROJECT_REF: ${{ inputs.target == 'prod' && vars.PROD_PROJECT_REF || vars.STAGING_PROJECT_REF }} run: | - supabase functions deploy --project-ref "$PROJECT_REF" + supabase functions deploy --use-api --project-ref "$PROJECT_REF" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 22f44ce1..773b6f22 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -42,3 +42,26 @@ jobs: name: coverage-report path: coverage/ retention-days: 7 + + deno-test: + name: Run Edge Function Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Run Deno tests for Edge Functions + run: | + set -e + for fn in supabase/functions/*/; do + if compgen -G "$fn"*.test.ts > /dev/null; then + echo "::group::deno test $fn" + (cd "$fn" && deno test --allow-env --allow-net --allow-read) + echo "::endgroup::" + fi + done diff --git a/.gitignore b/.gitignore index 9a3df1b3..07d3c713 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,4 @@ dev-dist dump.sql # Sync script credentials (DB connection strings — never commit) -scripts/.env.sync \ No newline at end of file +scripts/.env.sync diff --git a/deno.lock b/deno.lock deleted file mode 100644 index defafbe4..00000000 --- a/deno.lock +++ /dev/null @@ -1,198 +0,0 @@ -{ - "version": "5", - "redirects": { - "https://esm.sh/@supabase/node-fetch@^2.6.13?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", - "https://esm.sh/@supabase/node-fetch@^2.6.14?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", - "https://esm.sh/@supabase/supabase-js@2": "https://esm.sh/@supabase/supabase-js@2.57.4", - "https://esm.sh/@types/boolbase@~1.0.3/index.d.ts": "https://esm.sh/@types/boolbase@1.0.3/index.d.ts", - "https://esm.sh/boolbase@^1.0.0?target=denonext": "https://esm.sh/boolbase@1.0.0?target=denonext", - "https://esm.sh/cheerio-select@^2.1.0?target=denonext": "https://esm.sh/cheerio-select@2.1.0?target=denonext", - "https://esm.sh/css-select@^5.1.0?target=denonext": "https://esm.sh/css-select@5.2.2?target=denonext", - "https://esm.sh/css-what@^6.1.0?target=denonext": "https://esm.sh/css-what@6.2.2?target=denonext", - "https://esm.sh/dom-serializer@^2.0.0?target=denonext": "https://esm.sh/dom-serializer@2.0.0?target=denonext", - "https://esm.sh/domelementtype@^2.3.0?target=denonext": "https://esm.sh/domelementtype@2.3.0?target=denonext", - "https://esm.sh/domhandler@^5.0.3?target=denonext": "https://esm.sh/domhandler@5.0.3?target=denonext", - "https://esm.sh/domutils@^3.0.1?target=denonext": "https://esm.sh/domutils@3.2.2?target=denonext", - "https://esm.sh/entities@^4.2.0?target=denonext": "https://esm.sh/entities@4.5.0?target=denonext", - "https://esm.sh/entities@^4.4.0/lib/decode?target=denonext": "https://esm.sh/entities@4.5.0/lib/decode?target=denonext", - "https://esm.sh/entities@^6.0.0/decode?target=denonext": "https://esm.sh/entities@6.0.1/decode?target=denonext", - "https://esm.sh/entities@^6.0.0/escape?target=denonext": "https://esm.sh/entities@6.0.1/escape?target=denonext", - "https://esm.sh/htmlparser2@^8.0.1?target=denonext": "https://esm.sh/htmlparser2@8.0.2?target=denonext", - "https://esm.sh/nth-check@^2.0.1?target=denonext": "https://esm.sh/nth-check@2.1.1?target=denonext", - "https://esm.sh/parse5-htmlparser2-tree-adapter@^7.0.0?target=denonext": "https://esm.sh/parse5-htmlparser2-tree-adapter@7.1.0?target=denonext", - "https://esm.sh/parse5@^7.0.0?target=denonext": "https://esm.sh/parse5@7.3.0?target=denonext", - "https://esm.sh/tr46@~0.0.3?target=denonext": "https://esm.sh/tr46@0.0.3?target=denonext", - "https://esm.sh/webidl-conversions@^3.0.0?target=denonext": "https://esm.sh/webidl-conversions@3.0.1?target=denonext", - "https://esm.sh/whatwg-url@^5.0.0?target=denonext": "https://esm.sh/whatwg-url@5.0.0?target=denonext" - }, - "remote": { - "https://deno.land/std@0.168.0/async/abortable.ts": "80b2ac399f142cc528f95a037a7d0e653296352d95c681e284533765961de409", - "https://deno.land/std@0.168.0/async/deadline.ts": "2c2deb53c7c28ca1dda7a3ad81e70508b1ebc25db52559de6b8636c9278fd41f", - "https://deno.land/std@0.168.0/async/debounce.ts": "60301ffb37e730cd2d6f9dadfd0ecb2a38857681bd7aaf6b0a106b06e5210a98", - "https://deno.land/std@0.168.0/async/deferred.ts": "77d3f84255c3627f1cc88699d8472b664d7635990d5358c4351623e098e917d6", - "https://deno.land/std@0.168.0/async/delay.ts": "5a9bfba8de38840308a7a33786a0155a7f6c1f7a859558ddcec5fe06e16daf57", - "https://deno.land/std@0.168.0/async/mod.ts": "7809ad4bb223e40f5fdc043e5c7ca04e0e25eed35c32c3c32e28697c553fa6d9", - "https://deno.land/std@0.168.0/async/mux_async_iterator.ts": "770a0ff26c59f8bbbda6b703a2235f04e379f73238e8d66a087edc68c2a2c35f", - "https://deno.land/std@0.168.0/async/pool.ts": "6854d8cd675a74c73391c82005cbbe4cc58183bddcd1fbbd7c2bcda42b61cf69", - "https://deno.land/std@0.168.0/async/retry.ts": "e8e5173623915bbc0ddc537698fa418cf875456c347eda1ed453528645b42e67", - "https://deno.land/std@0.168.0/async/tee.ts": "3a47cc4e9a940904fd4341f0224907e199121c80b831faa5ec2b054c6d2eff5e", - "https://deno.land/std@0.168.0/http/server.ts": "e99c1bee8a3f6571ee4cdeb2966efad465b8f6fe62bec1bdb59c1f007cc4d155", - "https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", - "https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", - "https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", - "https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", - "https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", - "https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1", - "https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", - "https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", - "https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774", - "https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", - "https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", - "https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", - "https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e", - "https://esm.sh/@supabase/auth-js@2.71.1/denonext/auth-js.mjs": "d55f67342e652b8bdce35b0ff13ad5cc294b7e96dbd68f859b464b07c6864967", - "https://esm.sh/@supabase/functions-js@2.4.6/denonext/functions-js.mjs": "d6cc049a0430f428ff0b71a0d3c48d45a243ddd48c68febcdb5cb8a02476a1dc", - "https://esm.sh/@supabase/node-fetch@2.6.15/denonext/node-fetch.mjs": "0bae9052231f4f6dbccc7234d05ea96923dbf967be12f402764580b6bf9f713d", - "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext": "4d28c4ad97328403184353f68434f2b6973971507919e9150297413664919cf3", - "https://esm.sh/@supabase/postgrest-js@1.21.4/denonext/postgrest-js.mjs": "c3769b11ef02debc78ecf6ab4e152d3cf7dbd05bbbafeb72c160e76cc57cda3c", - "https://esm.sh/@supabase/realtime-js@2.15.5/denonext/realtime-js.mjs": "518bdc73c29b502ba4dcf7ce2dff0ff8c1cbd8e5978f7ea2435af8214ea45dd5", - "https://esm.sh/@supabase/storage-js@2.12.1/denonext/storage-js.mjs": "7a5a47546486972c0627b620e7413300b4e82ac6e26b53d2c31933e13c2d652e", - "https://esm.sh/@supabase/supabase-js@2.57.4": "05a369085eb4a4c99d85ccece97f0cf1e05357122e0e74373da1f0e91b014902", - "https://esm.sh/@supabase/supabase-js@2.57.4/denonext/supabase-js.mjs": "b31f4ec51272218b68cfdcef9de5aa7abd0f1da1262fa0b9377c62eb18fe494b", - "https://esm.sh/boolbase@1.0.0/denonext/boolbase.mjs": "70e9521b9532b5e4dc0c807422529b15b4452663dbdb70dff9c7b65d0ff2e3cb", - "https://esm.sh/boolbase@1.0.0?target=denonext": "5d10bc2e0fb13eedfc6859bffbeb5a6f08679797fa8740c7d821841c2e22945f", - "https://esm.sh/cheerio-select@2.1.0/denonext/cheerio-select.mjs": "755b7da4011b67a75d1140d76c503cd6929c7213454debf5b6ebc086b73fa9d9", - "https://esm.sh/cheerio-select@2.1.0?target=denonext": "ae26d1996b4bb1d701cb7095e1c2ed7310e5fe88c3786efc26ceb6168ce1513d", - "https://esm.sh/cheerio@1.0.0-rc.12": "fce7bbfff7de7d2c635a798ea80e9a8beb5284c394c79d76afbe6d7b9675e7c0", - "https://esm.sh/cheerio@1.0.0-rc.12/denonext/cheerio.mjs": "b4ca825480bc25536b37570eacb6693d4c6d2371033ff2e5e32ceedc8f01e42f", - "https://esm.sh/cheerio@1.0.0-rc.12/denonext/lib/esm/load.mjs": "f5493a87fd62b2c4e19185a1e1a3ed93d5b2becfb7b46bfccc9ac16943883821", - "https://esm.sh/cheerio@1.0.0-rc.12/denonext/lib/esm/options.mjs": "382ade1b80a105b9d83e74f4c87e2cb27ebc0b3304c7b7094443b2f191f7b881", - "https://esm.sh/cheerio@1.0.0-rc.12/denonext/lib/esm/parse.mjs": "924fbfd7fa9528fb593ee0ad0f678196c076f1f3d733cbbb0a19f718e5796b5f", - "https://esm.sh/cheerio@1.0.0-rc.12/denonext/lib/esm/static.mjs": "5b6fe5cefd4a13f0692930f81e5a3667ddf45051910bf74276fb584605aacbd7", - "https://esm.sh/cheerio@1.0.0-rc.12/denonext/lib/esm/types.mjs": "548363e175a73fe23431f9959f0c4e942d9f9f107dbb5a3367f6a4e4f4129beb", - "https://esm.sh/cheerio@1.0.0-rc.12/denonext/lib/utils.mjs": "53ba8383160d9a7cee7d7c8db9b680ba276c2439f391fc8395b64eac5d5c5a35", - "https://esm.sh/css-select@5.2.2/denonext/css-select.mjs": "db6e191df366250412483170f6cc25b8416b86b7cbfc85b386de2aa92712924b", - "https://esm.sh/css-select@5.2.2?target=denonext": "6a1bffb076b7b4260cd1c62d3be28be32fdc7c9260a22f2402f218cb12beb440", - "https://esm.sh/css-what@6.2.2/denonext/css-what.mjs": "9c9b079c45f30d5392006f8225f9322564898dd23888e9e6740e25955d37e204", - "https://esm.sh/css-what@6.2.2?target=denonext": "74e16b118fb7045d5c136d4deaa3473627c8d10eb1fd7d0ab29ae5f588bec979", - "https://esm.sh/dom-serializer@2.0.0/denonext/dom-serializer.mjs": "545028b1d2c25bae5cbfe6930a28a2e4f7f05e1a0d09bbd0f3f5f9a33df8e3bd", - "https://esm.sh/dom-serializer@2.0.0?target=denonext": "1626b2b8326556ea2816b5f9bf7522bc9581d545fd9ad117c066ab7a5ff1fb89", - "https://esm.sh/domelementtype@2.3.0/denonext/domelementtype.mjs": "4f3b57348729cd517560139eb1969ca2fe9cc58c5188abe56e7336d5cb557cc0", - "https://esm.sh/domelementtype@2.3.0?target=denonext": "2beb2a1e3d18892a9b00ef9528811b93f613a77d2b6fb25376ec0f109ac48a4f", - "https://esm.sh/domhandler@5.0.3/denonext/domhandler.mjs": "3fb258a3d79bc9066a568bb6b09ce946d1fcfa2636a24ae80a4db220956e0873", - "https://esm.sh/domhandler@5.0.3?target=denonext": "298fde249b7bff9e80667cfe643e7d4b390871b77b0928d086ce4c0b8fc570e2", - "https://esm.sh/domutils@3.2.2/denonext/domutils.mjs": "f0b4e80e73810ed6f3d8c4e1822feef89208f32c88b6024a84328d02f5f77c40", - "https://esm.sh/domutils@3.2.2?target=denonext": "7e487176c61dfd1dfdbcfd1195e7329a64f53421511561b69c570a6cff0a6167", - "https://esm.sh/entities@4.5.0/denonext/entities.mjs": "4a9306e4021ae1079e83b5db26e1678c536fa69c8f2839802bc3cc43282cef08", - "https://esm.sh/entities@4.5.0/denonext/lib/decode.mjs": "ef22e25f6bca668e40c4f7d4ecaebe2172a833a18372d55b54f997d0d8702dcd", - "https://esm.sh/entities@4.5.0/denonext/lib/escape.mjs": "116aef78e5ff05efa6f79851b8b59da025ab88f5c25d2262f73df98f4d57c3fa", - "https://esm.sh/entities@4.5.0/lib/decode?target=denonext": "488bc8401a0c85a76527d61a41352c5371904aeda57a136eb10ccfadcd2f7c8c", - "https://esm.sh/entities@4.5.0?target=denonext": "f6bc559c07f40e94b3ef50f0b24e2666a2258db3b6697bf4da8fd2fc014ef7a1", - "https://esm.sh/entities@6.0.1/decode?target=denonext": "3ccc9b5e285ac182223bec6c9e053ff17814f4d27a31b8abafe35d3b684faaa7", - "https://esm.sh/entities@6.0.1/denonext/decode.mjs": "0e11dc867c49cd73eaa3de858276b02727bf6a3e1e5a84be72722cba08697b7d", - "https://esm.sh/entities@6.0.1/denonext/escape.mjs": "f23f7faf0499133a54a93a5dc4276f08793b2bb36b539b1bacddcc3b1e746aca", - "https://esm.sh/entities@6.0.1/escape?target=denonext": "c3df42c65816226666e5a0560e68e3c058a184684488a26b3dedce05eaa329e2", - "https://esm.sh/htmlparser2@8.0.2/denonext/htmlparser2.mjs": "c0be0f190e625b82e88378875016f820a38d586e9c885d37e3dd2073a4f0fdfb", - "https://esm.sh/htmlparser2@8.0.2/denonext/lib/esm/Parser.mjs": "d58fb2f87f8fead8e7ac03c544690908b4db21ab0513415b0cfe9311ab31aaa3", - "https://esm.sh/htmlparser2@8.0.2/denonext/lib/esm/Tokenizer.mjs": "09109e601c7acd75b99c2a34e53e339a46301d9937e1495eaca8f9019a7b3040", - "https://esm.sh/htmlparser2@8.0.2?target=denonext": "fd3edaa58a00e79f11b3510cc3930c9f5345486cdcc52c0afe24bbb36aece028", - "https://esm.sh/nth-check@2.1.1/denonext/nth-check.mjs": "2b0541d4564b27c31b37b006329c64036bee04a4c8f14e8357037fd7d35ecfe4", - "https://esm.sh/nth-check@2.1.1?target=denonext": "689135c5e0e825a2a89058636f1b3a497040597c17de9107c703e9d764d22e25", - "https://esm.sh/parse5-htmlparser2-tree-adapter@7.1.0/denonext/parse5-htmlparser2-tree-adapter.mjs": "7bfd000e678a20d0648da01bf5a9bc85188b3d10e4e079525324d336d9a4a4a2", - "https://esm.sh/parse5-htmlparser2-tree-adapter@7.1.0?target=denonext": "c3f6c7adc65cd1bc13b5fb95d1cd40cdb1250d49488c5252156d3fde0947b0e4", - "https://esm.sh/parse5@7.3.0/denonext/parse5.mjs": "39564d89f13b5d701ac8f869caea8660ada421fcd19ef2234adfd935cf7cf27e", - "https://esm.sh/parse5@7.3.0?target=denonext": "898a8cf4c02510b1cd0f90c9dffdfe9229f31f2dec425310da4404d472137f2e", - "https://esm.sh/tr46@0.0.3/denonext/tr46.mjs": "5753ec0a99414f4055f0c1f97691100f13d88e48a8443b00aebb90a512785fa2", - "https://esm.sh/tr46@0.0.3?target=denonext": "19cb9be0f0d418a0c3abb81f2df31f080e9540a04e43b0f699bce1149cba0cbb", - "https://esm.sh/webidl-conversions@3.0.1/denonext/webidl-conversions.mjs": "54b5c2d50a294853c4ccebf9d5ed8988c94f4e24e463d84ec859a866ea5fafec", - "https://esm.sh/webidl-conversions@3.0.1?target=denonext": "4e20318d50528084616c79d7b3f6e7f0fe7b6d09013bd01b3974d7448d767e29", - "https://esm.sh/whatwg-url@5.0.0/denonext/whatwg-url.mjs": "29b16d74ee72624c915745bbd25b617cfd2248c6af0f5120d131e232a9a9af79", - "https://esm.sh/whatwg-url@5.0.0?target=denonext": "f001a2cadf81312d214ca330033f474e74d81a003e21e8c5d70a1f46dc97b02d" - }, - "workspace": { - "packageJson": { - "dependencies": [ - "npm:@hookform/resolvers@^3.9.0", - "npm:@playwright/test@^1.54.1", - "npm:@radix-ui/react-accordion@^1.2.0", - "npm:@radix-ui/react-alert-dialog@^1.1.1", - "npm:@radix-ui/react-aspect-ratio@^1.1.0", - "npm:@radix-ui/react-avatar@^1.1.0", - "npm:@radix-ui/react-checkbox@^1.1.1", - "npm:@radix-ui/react-collapsible@^1.1.0", - "npm:@radix-ui/react-context-menu@^2.2.1", - "npm:@radix-ui/react-dialog@^1.1.2", - "npm:@radix-ui/react-dropdown-menu@^2.1.1", - "npm:@radix-ui/react-hover-card@^1.1.1", - "npm:@radix-ui/react-label@^2.1.0", - "npm:@radix-ui/react-menubar@^1.1.1", - "npm:@radix-ui/react-navigation-menu@^1.2.0", - "npm:@radix-ui/react-popover@^1.1.1", - "npm:@radix-ui/react-progress@^1.1.0", - "npm:@radix-ui/react-radio-group@^1.2.0", - "npm:@radix-ui/react-scroll-area@^1.1.0", - "npm:@radix-ui/react-select@^2.1.1", - "npm:@radix-ui/react-separator@^1.1.0", - "npm:@radix-ui/react-slider@^1.2.0", - "npm:@radix-ui/react-slot@^1.1.0", - "npm:@radix-ui/react-switch@^1.1.0", - "npm:@radix-ui/react-tabs@^1.1.0", - "npm:@radix-ui/react-toast@^1.2.1", - "npm:@radix-ui/react-toggle-group@^1.1.0", - "npm:@radix-ui/react-toggle@^1.1.0", - "npm:@radix-ui/react-tooltip@^1.1.4", - "npm:@supabase/supabase-js@^2.50.0", - "npm:@tailwindcss/line-clamp@~0.4.4", - "npm:@tailwindcss/typography@~0.5.15", - "npm:@tanstack/query-async-storage-persister@^5.86.0", - "npm:@tanstack/react-query-devtools@^5.81.2", - "npm:@tanstack/react-query-persist-client@^5.85.9", - "npm:@tanstack/react-query@^5.56.2", - "npm:@types/node@^22.5.5", - "npm:@types/react-dom@^18.3.0", - "npm:@types/react@^18.3.3", - "npm:@vitejs/plugin-react-swc@^3.5.0", - "npm:autoprefixer@^10.4.20", - "npm:class-variance-authority@~0.7.1", - "npm:clsx@^2.1.1", - "npm:cmdk@1", - "npm:date-fns-tz@^3.2.0", - "npm:date-fns@^4.1.0", - "npm:embla-carousel-react@^8.3.0", - "npm:framer-motion@^12.23.12", - "npm:globals@^15.9.0", - "npm:husky@^9.1.7", - "npm:idb@^8.0.3", - "npm:input-otp@^1.2.4", - "npm:lint-staged@^16.1.4", - "npm:lucide-react@0.462", - "npm:next-themes@0.3", - "npm:oxlint@^1.11.1", - "npm:postcss@^8.4.47", - "npm:prettier@^3.6.2", - "npm:react-day-picker@^9.8.0", - "npm:react-dom@^18.3.1", - "npm:react-hook-form@^7.53.0", - "npm:react-resizable-panels@^2.1.3", - "npm:react-router-dom@^6.26.2", - "npm:react@^18.3.1", - "npm:recharts@^2.12.7", - "npm:sonner@^1.5.0", - "npm:supabase@^2.33.9", - "npm:tailwind-merge@^2.5.2", - "npm:tailwindcss-animate@^1.0.7", - "npm:tailwindcss@^3.4.11", - "npm:tsx@^4.20.3", - "npm:typescript@^5.5.3", - "npm:vaul@~0.9.3", - "npm:vite-plugin-pwa@^1.0.1", - "npm:vite@^5.4.1", - "npm:workbox-background-sync@^7.3.0", - "npm:workbox-precaching@^7.3.0", - "npm:workbox-routing@^7.3.0", - "npm:workbox-strategies@^7.3.0", - "npm:zod@^3.23.8" - ] - } - } -} diff --git a/package.json b/package.json index 7ea6d229..9467473a 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,7 @@ "test:e2e:report": "playwright show-report", "test:setup": "bash scripts/setup-test-env.sh", "test:setup:full": "bash scripts/setup-local-supabase.sh", - "types:generate": "supabase gen types typescript --project-id qssmazlqrmxiudxckxvi > src/integrations/supabase/types.ts", - "types:generate:local": "supabase gen types typescript --local > src/integrations/supabase/types.ts", + "types:generate": "bash scripts/gen-types.sh", "db:sync:staging": "bash scripts/sync-from-prod.sh staging", "db:sync:local": "bash scripts/sync-from-prod.sh local", "db:recreate:staging": "bash scripts/recreate-staging.sh", @@ -61,7 +60,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", - "@supabase/supabase-js": "^2.81.1", + "@supabase/supabase-js": "^2.105.0", "@tailwindcss/line-clamp": "^0.4.4", "@tanstack/query-async-storage-persister": "^5.86.0", "@tanstack/react-query": "^5.56.2", @@ -84,6 +83,7 @@ "lucide-react": "^0.462.0", "marked": "^16.3.0", "next-themes": "^0.3.0", + "papaparse": "^5.5.3", "posthog-js": "^1.277.0", "react": "^18.3.1", "react-day-picker": "^9.8.0", @@ -102,15 +102,15 @@ "zod": "^3.23.8" }, "devDependencies": { - "vite-plugin-pwa": "^1.2.0", "@playwright/test": "^1.54.1", "@tailwindcss/typography": "^0.5.15", + "@tanstack/router-devtools": "^1.136.18", + "@tanstack/router-plugin": "^1.136.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@tanstack/router-devtools": "^1.136.18", - "@tanstack/router-plugin": "^1.136.18", "@types/node": "^22.5.5", + "@types/papaparse": "^5.5.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^4.2.2", @@ -130,6 +130,7 @@ "tsx": "^4.20.3", "typescript": "^5.5.3", "vite": "^7.2.7", + "vite-plugin-pwa": "^1.2.0", "vitest": "^4.0.15" }, "lint-staged": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7209992..07c1c869 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^1.1.4 version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@supabase/supabase-js': - specifier: ^2.81.1 - version: 2.81.1 + specifier: ^2.105.0 + version: 2.105.4 '@tailwindcss/line-clamp': specifier: ^0.4.4 version: 0.4.4(tailwindcss@3.4.17) @@ -161,6 +161,9 @@ importers: next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + papaparse: + specifier: ^5.5.3 + version: 5.5.3 posthog-js: specifier: ^1.277.0 version: 1.277.0 @@ -234,6 +237,9 @@ importers: '@types/node': specifier: ^22.5.5 version: 22.18.6 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/react': specifier: ^18.3.3 version: 18.3.24 @@ -2098,28 +2104,31 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.81.1': - resolution: {integrity: sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==} + '@supabase/auth-js@2.105.4': + resolution: {integrity: sha512-Ejfa37M5xoIwoxVebxRahnwubPo8g22qkXQ4p50+N9MIvU9UZoN+A8dwVPtczzGf8oV/YXN80ZPxK4aWXuSN/A==} engines: {node: '>=20.0.0'} - '@supabase/functions-js@2.81.1': - resolution: {integrity: sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==} + '@supabase/functions-js@2.105.4': + resolution: {integrity: sha512-JVNKbBft3Qkja+WlGaE026AJ2AH9K0UTsxsfvEIHgd4zFrBor4BYRCrYFrv9IDsvVqkF72wKDsODJl5GY/C4tA==} engines: {node: '>=20.0.0'} - '@supabase/postgrest-js@2.81.1': - resolution: {integrity: sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==} + '@supabase/phoenix@0.4.2': + resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} + + '@supabase/postgrest-js@2.105.4': + resolution: {integrity: sha512-SppIyLo/kTwIlz1qpv2HN1EQqBg0GVktrDDFsXygYROha3MgVn4rT7p5EjFHFqXQm2rdRGb/BI7bc+jr10m91w==} engines: {node: '>=20.0.0'} - '@supabase/realtime-js@2.81.1': - resolution: {integrity: sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==} + '@supabase/realtime-js@2.105.4': + resolution: {integrity: sha512-6ov6c59+8D9h7q4M4Gy/uDJlC0Akxl9/714Y+6vJ+Sijuc16TS/p5DwhfRCLNcIhNiej1gEt+CQUwsjiPt4PxQ==} engines: {node: '>=20.0.0'} - '@supabase/storage-js@2.81.1': - resolution: {integrity: sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==} + '@supabase/storage-js@2.105.4': + resolution: {integrity: sha512-Jx+pzMP1Whjof2PWHoVBUA75/p7PQE9CqKBzn1oXVyJDOggMLSH2OzVWwsXYaxEpdC1K/KltwmOX44nL3LHl9g==} engines: {node: '>=20.0.0'} - '@supabase/supabase-js@2.81.1': - resolution: {integrity: sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==} + '@supabase/supabase-js@2.105.4': + resolution: {integrity: sha512-cEnx+k49knU+qdIP7rXwR6fqEXPHZs+74xFK1R0S8MgQ7v9tbePVdGxvO03n3bPympMdJWVLadARBfU4TgNHCQ==} engines: {node: '>=20.0.0'} '@surma/rollup-plugin-off-main-thread@2.2.3': @@ -2393,8 +2402,8 @@ packages: '@types/node@22.18.6': resolution: {integrity: sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==} - '@types/phoenix@1.6.6': - resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2416,9 +2425,6 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@vercel/speed-insights@1.2.0': resolution: {integrity: sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw==} peerDependencies: @@ -3176,6 +3182,10 @@ packages: engines: {node: '>=18'} hasBin: true + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3637,6 +3647,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -6427,42 +6440,37 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.81.1': + '@supabase/auth-js@2.105.4': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.81.1': + '@supabase/functions-js@2.105.4': dependencies: tslib: 2.8.1 - '@supabase/postgrest-js@2.81.1': + '@supabase/phoenix@0.4.2': {} + + '@supabase/postgrest-js@2.105.4': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.81.1': + '@supabase/realtime-js@2.105.4': dependencies: - '@types/phoenix': 1.6.6 - '@types/ws': 8.18.1 + '@supabase/phoenix': 0.4.2 tslib: 2.8.1 - ws: 8.18.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@supabase/storage-js@2.81.1': + '@supabase/storage-js@2.105.4': dependencies: + iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.81.1': + '@supabase/supabase-js@2.105.4': dependencies: - '@supabase/auth-js': 2.81.1 - '@supabase/functions-js': 2.81.1 - '@supabase/postgrest-js': 2.81.1 - '@supabase/realtime-js': 2.81.1 - '@supabase/storage-js': 2.81.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + '@supabase/auth-js': 2.105.4 + '@supabase/functions-js': 2.105.4 + '@supabase/postgrest-js': 2.105.4 + '@supabase/realtime-js': 2.105.4 + '@supabase/storage-js': 2.105.4 '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: @@ -6777,7 +6785,9 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/phoenix@1.6.6': {} + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 22.18.6 '@types/prop-types@15.7.15': {} @@ -6798,10 +6808,6 @@ snapshots: '@types/trusted-types@2.0.7': {} - '@types/ws@8.18.1': - dependencies: - '@types/node': 22.18.6 - '@vercel/speed-insights@1.2.0(react@18.3.1)': optionalDependencies: react: 18.3.1 @@ -7647,6 +7653,8 @@ snapshots: husky@9.1.7: {} + iceberg-js@0.8.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -8088,6 +8096,8 @@ snapshots: package-json-from-dist@1.0.1: {} + papaparse@5.5.3: {} + parse5@7.3.0: dependencies: entities: 6.0.1 diff --git a/scripts/gen-types.sh b/scripts/gen-types.sh new file mode 100755 index 00000000..088d62f9 --- /dev/null +++ b/scripts/gen-types.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Generate Supabase TypeScript types for both the app and the Edge Functions. +# Pass --local to generate from the local database instead of the remote project. + +set -euo pipefail + +PROJECT_ID="qssmazlqrmxiudxckxvi" +APP_TYPES="src/integrations/supabase/types.ts" +EDGE_TYPES="supabase/functions/_shared/database.types.ts" + +if [[ "${1:-}" == "--local" ]]; then + supabase gen types typescript --local | tee "$APP_TYPES" > "$EDGE_TYPES" +else + supabase gen types typescript --project-id "$PROJECT_ID" | tee "$APP_TYPES" > "$EDGE_TYPES" +fi diff --git a/scripts/gen-zone-countries.mjs b/scripts/gen-zone-countries.mjs new file mode 100644 index 00000000..64c501e9 --- /dev/null +++ b/scripts/gen-zone-countries.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Regenerate src/components/Admin/ScheduleImport/zoneCountries.ts from the +// system's IANA tz database (zone1970.tab). Run after a tzdata upgrade if you +// want fresh country mappings. +// +// Usage: node scripts/gen-zone-countries.mjs +// +// Default source: /usr/share/zoneinfo/zone1970.tab (Linux/macOS default). +// Override with: ZONE1970_PATH=/some/other/path node scripts/... + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SOURCE = process.env.ZONE1970_PATH ?? "/usr/share/zoneinfo/zone1970.tab"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OUT = path.resolve( + __dirname, + "..", + "src/components/Admin/ScheduleImport/zoneCountries.ts", +); + +const text = fs.readFileSync(SOURCE, "utf8"); +const map = {}; +for (const line of text.split("\n")) { + if (!line || line.startsWith("#")) continue; + const cols = line.split("\t"); + if (cols.length < 3) continue; + map[cols[2]] = cols[0].split(","); +} + +const keys = Object.keys(map).sort(); +const lines = [ + "// Generated from /usr/share/zoneinfo/zone1970.tab (IANA tz database).", + "// Maps IANA timezone -> ISO 3166 alpha-2 country codes; the first code is", + "// the primary country. Shared zones (e.g. Europe/Berlin) list all overlapping", + "// countries so country search hits them too.", + "//", + "// Regenerate with: node scripts/gen-zone-countries.mjs", + "", + "export const ZONE_COUNTRIES: Record = {", + ...keys.map( + (k) => + ` ${JSON.stringify(k)}: [${map[k].map((c) => JSON.stringify(c)).join(", ")}],`, + ), + "};", + "", +]; + +fs.writeFileSync(OUT, lines.join("\n")); +console.log(`Wrote ${keys.length} entries to ${path.relative(process.cwd(), OUT)}`); diff --git a/src/components/Admin/ScheduleImport/CommitResultCard.tsx b/src/components/Admin/ScheduleImport/CommitResultCard.tsx new file mode 100644 index 00000000..e833e0ac --- /dev/null +++ b/src/components/Admin/ScheduleImport/CommitResultCard.tsx @@ -0,0 +1,42 @@ +import { CheckCircle2, RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { type CommitResult } from "@/services/scheduleImport/types"; + +type Props = { + result: CommitResult; + onReset: () => void; +}; + +export function CommitResultCard({ result, onReset }: Props) { + return ( + + +
+ + Schedule imported successfully +
+
    +
  • + {result.setsCreated} set{result.setsCreated !== 1 ? "s" : ""}{" "} + created +
  • +
  • + {result.setsUpdated} set{result.setsUpdated !== 1 ? "s" : ""}{" "} + updated +
  • + {result.setsArchived > 0 && ( +
  • + {result.setsArchived} set{result.setsArchived !== 1 ? "s" : ""}{" "} + archived +
  • + )} +
+ +
+
+ ); +} diff --git a/src/components/Admin/ScheduleImport/CsvDropZone.tsx b/src/components/Admin/ScheduleImport/CsvDropZone.tsx new file mode 100644 index 00000000..43734ba3 --- /dev/null +++ b/src/components/Admin/ScheduleImport/CsvDropZone.tsx @@ -0,0 +1,59 @@ +import { useId, useRef } from "react"; +import { Upload } from "lucide-react"; +import { Label } from "@/components/ui/label"; + +type Props = { + fileName: string | null; + rowCount: number; + onFileSelected: (file: File) => void; +}; + +export function CsvDropZone({ fileName, rowCount, onFileSelected }: Props) { + const fileRef = useRef(null); + const inputId = useId(); + + function handleChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) onFileSelected(file); + // Clear the value so re-selecting the same file still fires onChange. + e.target.value = ""; + } + + return ( +
+ + + +

+ Required column: Artists (use | for B2B, e.g.{" "} + Carl Cox | Peggy Gou). Optional: Set Name,{" "} + Stage, Date (YYYY-MM-DD),{" "} + Start Time (HH:MM), End Time (HH:MM),{" "} + Description. +

+
+ ); +} diff --git a/src/components/Admin/ScheduleImport/CsvUploadStep.tsx b/src/components/Admin/ScheduleImport/CsvUploadStep.tsx new file mode 100644 index 00000000..c56dca3a --- /dev/null +++ b/src/components/Admin/ScheduleImport/CsvUploadStep.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { parseScheduleCsv } from "@/services/scheduleImport/parseCsv"; +import { callDiffSchedule } from "@/services/scheduleImport/api"; +import { type CsvRow, type DiffResult } from "@/services/scheduleImport/types"; +import { TimezonePicker } from "./TimezonePicker"; +import { CsvDropZone } from "./CsvDropZone"; + +type Props = { + festivalEditionId: string; + onDiffReady: (diff: DiffResult, timezone: string) => void; +}; + +export function CsvUploadStep({ festivalEditionId, onDiffReady }: Props) { + const [timezone, setTimezone] = useState("Europe/Lisbon"); + const [fileName, setFileName] = useState(null); + + const readFileMutation = useMutation({ mutationFn: readFile }); + const analyseMutation = useMutation({ + mutationFn: (rows: CsvRow[]) => + callDiffSchedule(festivalEditionId, timezone, rows), + onSuccess: (diff) => onDiffReady(diff, timezone), + }); + + const rows = readFileMutation.data ?? []; + const error = + analyseMutation.error?.message ?? readFileMutation.error?.message ?? null; + + function handleFileSelected(file: File) { + setFileName(file.name); + analyseMutation.reset(); + readFileMutation.reset(); + readFileMutation.mutate(file); + } + + function handleAnalyse() { + if (rows.length === 0) return; + analyseMutation.mutate(rows); + } + + return ( +
+ + + + {error &&

{error}

} + + +
+ ); +} + +async function readFile(file: File): Promise { + const content = await file.text(); + const parsed = parseScheduleCsv(content); + if (parsed.length === 0) { + throw new Error( + "No valid rows found. Make sure your CSV has an 'Artists' column.", + ); + } + return parsed; +} diff --git a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx new file mode 100644 index 00000000..6e3f9ec1 --- /dev/null +++ b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx @@ -0,0 +1,98 @@ +import { AlertCircle, Loader2 } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + type DiffResult, + type StageMismatchResolution, + type OrphanResolution, +} from "@/services/scheduleImport/types"; +import { DiffSummaryBanner } from "./DiffSummaryBanner"; +import { StageMismatchResolver } from "./StageMismatchResolver"; +import { OrphanedSetsPanel } from "./OrphanedSetsPanel"; + +type DbStage = { id: string; name: string }; + +type Props = { + diff: DiffResult; + timezone: string; + dbStages: DbStage[]; + stageMismatchResolutions: Record; + orphanResolutions: Record; + onStageMismatchChange: ( + csvValue: string, + resolution: StageMismatchResolution, + ) => void; + onOrphanChange: (setId: string, resolution: OrphanResolution) => void; + onCommit: () => void; + onReset: () => void; + committing: boolean; + commitError: string | null; + canCommit: boolean; +}; + +export function DiffReviewStep({ + diff, + timezone, + dbStages, + stageMismatchResolutions, + orphanResolutions, + onStageMismatchChange, + onOrphanChange, + onCommit, + onReset, + committing, + commitError, + canCommit, +}: Props) { + return ( + + + Review Changes + + + + + + + + + {commitError && ( + + + Import failed — no changes were saved. + {commitError} + + )} + +
+ + +
+
+
+ ); +} diff --git a/src/components/Admin/ScheduleImport/DiffSummaryBanner.tsx b/src/components/Admin/ScheduleImport/DiffSummaryBanner.tsx new file mode 100644 index 00000000..13f6853c --- /dev/null +++ b/src/components/Admin/ScheduleImport/DiffSummaryBanner.tsx @@ -0,0 +1,60 @@ +import { Badge } from "@/components/ui/badge"; +import { type DiffResult } from "@/services/scheduleImport/types"; + +type Props = { diff: DiffResult }; + +export function DiffSummaryBanner({ diff }: Props) { + const { summary, newArtistNames } = diff; + + const items = [ + { + label: "sets to create", + value: summary.setsToCreate, + variant: "default" as const, + }, + { + label: "sets to update", + value: summary.setsMatched, + variant: "secondary" as const, + }, + { + label: "new stages", + value: summary.newStages, + variant: "default" as const, + }, + { + label: "conflicts", + value: summary.setsOrphaned + diff.conflicts.stageNameMismatches.length, + variant: "destructive" as const, + }, + ].filter((item) => item.value > 0); + + return ( +
+
+ {items.map((item) => ( + + {item.value} {item.label} + + ))} + {items.length === 0 && ( + + No changes detected. + + )} +
+ + {summary.newArtists > 0 && ( +

+ + {summary.newArtists} new artist{summary.newArtists !== 1 ? "s" : ""} + {" "} + will be created: {newArtistNames.slice(0, 5).join(", ")} + {newArtistNames.length > 5 && + ` and ${newArtistNames.length - 5} more`} + . +

+ )} +
+ ); +} diff --git a/src/components/Admin/ScheduleImport/OrphanedSetsPanel.tsx b/src/components/Admin/ScheduleImport/OrphanedSetsPanel.tsx new file mode 100644 index 00000000..0c19b220 --- /dev/null +++ b/src/components/Admin/ScheduleImport/OrphanedSetsPanel.tsx @@ -0,0 +1,110 @@ +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Archive } from "lucide-react"; +import { formatDateTime } from "@/lib/timeUtils"; +import { + type DiffResult, + type OrphanResolution, +} from "@/services/scheduleImport/types"; + +type OrphanedSet = DiffResult["conflicts"]["orphanedSets"][number]; + +type Props = { + orphanedSets: OrphanedSet[]; + timezone: string; + resolutions: Record; + onChange: (setId: string, resolution: OrphanResolution) => void; +}; + +export function OrphanedSetsPanel({ + orphanedSets, + timezone, + resolutions, + onChange, +}: Props) { + if (orphanedSets.length === 0) return null; + + const everyArchived = orphanedSets.every( + (s) => (resolutions[s.id] ?? "keep") === "archive", + ); + + return ( +
+
+
+ + {orphanedSets.length} set{orphanedSets.length !== 1 ? "s" : ""} not in + CSV +
+ +
+ +

+ These sets exist in the database but were not matched to any row in your + CSV. Archived sets are hidden from users but votes are preserved. + Default: Keep. +

+ +
+ {orphanedSets.map((set) => ( + onChange(set.id, resolution)} + /> + ))} +
+
+ ); + + function toggleAll() { + const target: OrphanResolution = everyArchived ? "keep" : "archive"; + orphanedSets.forEach((s) => onChange(s.id, target)); + } +} + +type OrphanedItemProps = { + set: OrphanedSet; + timezone: string; + resolution: OrphanResolution; + onChange: (resolution: OrphanResolution) => void; +}; + +function OrphanedItem({ + set, + timezone, + resolution, + onChange, +}: OrphanedItemProps) { + const isArchive = resolution === "archive"; + // Format in the festival timezone so review decisions don't flip across + // midnight/DST for admins in a different timezone than the festival. + const time = formatDateTime(set.timeStart, false, timezone); + const switchId = `orphan-${set.id}`; + + return ( +
+
+

{set.name}

+

+ {[set.stage, time].filter(Boolean).join(" · ") || "No schedule info"} +

+
+
+ + onChange(checked ? "archive" : "keep")} + /> +
+
+ ); +} diff --git a/src/components/Admin/ScheduleImport/ReviewStage.tsx b/src/components/Admin/ScheduleImport/ReviewStage.tsx new file mode 100644 index 00000000..831e9bfc --- /dev/null +++ b/src/components/Admin/ScheduleImport/ReviewStage.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { buildCommitPayload } from "@/services/scheduleImport/buildCommitPayload"; +import { callCommitSchedule } from "@/services/scheduleImport/api"; +import { + type CommitResult, + type DiffResult, + type OrphanResolution, + type StageMismatchResolution, +} from "@/services/scheduleImport/types"; +import { artistsKeys } from "@/hooks/queries/artists/useArtists"; +import { setsKeys } from "@/hooks/queries/sets/useSets"; +import { stagesKeys } from "@/hooks/queries/stages/types"; +import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; +import { DiffReviewStep } from "./DiffReviewStep"; + +type Props = { + festivalEditionId: string; + diff: DiffResult; + timezone: string; + onCommitted: (result: CommitResult) => void; + onReset: () => void; +}; + +export function ReviewStage({ + festivalEditionId, + diff, + timezone, + onCommitted, + onReset, +}: Props) { + const queryClient = useQueryClient(); + const stagesQuery = useStagesByEditionQuery(festivalEditionId); + + const [stageMismatchResolutions, setStageMismatchResolutions] = useState< + Record + >(() => + Object.fromEntries( + diff.conflicts.stageNameMismatches.map((m) => [ + m.csvValue, + { action: "map" as const, dbStageName: m.closestDbValue }, + ]), + ), + ); + const [orphanResolutions, setOrphanResolutions] = useState< + Record + >({}); + + const commitMutation = useMutation({ + mutationFn: () => { + const payload = buildCommitPayload( + diff, + stageMismatchResolutions, + orphanResolutions, + ); + return callCommitSchedule(festivalEditionId, payload); + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: setsKeys.all }); + queryClient.invalidateQueries({ + queryKey: stagesKeys.byEdition(festivalEditionId), + }); + queryClient.invalidateQueries({ queryKey: artistsKeys.all }); + onCommitted(result); + }, + }); + + const canCommit = diff.conflicts.stageNameMismatches.every( + (m) => stageMismatchResolutions[m.csvValue] != null, + ); + + return ( + + setStageMismatchResolutions((prev) => ({ + ...prev, + [csvValue]: resolution, + })) + } + onOrphanChange={(setId, resolution) => + setOrphanResolutions((prev) => ({ ...prev, [setId]: resolution })) + } + onCommit={() => commitMutation.mutate()} + onReset={onReset} + committing={commitMutation.isPending} + commitError={commitMutation.error?.message ?? null} + canCommit={canCommit} + /> + ); +} diff --git a/src/components/Admin/ScheduleImport/ScheduleImportWizard.tsx b/src/components/Admin/ScheduleImport/ScheduleImportWizard.tsx new file mode 100644 index 00000000..d7efa2b4 --- /dev/null +++ b/src/components/Admin/ScheduleImport/ScheduleImportWizard.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + type CommitResult, + type DiffResult, +} from "@/services/scheduleImport/types"; +import { CsvUploadStep } from "./CsvUploadStep"; +import { ReviewStage } from "./ReviewStage"; +import { CommitResultCard } from "./CommitResultCard"; + +type Props = { festivalEditionId: string }; + +type WizardState = + | { step: "upload" } + | { step: "review"; diff: DiffResult; timezone: string } + | { step: "result"; result: CommitResult }; + +export function ScheduleImportWizard({ festivalEditionId }: Props) { + const [state, setState] = useState({ step: "upload" }); + + function reset() { + setState({ step: "upload" }); + } + + if (state.step === "upload") { + return ( + + + Import Schedule + + + + setState({ step: "review", diff, timezone }) + } + /> + + + ); + } + + if (state.step === "review") { + return ( + setState({ step: "result", result })} + onReset={reset} + /> + ); + } + + return ; +} diff --git a/src/components/Admin/ScheduleImport/StageMismatchResolver.tsx b/src/components/Admin/ScheduleImport/StageMismatchResolver.tsx new file mode 100644 index 00000000..c8fbfd02 --- /dev/null +++ b/src/components/Admin/ScheduleImport/StageMismatchResolver.tsx @@ -0,0 +1,141 @@ +import { useId } from "react"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AlertTriangle } from "lucide-react"; +import { + type DiffResult, + type StageMismatchResolution, +} from "@/services/scheduleImport/types"; + +type Mismatch = DiffResult["conflicts"]["stageNameMismatches"][number]; +type DbStage = { id: string; name: string }; + +type Props = { + mismatches: Mismatch[]; + dbStages: DbStage[]; + resolutions: Record; + onChange: (csvValue: string, resolution: StageMismatchResolution) => void; +}; + +export function StageMismatchResolver({ + mismatches, + dbStages, + resolutions, + onChange, +}: Props) { + if (mismatches.length === 0) return null; + + return ( +
+
+ + Stage name conflicts — resolve before committing +
+ + {mismatches.map((mismatch) => ( + + ))} +
+ ); +} + +type MismatchRowProps = { + mismatch: Mismatch; + dbStages: DbStage[]; + resolution: StageMismatchResolution; + onChange: (csvValue: string, resolution: StageMismatchResolution) => void; +}; + +function MismatchRow({ + mismatch, + dbStages, + resolution, + onChange, +}: MismatchRowProps) { + const baseId = useId(); + const mapId = `${baseId}-map`; + const createId = `${baseId}-create`; + + return ( +
+

+ CSV value:{" "} + {mismatch.csvValue} +

+ + { + if (action === "map") { + onChange(mismatch.csvValue, { + action: "map", + dbStageName: mismatch.closestDbValue, + }); + } else { + onChange(mismatch.csvValue, { action: "create" }); + } + }} + className="space-y-2" + > +
+ +
+ + {resolution.action === "map" && ( + + )} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/Admin/ScheduleImport/TimezoneItem.tsx b/src/components/Admin/ScheduleImport/TimezoneItem.tsx new file mode 100644 index 00000000..2e438e8a --- /dev/null +++ b/src/components/Admin/ScheduleImport/TimezoneItem.tsx @@ -0,0 +1,45 @@ +import { Check } from "lucide-react"; +import { CommandItem } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import type { TzInfo } from "./timezoneCatalog"; + +type Props = { + tz: TzInfo; + selected: boolean; + onSelect: (zone: string) => void; +}; + +export function TimezoneItem({ tz, selected, onSelect }: Props) { + return ( + onSelect(tz.zone)}> + +
+
+ {tz.city} + {tz.primaryCountry && ( + + {" · "} + {tz.primaryCountry} + + )} +
+
{tz.zone}
+
+
+ + {tz.offsetLabel} + + {tz.abbreviation && ( + + {tz.abbreviation} + + )} +
+
+ ); +} diff --git a/src/components/Admin/ScheduleImport/TimezonePicker.tsx b/src/components/Admin/ScheduleImport/TimezonePicker.tsx new file mode 100644 index 00000000..239ad8d9 --- /dev/null +++ b/src/components/Admin/ScheduleImport/TimezonePicker.tsx @@ -0,0 +1,92 @@ +import { useId, useState } from "react"; +import { ChevronsUpDown, Globe } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useTimezoneCatalog } from "./timezoneCatalog"; +import { TimezoneItem } from "./TimezoneItem"; + +type Props = { + value: string; + onChange: (value: string) => void; +}; + +export function TimezonePicker({ value, onChange }: Props) { + const triggerId = useId(); + const [open, setOpen] = useState(false); + + const { groups, byZone } = useTimezoneCatalog(); + const selected = byZone.get(value); + + function handleSelect(zone: string) { + onChange(zone); + setOpen(false); + } + + return ( +
+ + + + + + + + + + No matching timezone. + {groups.map((group) => ( + + {group.zones.map((tz) => ( + + ))} + + ))} + + + + +

+ All times in the CSV are interpreted as local festival time. +

+
+ ); +} diff --git a/src/components/Admin/ScheduleImport/timezoneCatalog.ts b/src/components/Admin/ScheduleImport/timezoneCatalog.ts new file mode 100644 index 00000000..4f546c55 --- /dev/null +++ b/src/components/Admin/ScheduleImport/timezoneCatalog.ts @@ -0,0 +1,180 @@ +import { useMemo } from "react"; +import { ZONE_COUNTRIES } from "./zoneCountries"; + +export type TzInfo = { + zone: string; + region: string; + city: string; + primaryCountry: string; + offsetLabel: string; + offsetMinutes: number; + abbreviation: string; + searchValue: string; +}; + +export type TzGroup = { + region: string; + zones: TzInfo[]; +}; + +const FALLBACK_ZONES = [ + "UTC", + "Europe/Lisbon", + "Europe/London", + "Europe/Berlin", + "Europe/Paris", + "America/New_York", + "America/Los_Angeles", +]; + +export function useTimezoneCatalog(): { + groups: TzGroup[]; + byZone: Map; +} { + return useMemo(() => { + const now = new Date(); + const zones = listZones(); + const countryNames = makeCountryNameResolver(); + + const entries: TzInfo[] = zones.map((zone) => + buildEntry(zone, now, countryNames), + ); + + entries.sort((a, b) => { + if (a.region !== b.region) return a.region.localeCompare(b.region); + if (a.offsetMinutes !== b.offsetMinutes) + return a.offsetMinutes - b.offsetMinutes; + return a.city.localeCompare(b.city); + }); + + const byZone = new Map(); + const grouped = new Map(); + for (const entry of entries) { + byZone.set(entry.zone, entry); + const bucket = grouped.get(entry.region) ?? []; + bucket.push(entry); + grouped.set(entry.region, bucket); + } + + const groups: TzGroup[] = Array.from(grouped.entries()).map( + ([region, list]) => ({ region, zones: list }), + ); + + return { groups, byZone }; + }, []); +} + +function listZones(): string[] { + if (typeof Intl.supportedValuesOf === "function") { + try { + return Intl.supportedValuesOf("timeZone"); + } catch { + // fall through + } + } + return FALLBACK_ZONES; +} + +function buildEntry( + zone: string, + now: Date, + countryNames: (code: string) => string, +): TzInfo { + const firstSlash = zone.indexOf("/"); + // Group by the top-level path segment (America/Argentina/Buenos_Aires + // rolls up into "America", not its own "America/Argentina" group). + const region = firstSlash >= 0 ? zone.slice(0, firstSlash) : "Other"; + const rawCity = firstSlash >= 0 ? zone.slice(firstSlash + 1) : zone; + const city = rawCity.replace(/_/g, " "); + + const offsetLabel = formatOffset(zone, now); + const offsetMinutes = parseOffsetMinutes(offsetLabel); + const abbreviation = formatAbbreviation(zone, now); + + // Country lookup: first code is the primary (shown in the row); all codes + // contribute resolved names to the search index so a zone shared by + // multiple countries (Europe/Berlin → DE, DK, NO, SE, …) matches any of them. + const countryCodes = ZONE_COUNTRIES[zone] ?? []; + const countryAllNames = countryCodes + .map((code) => countryNames(code)) + .filter(Boolean); + const primaryCountry = countryAllNames[0] ?? ""; + + const offsetCondensed = offsetLabel.replace(/[^+\-0-9]/g, ""); + const searchValue = [ + zone, + city, + region, + abbreviation, + offsetLabel, + offsetCondensed, + ...countryAllNames, + ...countryCodes, + ] + .filter(Boolean) + .join(" "); + + return { + zone, + region, + city, + primaryCountry, + offsetLabel, + offsetMinutes, + abbreviation, + searchValue, + }; +} + +function makeCountryNameResolver(): (code: string) => string { + let formatter: Intl.DisplayNames | null = null; + try { + formatter = new Intl.DisplayNames(["en"], { type: "region" }); + } catch { + // Older runtimes without DisplayNames — fall back to returning the code. + } + return (code) => { + if (!formatter) return code; + try { + return formatter.of(code) ?? code; + } catch { + return code; + } + }; +} + +function formatOffset(zone: string, now: Date): string { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: zone, + timeZoneName: "longOffset", + }).formatToParts(now); + const raw = + parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+00:00"; + // "GMT+01:00" -> "UTC+01:00"; bare "GMT" -> "UTC+00:00" + return raw === "GMT" ? "UTC+00:00" : raw.replace(/^GMT/, "UTC"); + } catch { + return "UTC+00:00"; + } +} + +function parseOffsetMinutes(offsetLabel: string): number { + const match = offsetLabel.match(/([+-])(\d{1,2}):(\d{2})$/); + if (!match) return 0; + const sign = match[1] === "+" ? 1 : -1; + return sign * (Number(match[2]) * 60 + Number(match[3])); +} + +function formatAbbreviation(zone: string, now: Date): string { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: zone, + timeZoneName: "short", + }).formatToParts(now); + const raw = parts.find((p) => p.type === "timeZoneName")?.value ?? ""; + // Drop generic "GMT+1" abbreviations — those duplicate the offset column. + return /^GMT[+-]/.test(raw) ? "" : raw; + } catch { + return ""; + } +} diff --git a/src/components/Admin/ScheduleImport/zoneCountries.ts b/src/components/Admin/ScheduleImport/zoneCountries.ts new file mode 100644 index 00000000..283032cc --- /dev/null +++ b/src/components/Admin/ScheduleImport/zoneCountries.ts @@ -0,0 +1,366 @@ +// Generated from /usr/share/zoneinfo/zone1970.tab (IANA tz database). +// Maps IANA timezone -> ISO 3166 alpha-2 country codes; the first code is +// the primary country. Shared zones (e.g. Europe/Berlin) list all overlapping +// countries so country search hits them too. +// +// Regenerate with: node scripts/gen-zone-countries.mjs + +export const ZONE_COUNTRIES: Record = { + "Africa/Abidjan": [ + "CI", + "BF", + "GH", + "GM", + "GN", + "IS", + "ML", + "MR", + "SH", + "SL", + "SN", + "TG", + ], + "Africa/Algiers": ["DZ"], + "Africa/Bissau": ["GW"], + "Africa/Cairo": ["EG"], + "Africa/Casablanca": ["MA"], + "Africa/Ceuta": ["ES"], + "Africa/El_Aaiun": ["EH"], + "Africa/Johannesburg": ["ZA", "LS", "SZ"], + "Africa/Juba": ["SS"], + "Africa/Khartoum": ["SD"], + "Africa/Lagos": ["NG", "AO", "BJ", "CD", "CF", "CG", "CM", "GA", "GQ", "NE"], + "Africa/Maputo": ["MZ", "BI", "BW", "CD", "MW", "RW", "ZM", "ZW"], + "Africa/Monrovia": ["LR"], + "Africa/Nairobi": [ + "KE", + "DJ", + "ER", + "ET", + "KM", + "MG", + "SO", + "TZ", + "UG", + "YT", + ], + "Africa/Ndjamena": ["TD"], + "Africa/Sao_Tome": ["ST"], + "Africa/Tripoli": ["LY"], + "Africa/Tunis": ["TN"], + "Africa/Windhoek": ["NA"], + "America/Adak": ["US"], + "America/Anchorage": ["US"], + "America/Araguaina": ["BR"], + "America/Argentina/Buenos_Aires": ["AR"], + "America/Argentina/Catamarca": ["AR"], + "America/Argentina/Cordoba": ["AR"], + "America/Argentina/Jujuy": ["AR"], + "America/Argentina/La_Rioja": ["AR"], + "America/Argentina/Mendoza": ["AR"], + "America/Argentina/Rio_Gallegos": ["AR"], + "America/Argentina/Salta": ["AR"], + "America/Argentina/San_Juan": ["AR"], + "America/Argentina/San_Luis": ["AR"], + "America/Argentina/Tucuman": ["AR"], + "America/Argentina/Ushuaia": ["AR"], + "America/Asuncion": ["PY"], + "America/Bahia": ["BR"], + "America/Bahia_Banderas": ["MX"], + "America/Barbados": ["BB"], + "America/Belem": ["BR"], + "America/Belize": ["BZ"], + "America/Boa_Vista": ["BR"], + "America/Bogota": ["CO"], + "America/Boise": ["US"], + "America/Cambridge_Bay": ["CA"], + "America/Campo_Grande": ["BR"], + "America/Cancun": ["MX"], + "America/Caracas": ["VE"], + "America/Cayenne": ["GF"], + "America/Chicago": ["US"], + "America/Chihuahua": ["MX"], + "America/Ciudad_Juarez": ["MX"], + "America/Costa_Rica": ["CR"], + "America/Coyhaique": ["CL"], + "America/Cuiaba": ["BR"], + "America/Danmarkshavn": ["GL"], + "America/Dawson": ["CA"], + "America/Dawson_Creek": ["CA"], + "America/Denver": ["US"], + "America/Detroit": ["US"], + "America/Edmonton": ["CA"], + "America/Eirunepe": ["BR"], + "America/El_Salvador": ["SV"], + "America/Fort_Nelson": ["CA"], + "America/Fortaleza": ["BR"], + "America/Glace_Bay": ["CA"], + "America/Goose_Bay": ["CA"], + "America/Grand_Turk": ["TC"], + "America/Guatemala": ["GT"], + "America/Guayaquil": ["EC"], + "America/Guyana": ["GY"], + "America/Halifax": ["CA"], + "America/Havana": ["CU"], + "America/Hermosillo": ["MX"], + "America/Indiana/Indianapolis": ["US"], + "America/Indiana/Knox": ["US"], + "America/Indiana/Marengo": ["US"], + "America/Indiana/Petersburg": ["US"], + "America/Indiana/Tell_City": ["US"], + "America/Indiana/Vevay": ["US"], + "America/Indiana/Vincennes": ["US"], + "America/Indiana/Winamac": ["US"], + "America/Inuvik": ["CA"], + "America/Iqaluit": ["CA"], + "America/Jamaica": ["JM"], + "America/Juneau": ["US"], + "America/Kentucky/Louisville": ["US"], + "America/Kentucky/Monticello": ["US"], + "America/La_Paz": ["BO"], + "America/Lima": ["PE"], + "America/Los_Angeles": ["US"], + "America/Maceio": ["BR"], + "America/Managua": ["NI"], + "America/Manaus": ["BR"], + "America/Martinique": ["MQ"], + "America/Matamoros": ["MX"], + "America/Mazatlan": ["MX"], + "America/Menominee": ["US"], + "America/Merida": ["MX"], + "America/Metlakatla": ["US"], + "America/Mexico_City": ["MX"], + "America/Miquelon": ["PM"], + "America/Moncton": ["CA"], + "America/Monterrey": ["MX"], + "America/Montevideo": ["UY"], + "America/New_York": ["US"], + "America/Nome": ["US"], + "America/Noronha": ["BR"], + "America/North_Dakota/Beulah": ["US"], + "America/North_Dakota/Center": ["US"], + "America/North_Dakota/New_Salem": ["US"], + "America/Nuuk": ["GL"], + "America/Ojinaga": ["MX"], + "America/Panama": ["PA", "CA", "KY"], + "America/Paramaribo": ["SR"], + "America/Phoenix": ["US", "CA"], + "America/Port-au-Prince": ["HT"], + "America/Porto_Velho": ["BR"], + "America/Puerto_Rico": [ + "PR", + "AG", + "CA", + "AI", + "AW", + "BL", + "BQ", + "CW", + "DM", + "GD", + "GP", + "KN", + "LC", + "MF", + "MS", + "SX", + "TT", + "VC", + "VG", + "VI", + ], + "America/Punta_Arenas": ["CL"], + "America/Rankin_Inlet": ["CA"], + "America/Recife": ["BR"], + "America/Regina": ["CA"], + "America/Resolute": ["CA"], + "America/Rio_Branco": ["BR"], + "America/Santarem": ["BR"], + "America/Santiago": ["CL"], + "America/Santo_Domingo": ["DO"], + "America/Sao_Paulo": ["BR"], + "America/Scoresbysund": ["GL"], + "America/Sitka": ["US"], + "America/St_Johns": ["CA"], + "America/Swift_Current": ["CA"], + "America/Tegucigalpa": ["HN"], + "America/Thule": ["GL"], + "America/Tijuana": ["MX"], + "America/Toronto": ["CA", "BS"], + "America/Vancouver": ["CA"], + "America/Whitehorse": ["CA"], + "America/Winnipeg": ["CA"], + "America/Yakutat": ["US"], + "Antarctica/Casey": ["AQ"], + "Antarctica/Davis": ["AQ"], + "Antarctica/Macquarie": ["AU"], + "Antarctica/Mawson": ["AQ"], + "Antarctica/Palmer": ["AQ"], + "Antarctica/Rothera": ["AQ"], + "Antarctica/Troll": ["AQ"], + "Antarctica/Vostok": ["AQ"], + "Asia/Almaty": ["KZ"], + "Asia/Amman": ["JO"], + "Asia/Anadyr": ["RU"], + "Asia/Aqtau": ["KZ"], + "Asia/Aqtobe": ["KZ"], + "Asia/Ashgabat": ["TM"], + "Asia/Atyrau": ["KZ"], + "Asia/Baghdad": ["IQ"], + "Asia/Baku": ["AZ"], + "Asia/Bangkok": ["TH", "CX", "KH", "LA", "VN"], + "Asia/Barnaul": ["RU"], + "Asia/Beirut": ["LB"], + "Asia/Bishkek": ["KG"], + "Asia/Chita": ["RU"], + "Asia/Colombo": ["LK"], + "Asia/Damascus": ["SY"], + "Asia/Dhaka": ["BD"], + "Asia/Dili": ["TL"], + "Asia/Dubai": ["AE", "OM", "RE", "SC", "TF"], + "Asia/Dushanbe": ["TJ"], + "Asia/Famagusta": ["CY"], + "Asia/Gaza": ["PS"], + "Asia/Hebron": ["PS"], + "Asia/Ho_Chi_Minh": ["VN"], + "Asia/Hong_Kong": ["HK"], + "Asia/Hovd": ["MN"], + "Asia/Irkutsk": ["RU"], + "Asia/Jakarta": ["ID"], + "Asia/Jayapura": ["ID"], + "Asia/Jerusalem": ["IL"], + "Asia/Kabul": ["AF"], + "Asia/Kamchatka": ["RU"], + "Asia/Karachi": ["PK"], + "Asia/Kathmandu": ["NP"], + "Asia/Khandyga": ["RU"], + "Asia/Kolkata": ["IN"], + "Asia/Krasnoyarsk": ["RU"], + "Asia/Kuching": ["MY", "BN"], + "Asia/Macau": ["MO"], + "Asia/Magadan": ["RU"], + "Asia/Makassar": ["ID"], + "Asia/Manila": ["PH"], + "Asia/Nicosia": ["CY"], + "Asia/Novokuznetsk": ["RU"], + "Asia/Novosibirsk": ["RU"], + "Asia/Omsk": ["RU"], + "Asia/Oral": ["KZ"], + "Asia/Pontianak": ["ID"], + "Asia/Pyongyang": ["KP"], + "Asia/Qatar": ["QA", "BH"], + "Asia/Qostanay": ["KZ"], + "Asia/Qyzylorda": ["KZ"], + "Asia/Riyadh": ["SA", "AQ", "KW", "YE"], + "Asia/Sakhalin": ["RU"], + "Asia/Samarkand": ["UZ"], + "Asia/Seoul": ["KR"], + "Asia/Shanghai": ["CN"], + "Asia/Singapore": ["SG", "AQ", "MY"], + "Asia/Srednekolymsk": ["RU"], + "Asia/Taipei": ["TW"], + "Asia/Tashkent": ["UZ"], + "Asia/Tbilisi": ["GE"], + "Asia/Tehran": ["IR"], + "Asia/Thimphu": ["BT"], + "Asia/Tokyo": ["JP", "AU"], + "Asia/Tomsk": ["RU"], + "Asia/Ulaanbaatar": ["MN"], + "Asia/Urumqi": ["CN"], + "Asia/Ust-Nera": ["RU"], + "Asia/Vladivostok": ["RU"], + "Asia/Yakutsk": ["RU"], + "Asia/Yangon": ["MM", "CC"], + "Asia/Yekaterinburg": ["RU"], + "Asia/Yerevan": ["AM"], + "Atlantic/Azores": ["PT"], + "Atlantic/Bermuda": ["BM"], + "Atlantic/Canary": ["ES"], + "Atlantic/Cape_Verde": ["CV"], + "Atlantic/Faroe": ["FO"], + "Atlantic/Madeira": ["PT"], + "Atlantic/South_Georgia": ["GS"], + "Atlantic/Stanley": ["FK"], + "Australia/Adelaide": ["AU"], + "Australia/Brisbane": ["AU"], + "Australia/Broken_Hill": ["AU"], + "Australia/Darwin": ["AU"], + "Australia/Eucla": ["AU"], + "Australia/Hobart": ["AU"], + "Australia/Lindeman": ["AU"], + "Australia/Lord_Howe": ["AU"], + "Australia/Melbourne": ["AU"], + "Australia/Perth": ["AU"], + "Australia/Sydney": ["AU"], + "Europe/Andorra": ["AD"], + "Europe/Astrakhan": ["RU"], + "Europe/Athens": ["GR"], + "Europe/Belgrade": ["RS", "BA", "HR", "ME", "MK", "SI"], + "Europe/Berlin": ["DE", "DK", "NO", "SE", "SJ"], + "Europe/Brussels": ["BE", "LU", "NL"], + "Europe/Bucharest": ["RO"], + "Europe/Budapest": ["HU"], + "Europe/Chisinau": ["MD"], + "Europe/Dublin": ["IE"], + "Europe/Gibraltar": ["GI"], + "Europe/Helsinki": ["FI", "AX"], + "Europe/Istanbul": ["TR"], + "Europe/Kaliningrad": ["RU"], + "Europe/Kirov": ["RU"], + "Europe/Kyiv": ["UA"], + "Europe/Lisbon": ["PT"], + "Europe/London": ["GB", "GG", "IM", "JE"], + "Europe/Madrid": ["ES"], + "Europe/Malta": ["MT"], + "Europe/Minsk": ["BY"], + "Europe/Moscow": ["RU"], + "Europe/Paris": ["FR", "MC"], + "Europe/Prague": ["CZ", "SK"], + "Europe/Riga": ["LV"], + "Europe/Rome": ["IT", "SM", "VA"], + "Europe/Samara": ["RU"], + "Europe/Saratov": ["RU"], + "Europe/Simferopol": ["RU", "UA"], + "Europe/Sofia": ["BG"], + "Europe/Tallinn": ["EE"], + "Europe/Tirane": ["AL"], + "Europe/Ulyanovsk": ["RU"], + "Europe/Vienna": ["AT"], + "Europe/Vilnius": ["LT"], + "Europe/Volgograd": ["RU"], + "Europe/Warsaw": ["PL"], + "Europe/Zurich": ["CH", "DE", "LI"], + "Indian/Chagos": ["IO"], + "Indian/Maldives": ["MV", "TF"], + "Indian/Mauritius": ["MU"], + "Pacific/Apia": ["WS"], + "Pacific/Auckland": ["NZ", "AQ"], + "Pacific/Bougainville": ["PG"], + "Pacific/Chatham": ["NZ"], + "Pacific/Easter": ["CL"], + "Pacific/Efate": ["VU"], + "Pacific/Fakaofo": ["TK"], + "Pacific/Fiji": ["FJ"], + "Pacific/Galapagos": ["EC"], + "Pacific/Gambier": ["PF"], + "Pacific/Guadalcanal": ["SB", "FM"], + "Pacific/Guam": ["GU", "MP"], + "Pacific/Honolulu": ["US"], + "Pacific/Kanton": ["KI"], + "Pacific/Kiritimati": ["KI"], + "Pacific/Kosrae": ["FM"], + "Pacific/Kwajalein": ["MH"], + "Pacific/Marquesas": ["PF"], + "Pacific/Nauru": ["NR"], + "Pacific/Niue": ["NU"], + "Pacific/Norfolk": ["NF"], + "Pacific/Noumea": ["NC"], + "Pacific/Pago_Pago": ["AS", "UM"], + "Pacific/Palau": ["PW"], + "Pacific/Pitcairn": ["PN"], + "Pacific/Port_Moresby": ["PG", "AQ", "FM"], + "Pacific/Rarotonga": ["CK"], + "Pacific/Tahiti": ["PF"], + "Pacific/Tarawa": ["KI", "MH", "TV", "UM", "WF"], + "Pacific/Tongatapu": ["TO"], +}; diff --git a/src/hooks/queries/sets/useMatchingSetsQuery.ts b/src/hooks/queries/sets/useMatchingSetsQuery.ts deleted file mode 100644 index 7b2f8ec3..00000000 --- a/src/hooks/queries/sets/useMatchingSetsQuery.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { findMatchingSets } from "@/services/csv/setMatcher"; -import type { SetImportData } from "@/services/csv/csvParser"; -import { supabase } from "@/integrations/supabase/client"; - -function useSetsQuery(editionId: string) { - return useQuery({ - queryKey: ["edition", editionId, "sets"], - queryFn: async () => { - const { data } = await supabase - .from("sets") - .select( - ` - id, - name, - time_start, - stage_id, - stages(name), - set_artists(artist_id, artists(name)) - `, - ) - .eq("festival_edition_id", editionId) - .eq("archived", false); - - return data; - }, - }); -} - -export function useMatchingSetsQuery( - importedSets: SetImportData[], - editionId: string, - enabled: boolean = true, -) { - const setsQuery = useSetsQuery(editionId); - - return useQuery({ - queryKey: ["matchingSets", { existingSets: setsQuery.data!, importedSets }], - queryFn: () => - findMatchingSets({ importedSets, existingSets: setsQuery.data! }), - enabled: enabled && importedSets.length > 0 && !!setsQuery.data, - }); -} diff --git a/src/lib/timeUtils.ts b/src/lib/timeUtils.ts index 50db08f1..b41a82c0 100644 --- a/src/lib/timeUtils.ts +++ b/src/lib/timeUtils.ts @@ -1,5 +1,5 @@ import { format, isValid, parseISO, isSameDay } from "date-fns"; -import { fromZonedTime, toZonedTime } from "date-fns-tz"; +import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz"; export function formatTimeRange( startTime: string | null, @@ -53,6 +53,7 @@ export function formatTimeRange( export function formatDateTime( dateTime: string | null, use24Hour: boolean = false, + timezone?: string, ): string | null { if (!dateTime) return null; @@ -60,6 +61,7 @@ export function formatDateTime( if (!isValid(date)) return null; const dateTimeFormat = use24Hour ? "MMM d, HH:mm" : "MMM d, h:mm a"; + if (timezone) return formatInTimeZone(date, timezone, dateTimeFormat); return format(date, dateTimeFormat); } diff --git a/src/pages/admin/festivals/CSVImportDialog/ArtistSelect.tsx b/src/pages/admin/festivals/CSVImportDialog/ArtistSelect.tsx deleted file mode 100644 index 89bd9d09..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/ArtistSelect.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { ArtistSelection } from "./SetsPreviewTable"; -import { useArtistsQuery } from "@/hooks/queries/artists/useArtists"; - -interface ArtistSelectProps { - selection: ArtistSelection; - onValueChange: (value: string) => void; -} - -export function ArtistSelect({ selection, onValueChange }: ArtistSelectProps) { - const artistsQuery = useArtistsQuery(); - - if (!artistsQuery.data) { - return <>Loading...; - } - - const allArtists = artistsQuery.data; - - const selectValue = selection.isCreating - ? "create" - : selection.artistId || "create"; - - const exactMatch = allArtists.find( - (a) => a.name.toLowerCase() === selection.csvName.toLowerCase(), - ); - - const displayValue = selection.isCreating ? ( - Creating: {selection.csvName} - ) : ( - allArtists.find((a) => a.id === selection.artistId)?.name || - selection.csvName - ); - - return ( - - ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/ArtistSelectionCell.tsx b/src/pages/admin/festivals/CSVImportDialog/ArtistSelectionCell.tsx deleted file mode 100644 index f3e27881..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/ArtistSelectionCell.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { TableCell } from "@/components/ui/table"; -import type { ArtistSelection } from "./SetsPreviewTable"; -import { ArtistSelect } from "./ArtistSelect"; - -interface ArtistSelectionCellProps { - artistSelections: ArtistSelection[]; - isLoadingMatches: boolean; - validationError?: string; - onArtistSelectionChange: (artistIndex: number, value: string) => void; -} - -export function ArtistSelectionCell({ - artistSelections, - isLoadingMatches, - validationError, - onArtistSelectionChange, -}: ArtistSelectionCellProps) { - return ( - -
- {isLoadingMatches ? ( -
Loading...
- ) : ( - artistSelections.map((selection, artistIdx) => ( - - onArtistSelectionChange(artistIdx, value) - } - /> - )) - )} - {validationError && ( -
{validationError}
- )} -
-
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/FileUploadSection.tsx b/src/pages/admin/festivals/CSVImportDialog/FileUploadSection.tsx deleted file mode 100644 index 5f6e1cf6..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/FileUploadSection.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { FileText } from "lucide-react"; - -interface FileUploadSectionProps { - id: string; - label: string; - file: File | null; - expectedColumns: string; - additionalInfo?: React.ReactNode; - onFileChange: (event: React.ChangeEvent) => void; -} - -export function FileUploadSection({ - id, - label, - file, - expectedColumns, - additionalInfo, - onFileChange, -}: FileUploadSectionProps) { - return ( -
- -
- - {file && ( -
- - {file.name} -
- )} -
-

- Expected columns: {expectedColumns} -

- {additionalInfo} -
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/ImportProgress.tsx b/src/pages/admin/festivals/CSVImportDialog/ImportProgress.tsx deleted file mode 100644 index 42f20f0f..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/ImportProgress.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Progress } from "@/components/ui/progress"; - -interface ImportProgressProps { - progress: { - current: number; - total: number; - label: string; - }; - isImporting: boolean; -} - -export function ImportProgress({ progress, isImporting }: ImportProgressProps) { - if (!isImporting || progress.total === 0) return null; - - return ( -
-
- {progress.label} - {Math.round((progress.current / progress.total) * 100)}% -
- -
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/ImportResults.tsx b/src/pages/admin/festivals/CSVImportDialog/ImportResults.tsx deleted file mode 100644 index cd658181..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/ImportResults.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { AlertCircle, CheckCircle2 } from "lucide-react"; -import type { ImportResult } from "@/services/csv/types"; - -interface ImportResultsProps { - results: ImportResult[]; -} - -export function ImportResults({ results }: ImportResultsProps) { - if (results.length === 0) { - return null; - } - - return ( -
- {results.map((result, index) => { - const hasErrors = result.errors && result.errors.length > 0; - return ( - - -
- {hasErrors || !result.success ? ( - - ) : ( - - )} -
- - {result.success ? "Success" : "Failed"} - - {result.message} -
-
-
- {hasErrors && ( - -
-

- Errors ({result.errors!.length}): -

-
    - {result.errors!.map((error, errorIndex) => ( -
  • - - {error} -
  • - ))} -
-
-
- )} -
- ); - })} -
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/MatchingSetCell.tsx b/src/pages/admin/festivals/CSVImportDialog/MatchingSetCell.tsx deleted file mode 100644 index 3396ab1c..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/MatchingSetCell.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { TableCell } from "@/components/ui/table"; -import { Link2 } from "lucide-react"; -import type { MatchingSet } from "@/services/csv/setMatcher"; - -interface MatchingSetCellProps { - matchingSet: MatchingSet | null; - isLoading: boolean; -} - -export function MatchingSetCell({ - matchingSet, - isLoading, -}: MatchingSetCellProps) { - if (isLoading) { - return ( - -
Loading...
-
- ); - } - - if (!matchingSet) { - return ( - -
No match
-
- ); - } - - return ( - -
-
- - - {matchingSet.name || "Unnamed Set"} - -
-
- {matchingSet.stage_name &&
Stage: {matchingSet.stage_name}
} -
- {matchingSet.vote_count}{" "} - {matchingSet.vote_count === 1 ? "vote" : "votes"} -
-
-
-
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/SetPreviewRow.tsx b/src/pages/admin/festivals/CSVImportDialog/SetPreviewRow.tsx deleted file mode 100644 index 3438f2ed..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/SetPreviewRow.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { TableCell, TableRow } from "@/components/ui/table"; -import type { SetImportData } from "@/services/csv/csvParser"; -import type { SetValidationResult } from "@/services/csv/timeValidator"; -import { cn } from "@/lib/utils"; -import type { MatchingSet } from "@/services/csv/setMatcher"; -import type { ArtistSelection, SetSelection } from "./SetsPreviewTable"; -import { ArtistSelectionCell } from "./ArtistSelectionCell"; -import { StageCellWithValidation } from "./StageCellWithValidation"; -import { TimeCellWithValidation } from "./TimeCellWithValidation"; -import { SetSelectionCell } from "./SetSelectionCell"; - -interface SetPreviewRowProps { - set: SetImportData; - index: number; - validation: SetValidationResult; - hasSeparateDateFields: boolean; - matchingSets: MatchingSet[]; - setSelection?: SetSelection; - artistSelections: ArtistSelection[]; - isLoadingMatches: boolean; - editionId: string; - onArtistSelectionChange: ( - setIndex: number, - artistIndex: number, - value: string, - ) => void; - onSetSelectionChange: (setIndex: number, selection: SetSelection) => void; -} - -export function SetPreviewRow({ - set, - index, - validation, - hasSeparateDateFields, - matchingSets, - setSelection, - artistSelections, - isLoadingMatches, - editionId, - onArtistSelectionChange, - onSetSelectionChange, -}: SetPreviewRowProps) { - const hasErrors = !validation.isValid; - - return ( - - - {index + 1} - - - - onArtistSelectionChange(index, artistIndex, value) - } - /> - {hasSeparateDateFields ? ( - <> - -
{set.date_start || "-"}
-
- - -
{set.date_end || "-"}
-
- - - ) : ( - <> - - - - )} - {set.name || "-"} - - {set.description || "-"} - - - onSetSelectionChange(index, selection) - } - /> -
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/SetSelectionCell.tsx b/src/pages/admin/festivals/CSVImportDialog/SetSelectionCell.tsx deleted file mode 100644 index d6c5f1c3..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/SetSelectionCell.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { TableCell } from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Label } from "@/components/ui/label"; -import type { MatchingSet } from "@/services/csv/setMatcher"; -import type { SetSelection } from "./SetsPreviewTable"; -import { useSetsByEditionQuery } from "@/hooks/queries/sets/useSetsByEdition"; -import { useMemo } from "react"; - -interface SetSelectionCellProps { - matchingSets: MatchingSet[]; - setSelection?: SetSelection; - isLoading: boolean; - editionId: string; - onSetSelectionChange: (selection: SetSelection) => void; -} - -export function SetSelectionCell({ - matchingSets, - setSelection, - isLoading, - editionId, - onSetSelectionChange, -}: SetSelectionCellProps) { - const allSetsQuery = useSetsByEditionQuery(editionId); - - const otherSets = useMemo(() => { - const matchingIds = new Set(matchingSets.map((m) => m.id)); - return allSetsQuery.data?.filter((set) => !matchingIds.has(set.id)) || []; - }, [matchingSets, allSetsQuery.data]); - - if (isLoading || allSetsQuery.isLoading) { - return ( - -
Loading...
-
- ); - } - - const selectedSetId = setSelection?.matchedSetId; - const selectedAction = setSelection?.action || "create"; - - function handleSetChange(setId: string) { - if (setId === "create") { - onSetSelectionChange({ action: "create" }); - } else { - onSetSelectionChange({ - action: "match", - matchedSetId: setId, - }); - } - } - - function handleActionChange(action: "match" | "duplicate") { - if (!selectedSetId) return; - onSetSelectionChange({ - action, - matchedSetId: selectedSetId, - }); - } - - return ( - -
- - - {selectedSetId && selectedSetId !== "create" && ( - -
- - -
-
- - -
-
- )} -
-
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/SetsPreviewTable.tsx b/src/pages/admin/festivals/CSVImportDialog/SetsPreviewTable.tsx deleted file mode 100644 index 4b4a4ba2..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/SetsPreviewTable.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { - Table, - TableBody, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { AlertCircle, CheckCircle2 } from "lucide-react"; -import type { SetImportData } from "@/services/csv/csvParser"; -import { - validateSetData, - type SetValidationResult, -} from "@/services/csv/timeValidator"; -import { useState, useEffect, useMemo } from "react"; -import { useArtistsQuery } from "@/hooks/queries/artists/useArtists"; -import { useMatchingSetsQuery } from "@/hooks/queries/sets/useMatchingSetsQuery"; -import { SetPreviewRow } from "./SetPreviewRow"; - -export interface ArtistSelection { - csvName: string; - artistId: string | null; - isCreating: boolean; -} - -export interface SetSelection { - action: "match" | "duplicate" | "create"; - matchedSetId?: string; -} - -interface SetsPreviewTableProps { - sets: SetImportData[]; - timezone: string; - editionId: string; - onArtistSelectionsChange?: ( - selections: Map, - ) => void; - onSetSelectionsChange?: (selections: Map) => void; -} - -export function SetsPreviewTable({ - sets, - timezone, - editionId, - onArtistSelectionsChange, - onSetSelectionsChange, -}: SetsPreviewTableProps) { - const [artistSelections, setArtistSelections] = useState< - Map - >(new Map()); - const [setSelections, setSetSelections] = useState>( - new Map(), - ); - - const artistsQuery = useArtistsQuery(); - const matchingSetsQuery = useMatchingSetsQuery(sets, editionId); - - const matchingSets = matchingSetsQuery.data || new Map(); - const isLoadingMatches = matchingSetsQuery.isLoading; - - const artistsByName = useMemo(() => { - const map = new Map(); - artistsQuery.data?.forEach((artist) => { - map.set(artist.name.toLowerCase(), artist.id); - }); - return map; - }, [artistsQuery.data]); - - useEffect(() => { - const initialArtistSelections = new Map(); - - sets.forEach((set, index) => { - const artistNames = set.artist_names - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0); - - const selections: ArtistSelection[] = artistNames.map((csvName) => { - const artistId = artistsByName.get(csvName.toLowerCase()); - - return { - csvName, - artistId: artistId || null, - isCreating: !artistId, - }; - }); - - initialArtistSelections.set(index, selections); - }); - - setArtistSelections(initialArtistSelections); - onArtistSelectionsChange?.(initialArtistSelections); - }, [sets, artistsByName, onArtistSelectionsChange]); - - useEffect(() => { - const initialSetSelections = new Map(); - - sets.forEach((_set, index) => { - if (!matchingSetsQuery.data) return; - - const matchingSetsForRow = matchingSetsQuery.data?.get(index) || []; - if (matchingSetsForRow.length > 0) { - initialSetSelections.set(index, { - action: "match", - matchedSetId: matchingSetsForRow[0].id, - }); - } else { - initialSetSelections.set(index, { - action: "create", - }); - } - }); - - setSetSelections(initialSetSelections); - onSetSelectionsChange?.(initialSetSelections); - }, [sets, matchingSetsQuery.data, onSetSelectionsChange]); - - if (sets.length === 0) { - return null; - } - - const validationResults: SetValidationResult[] = sets.map((set, index) => - validateSetData(set, index, timezone), - ); - - const validCount = validationResults.filter((r) => r.isValid).length; - const invalidCount = validationResults.length - validCount; - - const hasSeparateDateFields = sets.some( - (set) => set.date_start !== undefined || set.date_end !== undefined, - ); - - return ( - - -
- - Preview: {sets.length} set{sets.length !== 1 ? "s" : ""} (timezone:{" "} - {timezone}) - -
- {validCount > 0 && ( - - - {validCount} valid - - )} - {invalidCount > 0 && ( - - - {invalidCount} invalid - - )} -
-
-
- -
- - - - # - Stage - Artist(s) - {hasSeparateDateFields ? ( - <> - Date Start - Time Start - Date End - Time End - - ) : ( - <> - Start Time - End Time - - )} - Set Name - Description - Matching Set - - - - {sets.map((set, index) => ( - - ))} - -
-
-
-
- ); - - function handleArtistSelectionChange( - setIndex: number, - artistIndex: number, - value: string, - ) { - const currentSelections = artistSelections.get(setIndex) || []; - const newSelections = [...currentSelections]; - const selection = newSelections[artistIndex]; - - if (value === "create") { - newSelections[artistIndex] = { - ...selection, - artistId: null, - isCreating: true, - }; - } else { - newSelections[artistIndex] = { - ...selection, - artistId: value, - isCreating: false, - }; - } - - const newMap = new Map(artistSelections); - newMap.set(setIndex, newSelections); - setArtistSelections(newMap); - onArtistSelectionsChange?.(newMap); - } - - function handleSetSelectionChange(setIndex: number, selection: SetSelection) { - const newMap = new Map(setSelections); - newMap.set(setIndex, selection); - setSetSelections(newMap); - onSetSelectionsChange?.(newMap); - } -} diff --git a/src/pages/admin/festivals/CSVImportDialog/SetsTabContent.tsx b/src/pages/admin/festivals/CSVImportDialog/SetsTabContent.tsx deleted file mode 100644 index 06f2612f..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/SetsTabContent.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { FileUploadSection } from "./FileUploadSection"; -import { TimezoneSelector } from "./TimezoneSelector"; - -interface SetsTabContentProps { - setsFile: File | null; - timezone: string; - onSetsFileChange: (event: React.ChangeEvent) => void; - onTimezoneChange: (timezone: string) => void; -} - -export function SetsTabContent({ - setsFile, - timezone, - onSetsFileChange, - onTimezoneChange, -}: SetsTabContentProps) { - return ( -
- -

- • artist_names: Comma-separated artist names - (e.g., "Shpongle,Ott" or just "Shpongle") -

-

- • name: Optional set name. If empty, will - auto-generate from artists -

-

• Artists will be created automatically if they don't exist

-
- } - /> - - - - ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/StageCellWithValidation.tsx b/src/pages/admin/festivals/CSVImportDialog/StageCellWithValidation.tsx deleted file mode 100644 index eefcd73b..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/StageCellWithValidation.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { TableCell } from "@/components/ui/table"; - -interface StageCellWithValidationProps { - stageName?: string; - error?: string; -} - -export function StageCellWithValidation({ - stageName, - error, -}: StageCellWithValidationProps) { - return ( - -
-
{stageName || "-"}
- {error &&
{error}
} -
-
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/StagesPreviewTable.tsx b/src/pages/admin/festivals/CSVImportDialog/StagesPreviewTable.tsx deleted file mode 100644 index b04e487e..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/StagesPreviewTable.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { StageImportData } from "@/services/csv/csvParser"; - -interface StagesPreviewTableProps { - stages: StageImportData[]; -} - -export function StagesPreviewTable({ stages }: StagesPreviewTableProps) { - if (stages.length === 0) { - return null; - } - - return ( - - - - Preview: {stages.length} stage{stages.length !== 1 ? "s" : ""} - - - -
- - - - # - Stage Name - - - - {stages.map((stage, index) => ( - - - {index + 1} - - {stage.name} - - ))} - -
-
-
-
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/StagesTabContent.tsx b/src/pages/admin/festivals/CSVImportDialog/StagesTabContent.tsx deleted file mode 100644 index e779aca2..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/StagesTabContent.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { FileUploadSection } from "./FileUploadSection"; - -interface StagesTabContentProps { - stagesFile: File | null; - onStagesFileChange: (event: React.ChangeEvent) => void; -} - -export function StagesTabContent({ - stagesFile, - onStagesFileChange, -}: StagesTabContentProps) { - return ( - - ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/TimeCellWithValidation.tsx b/src/pages/admin/festivals/CSVImportDialog/TimeCellWithValidation.tsx deleted file mode 100644 index d6019294..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/TimeCellWithValidation.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { TableCell } from "@/components/ui/table"; - -interface TimeCellWithValidationProps { - time?: string; - error?: string; -} - -export function TimeCellWithValidation({ - time, - error, -}: TimeCellWithValidationProps) { - return ( - -
-
{time || "-"}
- {error &&
{error}
} -
-
- ); -} diff --git a/src/pages/admin/festivals/CSVImportDialog/TimezoneSelector.tsx b/src/pages/admin/festivals/CSVImportDialog/TimezoneSelector.tsx deleted file mode 100644 index c1993ae7..00000000 --- a/src/pages/admin/festivals/CSVImportDialog/TimezoneSelector.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -interface TimezoneSelectorProps { - value: string; - onValueChange: (value: string) => void; -} - -export function TimezoneSelector({ - value, - onValueChange, -}: TimezoneSelectorProps) { - return ( -
- - -

- Select the timezone that the CSV times are in -

-
- ); -} diff --git a/src/pages/admin/festivals/CSVImportPage.tsx b/src/pages/admin/festivals/CSVImportPage.tsx deleted file mode 100644 index d6043719..00000000 --- a/src/pages/admin/festivals/CSVImportPage.tsx +++ /dev/null @@ -1,473 +0,0 @@ -import { useState, useEffect } from "react"; -import { useParams, useNavigate, useSearch } from "@tanstack/react-router"; -import { useToast } from "@/hooks/use-toast"; -import { Button } from "@/components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Upload, Loader2, ArrowLeft } from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; -import { importStages } from "@/services/csv/stageImporter"; -import { - importSetsWithMappings, - type ArtistMapping, -} from "@/services/csv/setImporter"; -import { - parseStagesCSV, - parseSetsCSV, - type SetImportData, - type StageImportData, -} from "@/services/csv/csvParser"; -import type { ImportResult } from "@/services/csv/types"; -import { useArtistsQuery } from "@/hooks/queries/artists/useArtists"; -import { StagesTabContent } from "@/pages/admin/festivals/CSVImportDialog/StagesTabContent"; -import { SetsTabContent } from "@/pages/admin/festivals/CSVImportDialog/SetsTabContent"; -import { ImportProgress } from "@/pages/admin/festivals/CSVImportDialog/ImportProgress"; -import { ImportResults } from "@/pages/admin/festivals/CSVImportDialog/ImportResults"; -import { StagesPreviewTable } from "@/pages/admin/festivals/CSVImportDialog/StagesPreviewTable"; -import { - SetsPreviewTable, - type ArtistSelection, - type SetSelection, -} from "@/pages/admin/festivals/CSVImportDialog/SetsPreviewTable"; -import { validateSetSelections } from "@/services/csv/setSelectionValidator"; -import { useFestivalsQuery } from "@/hooks/queries/festivals/useFestivals"; -import { useFestivalEditionsForFestivalQuery } from "@/hooks/queries/festivals/editions/useFestivalEditionsForFestival"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - -function getUserTimezone(): string { - return Intl.DateTimeFormat().resolvedOptions().timeZone; -} - -export function CSVImportPage() { - const { festivalId: urlFestivalId, editionId: urlEditionId } = useParams({ - strict: false, - }); - const navigate = useNavigate(); - const { tab } = useSearch({ strict: false }); - const defaultTab = tab || "stages"; - - const [selectedFestivalId, setSelectedFestivalId] = useState( - urlFestivalId || "", - ); - const [selectedEditionId, setSelectedEditionId] = useState( - urlEditionId || "", - ); - const [isImporting, setIsImporting] = useState(false); - const [stagesFile, setStagesFile] = useState(null); - const [setsFile, setSetsFile] = useState(null); - const [timezone, setTimezone] = useState(getUserTimezone()); - const [progress, setProgress] = useState({ current: 0, total: 0, label: "" }); - const [importResults, setImportResults] = useState([]); - - const [stagesPreview, setStagesPreview] = useState([]); - const [setsPreview, setSetsPreview] = useState([]); - const [artistSelections, setArtistSelections] = useState< - Map - >(new Map()); - const [setSelections, setSetSelections] = useState>( - new Map(), - ); - - const { toast } = useToast(); - const queryClient = useQueryClient(); - const artistsQuery = useArtistsQuery(); - const festivalsQuery = useFestivalsQuery({ all: true }); - const editionsQuery = useFestivalEditionsForFestivalQuery( - selectedFestivalId, - { all: true }, - ); - - useEffect(() => { - if (urlFestivalId) { - setSelectedFestivalId(urlFestivalId); - } - }, [urlFestivalId]); - - useEffect(() => { - if (urlEditionId) { - setSelectedEditionId(urlEditionId); - } - }, [urlEditionId]); - - function handleFestivalChange(festivalId: string) { - setSelectedFestivalId(festivalId); - setSelectedEditionId(""); - navigate({ - to: "/admin/festivals/import", - search: (prev) => ({ tab: prev.tab }), - replace: true, - }); - } - - function handleEditionChange(editionId: string) { - setSelectedEditionId(editionId); - if (selectedFestivalId && editionId) { - navigate({ - to: "/admin/festivals/$festivalId/$editionId/import", - params: { festivalId: selectedFestivalId, editionId }, - search: (prev) => ({ ...prev }), - replace: true, - }); - } - } - - async function handleFileChange( - event: React.ChangeEvent, - type: "stages" | "sets", - ) { - const file = event.target.files?.[0]; - if (file && file.type === "text/csv") { - try { - const content = await readFileAsText(file); - - if (type === "stages") { - const parsedStages = parseStagesCSV(content); - setStagesFile(file); - setStagesPreview(parsedStages); - } else { - const parsedSets = parseSetsCSV(content); - setSetsFile(file); - setSetsPreview(parsedSets); - } - } catch (error) { - toast({ - title: "Failed to parse CSV", - description: - error instanceof Error ? error.message : "Invalid CSV format", - variant: "destructive", - }); - if (type === "stages") { - setStagesFile(null); - setStagesPreview([]); - } else { - setSetsFile(null); - setSetsPreview([]); - } - } - } else { - toast({ - title: "Invalid file", - description: "Please select a CSV file", - variant: "destructive", - }); - } - } - - function readFileAsText(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => resolve(e.target?.result as string); - reader.onerror = reject; - reader.readAsText(file); - }); - } - - async function handleImport() { - if (!stagesFile && !setsFile) { - toast({ - title: "No files selected", - description: "Please select at least one CSV file to import", - variant: "destructive", - }); - return; - } - - if (!selectedEditionId) { - toast({ - title: "No edition selected", - description: "Please select a festival edition", - variant: "destructive", - }); - return; - } - - if (!artistsQuery.data) { - toast({ - title: "Artists data not loaded", - description: "Please wait for artists data to load", - variant: "destructive", - }); - return; - } - - if (setsFile && setSelections.size > 0) { - const validationErrors = validateSetSelections(setSelections); - if (validationErrors.length > 0) { - toast({ - title: "Set selection conflicts", - description: validationErrors[0].message, - variant: "destructive", - }); - return; - } - } - - setIsImporting(true); - setImportResults([]); - const results: ImportResult[] = []; - - try { - if (stagesFile) { - setProgress({ current: 0, total: 0, label: "Importing stages..." }); - const stagesContent = await readFileAsText(stagesFile); - const stagesData = parseStagesCSV(stagesContent); - - const stagesResult = await importStages( - stagesData, - selectedEditionId, - (current, total) => { - setProgress({ - current, - total, - label: `Importing stages (${current}/${total})...`, - }); - }, - ); - results.push(stagesResult); - } - - if (setsFile) { - setProgress({ - current: 0, - total: 0, - label: "Importing sets...", - }); - const setsContent = await readFileAsText(setsFile); - const setsData = parseSetsCSV(setsContent); - - const artistMappings = new Map(); - artistSelections.forEach((selections, index) => { - artistMappings.set( - index, - selections.map((sel) => ({ - csvName: sel.csvName, - artistId: sel.artistId, - shouldCreate: sel.isCreating, - })), - ); - }); - - const setsResult = await importSetsWithMappings( - setsData, - selectedEditionId, - artistMappings, - setSelections, - timezone, - (current, total) => { - setProgress({ - current, - total, - label: `Importing sets (${current}/${total})...`, - }); - }, - ); - results.push(setsResult); - } - - const successCount = results.filter((r) => r.success).length; - const failureCount = results.filter((r) => !r.success).length; - const allErrors = results.flatMap((r) => r.errors || []); - - setImportResults(results); - - if (successCount > 0 && failureCount === 0 && allErrors.length === 0) { - toast({ - title: "Import successful", - description: results.map((r) => r.message).join(". "), - }); - - queryClient.invalidateQueries({ queryKey: ["stages"] }); - queryClient.invalidateQueries({ queryKey: ["sets"] }); - queryClient.invalidateQueries({ queryKey: ["artists"] }); - - setStagesFile(null); - setSetsFile(null); - setStagesPreview([]); - setSetsPreview([]); - setProgress({ current: 0, total: 0, label: "" }); - setImportResults([]); - } else { - toast({ - title: "Import completed with issues", - description: `${results.map((r) => r.message).join(". ")}${allErrors.length > 0 ? ` See details below for ${allErrors.length} error${allErrors.length === 1 ? "" : "s"}.` : ""}`, - variant: failureCount > 0 ? "destructive" : "default", - }); - } - } catch (error) { - toast({ - title: "Import failed", - description: error instanceof Error ? error.message : "Unknown error", - variant: "destructive", - }); - } finally { - setIsImporting(false); - setProgress({ current: 0, total: 0, label: "" }); - } - } - - const selectedFestival = festivalsQuery.data?.find( - (f) => f.id === selectedFestivalId, - ); - const selectedEdition = editionsQuery.data?.find( - (e) => e.id === selectedEditionId, - ); - - return ( -
-
- -
- - - - Import CSV Data - - Select a festival and edition, then upload CSV files to import - stages and sets. - - - -
-
- - -
- -
- - -
-
- - {selectedFestival && selectedEdition && ( -
-

- Importing to:{" "} - {selectedFestival.name} {selectedEdition.year} - {selectedEdition.name && ` - ${selectedEdition.name}`} -

-
- )} -
-
- - {selectedEditionId && ( - - - - - Stages - Sets - - - - handleFileChange(e, "stages")} - /> - {stagesPreview.length > 0 && ( - - )} - - - - handleFileChange(e, "sets")} - onTimezoneChange={setTimezone} - /> - {setsPreview.length > 0 && selectedEditionId && ( - - )} - - - - - - - -
- -
-
-
- )} -
- ); -} diff --git a/src/pages/admin/festivals/FestivalEdition.tsx b/src/pages/admin/festivals/FestivalEdition.tsx index cbb5725f..e4a96aa3 100644 --- a/src/pages/admin/festivals/FestivalEdition.tsx +++ b/src/pages/admin/festivals/FestivalEdition.tsx @@ -1,5 +1,5 @@ import { useParams, useLocation, Outlet, Link } from "@tanstack/react-router"; -import { Loader2, MapPin, Music } from "lucide-react"; +import { Loader2, MapPin, Music, Upload } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useFestivalEditionBySlugQuery } from "@/hooks/queries/festivals/editions/useFestivalEditionBySlug"; import { cn } from "@/lib/utils"; @@ -44,6 +44,7 @@ export default function FestivalEdition() { const isOnSets = location.pathname.includes("/sets"); const isOnStages = location.pathname.includes("/stages"); + const isOnImport = location.pathname.includes("/import"); return (
@@ -57,7 +58,7 @@ export default function FestivalEdition() {
-
+
Sets + + + Import +
diff --git a/src/pages/admin/festivals/SetsManagement/SetManagement.tsx b/src/pages/admin/festivals/SetsManagement/SetManagement.tsx index fbb5ff5d..0578eca6 100644 --- a/src/pages/admin/festivals/SetsManagement/SetManagement.tsx +++ b/src/pages/admin/festivals/SetsManagement/SetManagement.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; -import { Link, useParams } from "@tanstack/react-router"; +import { useParams } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Loader2, Plus, Music, Upload } from "lucide-react"; +import { Loader2, Plus, Music } from "lucide-react"; import { FestivalSet } from "@/hooks/queries/sets/useSets"; import { useSetsByEditionQuery } from "@/hooks/queries/sets/useSetsByEdition"; import { useDeleteSetMutation } from "@/hooks/queries/sets/useDeleteSet"; @@ -72,19 +72,6 @@ export function SetManagement() { Set Management
- - -
+ diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ee6778fd..47dc86ce 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -22,7 +22,6 @@ import { Route as AdminArtistsRouteImport } from './routes/admin/artists' import { Route as AdminAnalyticsRouteImport } from './routes/admin/analytics' import { Route as AdminAdminsRouteImport } from './routes/admin/admins' import { Route as FestivalsFestivalSlugIndexRouteImport } from './routes/festivals/$festivalSlug/index' -import { Route as AdminFestivalsImportRouteImport } from './routes/admin/festivals/import' import { Route as AdminFestivalsFestivalSlugRouteImport } from './routes/admin/festivals/$festivalSlug' import { Route as AdminArtistsDuplicatesRouteImport } from './routes/admin/artists/duplicates' import { Route as FestivalsFestivalSlugEditionsEditionSlugRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug' @@ -33,13 +32,13 @@ import { Route as FestivalsFestivalSlugEditionsEditionSlugMapRouteImport } from import { Route as FestivalsFestivalSlugEditionsEditionSlugInfoRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/info' import { Route as FestivalsFestivalSlugEditionsEditionSlugExploreRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/explore' import { Route as AdminFestivalsFestivalSlugEditionsEditionSlugRouteImport } from './routes/admin/festivals/$festivalSlug/editions/$editionSlug' -import { Route as AdminFestivalsFestivalIdEditionIdImportRouteImport } from './routes/admin/festivals/$festivalId.$editionId.import' import { Route as FestivalsFestivalSlugEditionsEditionSlugSetsIndexRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/sets/index' import { Route as FestivalsFestivalSlugEditionsEditionSlugSetsSetSlugRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/sets/$setSlug' import { Route as FestivalsFestivalSlugEditionsEditionSlugScheduleTimelineRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline' import { Route as FestivalsFestivalSlugEditionsEditionSlugScheduleListRouteImport } from './routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list' import { Route as AdminFestivalsFestivalSlugEditionsEditionSlugStagesRouteImport } from './routes/admin/festivals/$festivalSlug/editions/$editionSlug/stages' import { Route as AdminFestivalsFestivalSlugEditionsEditionSlugSetsRouteImport } from './routes/admin/festivals/$festivalSlug/editions/$editionSlug/sets' +import { Route as AdminFestivalsFestivalSlugEditionsEditionSlugImportRouteImport } from './routes/admin/festivals/$festivalSlug/editions/$editionSlug/import' const TermsRoute = TermsRouteImport.update({ id: '/terms', @@ -107,11 +106,6 @@ const FestivalsFestivalSlugIndexRoute = path: '/', getParentRoute: () => FestivalsFestivalSlugRoute, } as any) -const AdminFestivalsImportRoute = AdminFestivalsImportRouteImport.update({ - id: '/import', - path: '/import', - getParentRoute: () => AdminFestivalsRoute, -} as any) const AdminFestivalsFestivalSlugRoute = AdminFestivalsFestivalSlugRouteImport.update({ id: '/$festivalSlug', @@ -171,12 +165,6 @@ const AdminFestivalsFestivalSlugEditionsEditionSlugRoute = path: '/editions/$editionSlug', getParentRoute: () => AdminFestivalsFestivalSlugRoute, } as any) -const AdminFestivalsFestivalIdEditionIdImportRoute = - AdminFestivalsFestivalIdEditionIdImportRouteImport.update({ - id: '/$festivalId/$editionId/import', - path: '/$festivalId/$editionId/import', - getParentRoute: () => AdminFestivalsRoute, - } as any) const FestivalsFestivalSlugEditionsEditionSlugSetsIndexRoute = FestivalsFestivalSlugEditionsEditionSlugSetsIndexRouteImport.update({ id: '/', @@ -213,6 +201,12 @@ const AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute = path: '/sets', getParentRoute: () => AdminFestivalsFestivalSlugEditionsEditionSlugRoute, } as any) +const AdminFestivalsFestivalSlugEditionsEditionSlugImportRoute = + AdminFestivalsFestivalSlugEditionsEditionSlugImportRouteImport.update({ + id: '/import', + path: '/import', + getParentRoute: () => AdminFestivalsFestivalSlugEditionsEditionSlugRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -229,10 +223,8 @@ export interface FileRoutesByFullPath { '/groups': typeof GroupsIndexRoute '/admin/artists/duplicates': typeof AdminArtistsDuplicatesRoute '/admin/festivals/$festivalSlug': typeof AdminFestivalsFestivalSlugRouteWithChildren - '/admin/festivals/import': typeof AdminFestivalsImportRoute '/festivals/$festivalSlug/': typeof FestivalsFestivalSlugIndexRoute '/festivals/$festivalSlug/editions/$editionSlug': typeof FestivalsFestivalSlugEditionsEditionSlugRouteWithChildren - '/admin/festivals/$festivalId/$editionId/import': typeof AdminFestivalsFestivalIdEditionIdImportRoute '/admin/festivals/$festivalSlug/editions/$editionSlug': typeof AdminFestivalsFestivalSlugEditionsEditionSlugRouteWithChildren '/festivals/$festivalSlug/editions/$editionSlug/explore': typeof FestivalsFestivalSlugEditionsEditionSlugExploreRoute '/festivals/$festivalSlug/editions/$editionSlug/info': typeof FestivalsFestivalSlugEditionsEditionSlugInfoRoute @@ -240,6 +232,7 @@ export interface FileRoutesByFullPath { '/festivals/$festivalSlug/editions/$editionSlug/schedule': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleRouteWithChildren '/festivals/$festivalSlug/editions/$editionSlug/sets': typeof FestivalsFestivalSlugEditionsEditionSlugSetsRouteWithChildren '/festivals/$festivalSlug/editions/$editionSlug/social': typeof FestivalsFestivalSlugEditionsEditionSlugSocialRoute + '/admin/festivals/$festivalSlug/editions/$editionSlug/import': typeof AdminFestivalsFestivalSlugEditionsEditionSlugImportRoute '/admin/festivals/$festivalSlug/editions/$editionSlug/sets': typeof AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute '/admin/festivals/$festivalSlug/editions/$editionSlug/stages': typeof AdminFestivalsFestivalSlugEditionsEditionSlugStagesRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule/list': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute @@ -261,16 +254,15 @@ export interface FileRoutesByTo { '/groups': typeof GroupsIndexRoute '/admin/artists/duplicates': typeof AdminArtistsDuplicatesRoute '/admin/festivals/$festivalSlug': typeof AdminFestivalsFestivalSlugRouteWithChildren - '/admin/festivals/import': typeof AdminFestivalsImportRoute '/festivals/$festivalSlug': typeof FestivalsFestivalSlugIndexRoute '/festivals/$festivalSlug/editions/$editionSlug': typeof FestivalsFestivalSlugEditionsEditionSlugRouteWithChildren - '/admin/festivals/$festivalId/$editionId/import': typeof AdminFestivalsFestivalIdEditionIdImportRoute '/admin/festivals/$festivalSlug/editions/$editionSlug': typeof AdminFestivalsFestivalSlugEditionsEditionSlugRouteWithChildren '/festivals/$festivalSlug/editions/$editionSlug/explore': typeof FestivalsFestivalSlugEditionsEditionSlugExploreRoute '/festivals/$festivalSlug/editions/$editionSlug/info': typeof FestivalsFestivalSlugEditionsEditionSlugInfoRoute '/festivals/$festivalSlug/editions/$editionSlug/map': typeof FestivalsFestivalSlugEditionsEditionSlugMapRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleRouteWithChildren '/festivals/$festivalSlug/editions/$editionSlug/social': typeof FestivalsFestivalSlugEditionsEditionSlugSocialRoute + '/admin/festivals/$festivalSlug/editions/$editionSlug/import': typeof AdminFestivalsFestivalSlugEditionsEditionSlugImportRoute '/admin/festivals/$festivalSlug/editions/$editionSlug/sets': typeof AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute '/admin/festivals/$festivalSlug/editions/$editionSlug/stages': typeof AdminFestivalsFestivalSlugEditionsEditionSlugStagesRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule/list': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute @@ -294,10 +286,8 @@ export interface FileRoutesById { '/groups/': typeof GroupsIndexRoute '/admin/artists/duplicates': typeof AdminArtistsDuplicatesRoute '/admin/festivals/$festivalSlug': typeof AdminFestivalsFestivalSlugRouteWithChildren - '/admin/festivals/import': typeof AdminFestivalsImportRoute '/festivals/$festivalSlug/': typeof FestivalsFestivalSlugIndexRoute '/festivals/$festivalSlug/editions/$editionSlug': typeof FestivalsFestivalSlugEditionsEditionSlugRouteWithChildren - '/admin/festivals/$festivalId/$editionId/import': typeof AdminFestivalsFestivalIdEditionIdImportRoute '/admin/festivals/$festivalSlug/editions/$editionSlug': typeof AdminFestivalsFestivalSlugEditionsEditionSlugRouteWithChildren '/festivals/$festivalSlug/editions/$editionSlug/explore': typeof FestivalsFestivalSlugEditionsEditionSlugExploreRoute '/festivals/$festivalSlug/editions/$editionSlug/info': typeof FestivalsFestivalSlugEditionsEditionSlugInfoRoute @@ -305,6 +295,7 @@ export interface FileRoutesById { '/festivals/$festivalSlug/editions/$editionSlug/schedule': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleRouteWithChildren '/festivals/$festivalSlug/editions/$editionSlug/sets': typeof FestivalsFestivalSlugEditionsEditionSlugSetsRouteWithChildren '/festivals/$festivalSlug/editions/$editionSlug/social': typeof FestivalsFestivalSlugEditionsEditionSlugSocialRoute + '/admin/festivals/$festivalSlug/editions/$editionSlug/import': typeof AdminFestivalsFestivalSlugEditionsEditionSlugImportRoute '/admin/festivals/$festivalSlug/editions/$editionSlug/sets': typeof AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute '/admin/festivals/$festivalSlug/editions/$editionSlug/stages': typeof AdminFestivalsFestivalSlugEditionsEditionSlugStagesRoute '/festivals/$festivalSlug/editions/$editionSlug/schedule/list': typeof FestivalsFestivalSlugEditionsEditionSlugScheduleListRoute @@ -329,10 +320,8 @@ export interface FileRouteTypes { | '/groups' | '/admin/artists/duplicates' | '/admin/festivals/$festivalSlug' - | '/admin/festivals/import' | '/festivals/$festivalSlug/' | '/festivals/$festivalSlug/editions/$editionSlug' - | '/admin/festivals/$festivalId/$editionId/import' | '/admin/festivals/$festivalSlug/editions/$editionSlug' | '/festivals/$festivalSlug/editions/$editionSlug/explore' | '/festivals/$festivalSlug/editions/$editionSlug/info' @@ -340,6 +329,7 @@ export interface FileRouteTypes { | '/festivals/$festivalSlug/editions/$editionSlug/schedule' | '/festivals/$festivalSlug/editions/$editionSlug/sets' | '/festivals/$festivalSlug/editions/$editionSlug/social' + | '/admin/festivals/$festivalSlug/editions/$editionSlug/import' | '/admin/festivals/$festivalSlug/editions/$editionSlug/sets' | '/admin/festivals/$festivalSlug/editions/$editionSlug/stages' | '/festivals/$festivalSlug/editions/$editionSlug/schedule/list' @@ -361,16 +351,15 @@ export interface FileRouteTypes { | '/groups' | '/admin/artists/duplicates' | '/admin/festivals/$festivalSlug' - | '/admin/festivals/import' | '/festivals/$festivalSlug' | '/festivals/$festivalSlug/editions/$editionSlug' - | '/admin/festivals/$festivalId/$editionId/import' | '/admin/festivals/$festivalSlug/editions/$editionSlug' | '/festivals/$festivalSlug/editions/$editionSlug/explore' | '/festivals/$festivalSlug/editions/$editionSlug/info' | '/festivals/$festivalSlug/editions/$editionSlug/map' | '/festivals/$festivalSlug/editions/$editionSlug/schedule' | '/festivals/$festivalSlug/editions/$editionSlug/social' + | '/admin/festivals/$festivalSlug/editions/$editionSlug/import' | '/admin/festivals/$festivalSlug/editions/$editionSlug/sets' | '/admin/festivals/$festivalSlug/editions/$editionSlug/stages' | '/festivals/$festivalSlug/editions/$editionSlug/schedule/list' @@ -393,10 +382,8 @@ export interface FileRouteTypes { | '/groups/' | '/admin/artists/duplicates' | '/admin/festivals/$festivalSlug' - | '/admin/festivals/import' | '/festivals/$festivalSlug/' | '/festivals/$festivalSlug/editions/$editionSlug' - | '/admin/festivals/$festivalId/$editionId/import' | '/admin/festivals/$festivalSlug/editions/$editionSlug' | '/festivals/$festivalSlug/editions/$editionSlug/explore' | '/festivals/$festivalSlug/editions/$editionSlug/info' @@ -404,6 +391,7 @@ export interface FileRouteTypes { | '/festivals/$festivalSlug/editions/$editionSlug/schedule' | '/festivals/$festivalSlug/editions/$editionSlug/sets' | '/festivals/$festivalSlug/editions/$editionSlug/social' + | '/admin/festivals/$festivalSlug/editions/$editionSlug/import' | '/admin/festivals/$festivalSlug/editions/$editionSlug/sets' | '/admin/festivals/$festivalSlug/editions/$editionSlug/stages' | '/festivals/$festivalSlug/editions/$editionSlug/schedule/list' @@ -516,13 +504,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FestivalsFestivalSlugIndexRouteImport parentRoute: typeof FestivalsFestivalSlugRoute } - '/admin/festivals/import': { - id: '/admin/festivals/import' - path: '/import' - fullPath: '/admin/festivals/import' - preLoaderRoute: typeof AdminFestivalsImportRouteImport - parentRoute: typeof AdminFestivalsRoute - } '/admin/festivals/$festivalSlug': { id: '/admin/festivals/$festivalSlug' path: '/$festivalSlug' @@ -593,13 +574,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminFestivalsFestivalSlugEditionsEditionSlugRouteImport parentRoute: typeof AdminFestivalsFestivalSlugRoute } - '/admin/festivals/$festivalId/$editionId/import': { - id: '/admin/festivals/$festivalId/$editionId/import' - path: '/$festivalId/$editionId/import' - fullPath: '/admin/festivals/$festivalId/$editionId/import' - preLoaderRoute: typeof AdminFestivalsFestivalIdEditionIdImportRouteImport - parentRoute: typeof AdminFestivalsRoute - } '/festivals/$festivalSlug/editions/$editionSlug/sets/': { id: '/festivals/$festivalSlug/editions/$editionSlug/sets/' path: '/' @@ -642,6 +616,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminFestivalsFestivalSlugEditionsEditionSlugSetsRouteImport parentRoute: typeof AdminFestivalsFestivalSlugEditionsEditionSlugRoute } + '/admin/festivals/$festivalSlug/editions/$editionSlug/import': { + id: '/admin/festivals/$festivalSlug/editions/$editionSlug/import' + path: '/import' + fullPath: '/admin/festivals/$festivalSlug/editions/$editionSlug/import' + preLoaderRoute: typeof AdminFestivalsFestivalSlugEditionsEditionSlugImportRouteImport + parentRoute: typeof AdminFestivalsFestivalSlugEditionsEditionSlugRoute + } } } @@ -658,12 +639,15 @@ const AdminArtistsRouteWithChildren = AdminArtistsRoute._addFileChildren( ) interface AdminFestivalsFestivalSlugEditionsEditionSlugRouteChildren { + AdminFestivalsFestivalSlugEditionsEditionSlugImportRoute: typeof AdminFestivalsFestivalSlugEditionsEditionSlugImportRoute AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute: typeof AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute AdminFestivalsFestivalSlugEditionsEditionSlugStagesRoute: typeof AdminFestivalsFestivalSlugEditionsEditionSlugStagesRoute } const AdminFestivalsFestivalSlugEditionsEditionSlugRouteChildren: AdminFestivalsFestivalSlugEditionsEditionSlugRouteChildren = { + AdminFestivalsFestivalSlugEditionsEditionSlugImportRoute: + AdminFestivalsFestivalSlugEditionsEditionSlugImportRoute, AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute: AdminFestivalsFestivalSlugEditionsEditionSlugSetsRoute, AdminFestivalsFestivalSlugEditionsEditionSlugStagesRoute: @@ -692,15 +676,10 @@ const AdminFestivalsFestivalSlugRouteWithChildren = interface AdminFestivalsRouteChildren { AdminFestivalsFestivalSlugRoute: typeof AdminFestivalsFestivalSlugRouteWithChildren - AdminFestivalsImportRoute: typeof AdminFestivalsImportRoute - AdminFestivalsFestivalIdEditionIdImportRoute: typeof AdminFestivalsFestivalIdEditionIdImportRoute } const AdminFestivalsRouteChildren: AdminFestivalsRouteChildren = { AdminFestivalsFestivalSlugRoute: AdminFestivalsFestivalSlugRouteWithChildren, - AdminFestivalsImportRoute: AdminFestivalsImportRoute, - AdminFestivalsFestivalIdEditionIdImportRoute: - AdminFestivalsFestivalIdEditionIdImportRoute, } const AdminFestivalsRouteWithChildren = AdminFestivalsRoute._addFileChildren( diff --git a/src/routes/admin/festivals/$festivalId.$editionId.import.tsx b/src/routes/admin/festivals/$festivalId.$editionId.import.tsx deleted file mode 100644 index 90715693..00000000 --- a/src/routes/admin/festivals/$festivalId.$editionId.import.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { z } from "zod"; -import { CSVImportPage } from "@/pages/admin/festivals/CSVImportPage"; - -const importSearchSchema = z.object({ - tab: z.enum(["stages", "sets"]).optional(), -}); - -export const Route = createFileRoute( - "/admin/festivals/$festivalId/$editionId/import", -)({ - component: CSVImportPage, - validateSearch: importSearchSchema, -}); diff --git a/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/import.tsx b/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/import.tsx new file mode 100644 index 00000000..8eb9a033 --- /dev/null +++ b/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/import.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { editionsKeys } from "@/hooks/queries/festivals/editions/types"; +import { fetchFestivalEditionBySlug } from "@/hooks/queries/festivals/editions/useFestivalEditionBySlug"; +import { ScheduleImportWizard } from "@/components/Admin/ScheduleImport/ScheduleImportWizard"; + +export const Route = createFileRoute( + "/admin/festivals/$festivalSlug/editions/$editionSlug/import", +)({ + loader: ({ params, context }) => + context.queryClient.ensureQueryData({ + queryKey: editionsKeys.bySlug(params.festivalSlug, params.editionSlug), + queryFn: () => + fetchFestivalEditionBySlug({ + festivalSlug: params.festivalSlug, + editionSlug: params.editionSlug, + }), + }), + component: FestivalScheduleImport, +}); + +function FestivalScheduleImport() { + const edition = Route.useLoaderData(); + return ; +} diff --git a/src/routes/admin/festivals/import.tsx b/src/routes/admin/festivals/import.tsx deleted file mode 100644 index d7071bc0..00000000 --- a/src/routes/admin/festivals/import.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { CSVImportPage } from "@/pages/admin/festivals/CSVImportPage"; -import { z } from "zod"; - -const importSearchSchema = z.object({ - tab: z.enum(["sets", "stages"]).optional(), -}); - -export const Route = createFileRoute("/admin/festivals/import")({ - component: CSVImportPage, - validateSearch: importSearchSchema, -}); diff --git a/src/services/csv/csvParser.ts b/src/services/csv/csvParser.ts deleted file mode 100644 index b3648f39..00000000 --- a/src/services/csv/csvParser.ts +++ /dev/null @@ -1,77 +0,0 @@ -export interface StageImportData { - name: string; -} - -export interface SetImportData { - name?: string; - stage_name: string; - artist_names: string; - time_start?: string; - date_start?: string; - time_end?: string; - date_end?: string; - description?: string; -} - -export function parseCSV(csvContent: string): string[][] { - const lines = csvContent.trim().split("\n"); - return lines.map((line) => { - const result: string[] = []; - let current = ""; - let inQuotes = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - - if (char === '"') { - inQuotes = !inQuotes; - } else if (char === "," && !inQuotes) { - result.push(current.trim()); - current = ""; - } else { - current += char; - } - } - - result.push(current.trim()); - return result.map((field) => field.replace(/^"|"$/g, "")); - }); -} - -export function parseStagesCSV(csvContent: string): StageImportData[] { - const lines = parseCSV(csvContent); - const headers = lines[0] as Array; - - return lines.slice(1).map((line) => { - const stage: Partial = {}; - headers.forEach((header, index) => { - stage[header] = line[index] || ""; - }); - return stage as StageImportData; - }); -} - -export function parseSetsCSV(csvContent: string): SetImportData[] { - const lines = parseCSV(csvContent); - const headers = lines[0]; - - return lines.slice(1).map((line) => { - const set: Partial = {}; - headers.forEach((header, index) => { - const value = line[index] || ""; - if ( - header === "time_start" || - header === "time_end" || - header === "date_start" || - header === "date_end" - ) { - set[header as keyof SetImportData] = value || undefined; - } else if (header === "name") { - set[header as keyof SetImportData] = value || undefined; - } else { - set[header as keyof SetImportData] = value; - } - }); - return set as SetImportData; - }); -} diff --git a/src/services/csv/setDuplicator.ts b/src/services/csv/setDuplicator.ts deleted file mode 100644 index 96300991..00000000 --- a/src/services/csv/setDuplicator.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { supabase } from "@/integrations/supabase/client"; - -export async function duplicateSetWithVotes({ - newTimeEnd, - newTimeStart, - sourceSetId, - description, - stageId, -}: { - sourceSetId: string; - newTimeStart: string; - newTimeEnd: string; - stageId?: string | null; - description?: string | null; -}): Promise { - const params: { - source_set_id: string; - new_time_start: string; - new_time_end: string; - new_stage_id?: string | null; - new_description?: string | null; - } = { - source_set_id: sourceSetId, - new_time_start: newTimeStart, - new_time_end: newTimeEnd, - }; - - if (stageId !== undefined) { - params.new_stage_id = stageId; - } - - if (description !== undefined) { - params.new_description = description; - } - - const { data, error } = await supabase.rpc( - "duplicate_set_with_votes", - params, - ); - - if (error) { - throw new Error(`Failed to duplicate set: ${error.message}`); - } - - if (!data) { - throw new Error("No set ID returned from duplication"); - } - - return data as string; -} diff --git a/src/services/csv/setImporter.ts b/src/services/csv/setImporter.ts deleted file mode 100644 index 8f1d44b3..00000000 --- a/src/services/csv/setImporter.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { supabase } from "@/integrations/supabase/client"; -import { generateSlug } from "@/lib/slug"; -import { convertLocalTimeToUTC, combineDateAndTime } from "@/lib/timeUtils"; -import type { SetImportData } from "./csvParser"; -import type { ImportResult } from "./types"; -import type { SetSelection } from "@/pages/admin/festivals/CSVImportDialog/SetsPreviewTable"; -import { duplicateSetWithVotes } from "./setDuplicator"; - -function generateSetNameFromArtists(artistNames: string[]): string { - if (artistNames.length === 0) return "Unnamed Set"; - if (artistNames.length === 1) return artistNames[0]; - if (artistNames.length === 2) return `${artistNames[0]} & ${artistNames[1]}`; - return `${artistNames[0]} & ${artistNames.length - 1} others`; -} - -export interface ArtistMapping { - csvName: string; - artistId: string | null; - shouldCreate: boolean; -} - -async function importSetsWithArtistMap({ - artistMappings, - editionId, - sets, - timezone = "UTC", - onProgress, - setSelections, -}: { - sets: SetImportData[]; - editionId: string; - artistMappings: Map; - setSelections?: Map; - timezone?: string; - onProgress?: (completed: number, total: number) => void; -}): Promise { - const currentUser = await supabase.auth.getUser(); - const userId = currentUser.data.user?.id || ""; - - const results: Array = []; - const errors: Array = []; - const total = sets.length; - - for (let i = 0; i < sets.length; i++) { - const set = sets[i]; - const setMappings = artistMappings.get(i); - const setSelection = setSelections?.get(i); - - const response = await importSingleSet({ - importedSet: set, - setMappings, - setSelection, - editionId, - timezone, - userId, - }); - - if (response.type === "error") { - errors.push(...response.errors); - continue; - } else { - results.push(response.setName); - } - - onProgress?.(i + 1, total); - } - - if (errors.length > 0 && results.length === 0) { - return { - success: false, - message: "Failed to import sets", - errors, - }; - } - - return { - success: true, - message: `Successfully imported ${results.length} sets${errors.length > 0 ? ` (${errors.length} errors)` : ""}`, - inserted: results.length, - errors: errors.length > 0 ? errors : undefined, - }; -} - -async function importSingleSet({ - importedSet, - setMappings, - userId, - timezone, - editionId, - setSelection, -}: { - timezone: string; - userId: string; - importedSet: SetImportData; - setMappings: ArtistMapping[] | undefined; - editionId: string; - setSelection: SetSelection | undefined; -}): Promise< - | { - type: "error"; - errors: string[]; - } - | { - type: "success"; - setName: string; - } -> { - const errors: string[] = []; - try { - if (!setMappings || setMappings.length === 0) { - errors.push( - `Set "${importedSet.name || "Unnamed"}" has no artist mappings`, - ); - return { type: "error", errors }; - } - - const artistNames = setMappings.map((m) => m.csvName); - const setName = importedSet.name || generateSetNameFromArtists(artistNames); - - const artistIds: string[] = []; - - for (const mapping of setMappings) { - let artistId = mapping.artistId; - - if (!artistId && mapping.shouldCreate) { - const { data: newArtist, error: createError } = await supabase - .from("artists") - .insert({ - name: mapping.csvName, - slug: generateSlug(mapping.csvName), - added_by: userId, - }) - .select("id") - .single(); - - if (createError || !newArtist) { - errors.push( - `Failed to create artist "${mapping.csvName}": ${createError?.message || "No ID"}`, - ); - continue; - } - - artistId = newArtist.id; - } - - if (!artistId) { - errors.push(`Artist "${mapping.csvName}" could not be resolved`); - continue; - } - - artistIds.push(artistId); - } - - if (artistIds.length === 0) { - errors.push( - `Set "${importedSet.name || "Unnamed"}" has no valid artists`, - ); - return { type: "error", errors }; - } - - // Continue with set creation logic (same as original) - - let stageId = ""; - if (importedSet.stage_name) { - const { data: stage, error: stageError } = await supabase - .from("stages") - .select("id") - .eq("name", importedSet.stage_name) - .eq("festival_edition_id", editionId) - .single(); - - if (stageError || !stage) { - errors.push( - `Stage "${importedSet.stage_name}" not found for set "${setName}"`, - ); - return { type: "error", errors }; - } - - stageId = stage.id; - } - - const timeStartInput = - importedSet.date_start && importedSet.time_start - ? combineDateAndTime(importedSet.date_start, importedSet.time_start) - : importedSet.time_start; - const timeEndInput = - importedSet.date_end && importedSet.time_end - ? combineDateAndTime(importedSet.date_end, importedSet.time_end) - : importedSet.time_end; - - if (!timeStartInput) { - errors.push("Missing time start"); - return { type: "error", errors }; - } - - if (!timeEndInput) { - errors.push("Missing time end"); - return { type: "error", errors }; - } - - const utcTimeStart = convertLocalTimeToUTC(timeStartInput, timezone); - const utcTimeEnd = convertLocalTimeToUTC(timeEndInput, timezone); - - if (!utcTimeEnd || !utcTimeStart) { - errors.push("Time is not valid"); - return { type: "error", errors }; - } - - let createdSetId = ""; - let setError: Error | null = null; - - if (setSelection?.action === "match" && setSelection.matchedSetId) { - createdSetId = setSelection.matchedSetId; - const { error } = await supabase - .from("sets") - .update({ - stage_id: stageId || null, - time_start: utcTimeStart, - time_end: utcTimeEnd, - description: importedSet.description || null, - archived: false, - }) - .eq("id", createdSetId); - - setError = error; - } else if ( - setSelection?.action === "duplicate" && - setSelection.matchedSetId - ) { - try { - createdSetId = await duplicateSetWithVotes({ - sourceSetId: setSelection.matchedSetId, - newTimeStart: utcTimeStart!, - newTimeEnd: utcTimeEnd!, - stageId: stageId, - description: importedSet.description, - }); - } catch (error) { - setError = error as Error; - } - } else { - const { data, error } = await supabase - .from("sets") - .insert({ - name: setName, - slug: generateSlug(setName), - stage_id: stageId || null, - festival_edition_id: editionId, - time_start: utcTimeStart, - time_end: utcTimeEnd, - description: importedSet.description || null, - archived: false, - created_by: userId, - }) - .select("id") - .single(); - - createdSetId = data?.id || ""; - setError = error; - } - - if (setError || !createdSetId) { - errors.push( - `Failed to create set "${setName}": ${setError?.message || "No ID"}`, - ); - return { type: "error", errors }; - } - - // Link artists to set - for (const artistId of artistIds) { - await supabase.from("set_artists").upsert( - { - set_id: createdSetId, - artist_id: artistId, - }, - { - onConflict: "set_id,artist_id", - ignoreDuplicates: true, - }, - ); - } - - return { type: "success", setName }; - } catch (error) { - errors.push( - `Error processing set: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - - return { errors, type: "error" }; - } -} - -export async function importSets( - sets: SetImportData[], - editionId: string, - timezone: string = "UTC", - onProgress?: (completed: number, total: number) => void, -): Promise { - const artistMappings = new Map(); - - sets.forEach((set, index) => { - const artistNames = set.artist_names - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0); - - artistMappings.set( - index, - artistNames.map((csvName) => ({ - csvName, - artistId: null, - shouldCreate: true, - })), - ); - }); - - return importSetsWithArtistMap({ - sets, - editionId, - artistMappings: artistMappings, - timezone, - onProgress, - }); -} - -export async function importSetsWithMappings( - sets: SetImportData[], - editionId: string, - artistMappings: Map, - setSelections?: Map, - timezone: string = "UTC", - onProgress?: (completed: number, total: number) => void, -): Promise { - return importSetsWithArtistMap({ - sets, - editionId, - artistMappings, - setSelections, - timezone, - onProgress, - }); -} diff --git a/src/services/csv/setMatcher.ts b/src/services/csv/setMatcher.ts deleted file mode 100644 index fb8ca253..00000000 --- a/src/services/csv/setMatcher.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { supabase } from "@/integrations/supabase/client"; -import type { SetImportData } from "./csvParser"; - -export interface MatchingSet { - id: string; - name: string; - stage_name: string | null; - artist_names: string[]; - vote_count: number; - time_start: string | null; -} - -export async function findMatchingSets({ - existingSets, - importedSets, -}: { - importedSets: SetImportData[]; - existingSets: { - id: string; - name: string; - time_start: string | null; - set_artists?: { artists: { name: string } }[]; - stages?: { name: string } | null; - }[]; -}): Promise> { - const matchMap = new Map(); - - for (let index = 0; index < importedSets.length; index++) { - const set = importedSets[index]; - const artistNames = set.artist_names - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0); - - if (artistNames.length === 0) { - matchMap.set(index, []); - continue; - } - - if (!existingSets || existingSets.length === 0) { - matchMap.set(index, []); - continue; - } - - const matches: MatchingSet[] = []; - - for (const existingSet of existingSets) { - if (!existingSet.set_artists || existingSet.set_artists.length === 0) { - continue; - } - - const setArtistNames = existingSet.set_artists - .map( - (sa: { artists: { name: string } | null } | null) => - sa?.artists?.name, - ) - .filter((name): name is string => name !== null && name !== undefined); - - function normalizeArtistName(name: string) { - return name - .toLowerCase() - .trim() - .replace(/[.,;!?]+$/, ""); - } - - const csvArtistNamesLower = artistNames.map(normalizeArtistName); - const setArtistNamesLower = setArtistNames.map(normalizeArtistName); - - csvArtistNamesLower.sort(); - setArtistNamesLower.sort(); - - const artistsMatch = - setArtistNamesLower.length === csvArtistNamesLower.length && - setArtistNamesLower.every( - (name: string, idx: number) => name === csvArtistNamesLower[idx], - ); - - if (artistsMatch) { - const { count: voteCount } = await supabase - .from("votes") - .select("*", { count: "exact", head: true }) - .eq("set_id", existingSet.id); - - matches.push({ - id: existingSet.id, - name: existingSet.name, - stage_name: existingSet.stages?.name || null, - artist_names: setArtistNames, - vote_count: voteCount || 0, - time_start: existingSet.time_start, - }); - } - } - - matchMap.set(index, matches); - } - - return matchMap; -} diff --git a/src/services/csv/setSelectionValidator.ts b/src/services/csv/setSelectionValidator.ts deleted file mode 100644 index 064210a0..00000000 --- a/src/services/csv/setSelectionValidator.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { SetSelection } from "@/pages/admin/festivals/CSVImportDialog/SetsPreviewTable"; - -export interface SetSelectionValidationError { - rowIndices: number[]; - setId: string; - message: string; -} - -export function validateSetSelections( - selections: Map, -): SetSelectionValidationError[] { - const errors: SetSelectionValidationError[] = []; - const matchedSetIds = new Map(); - - selections.forEach((selection, rowIndex) => { - if (selection.action === "match" && selection.matchedSetId) { - const setId = selection.matchedSetId; - if (!matchedSetIds.has(setId)) { - matchedSetIds.set(setId, []); - } - matchedSetIds.get(setId)!.push(rowIndex); - } - }); - - matchedSetIds.forEach((rowIndices, setId) => { - if (rowIndices.length > 1) { - errors.push({ - rowIndices, - setId, - message: `Set is matched by multiple rows (${rowIndices.map((i) => i + 1).join(", ")}). Only one row can match an existing set. Use "Duplicate" or "Create new" for the others.`, - }); - } - }); - - return errors; -} diff --git a/src/services/csv/stageImporter.ts b/src/services/csv/stageImporter.ts deleted file mode 100644 index 146de6ea..00000000 --- a/src/services/csv/stageImporter.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { supabase } from "@/integrations/supabase/client"; -import { generateSlug } from "@/lib/slug"; -import type { StageImportData } from "./csvParser"; -import type { ImportResult } from "./types"; - -export async function importStages( - stages: StageImportData[], - editionId: string, - onProgress?: (completed: number, total: number) => void, -): Promise { - try { - const stageInserts = stages.map((stage) => ({ - name: stage.name, - slug: generateSlug(stage.name), - festival_edition_id: editionId, - archived: false, - })); - - const { data, error } = await supabase - .from("stages") - .upsert(stageInserts, { - onConflict: "name,festival_edition_id", - ignoreDuplicates: false, - }) - .select(); - - if (error) { - return { - success: false, - message: `Failed to import stages: ${error.message}`, - errors: [error.message], - }; - } - - // Report completion - onProgress?.(stages.length, stages.length); - - return { - success: true, - message: `Successfully imported ${data?.length || 0} stages`, - inserted: data?.length || 0, - }; - } catch (error) { - return { - success: false, - message: `Import failed: ${error instanceof Error ? error.message : "Unknown error"}`, - errors: [error instanceof Error ? error.message : "Unknown error"], - }; - } -} diff --git a/src/services/csv/timeValidator.ts b/src/services/csv/timeValidator.ts deleted file mode 100644 index 3c24733d..00000000 --- a/src/services/csv/timeValidator.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { convertLocalTimeToUTC, combineDateAndTime } from "@/lib/timeUtils"; - -export interface TimeValidationResult { - isValid: boolean; - error?: string; -} - -export function validateTimeString( - timeString: string | undefined, - dateString: string | undefined, - timezone: string, -): TimeValidationResult { - if (dateString && timeString) { - const combined = combineDateAndTime(dateString, timeString); - if (!combined) { - return { - isValid: false, - error: "Failed to combine date and time", - }; - } - - try { - const result = convertLocalTimeToUTC(combined, timezone); - if (result === null) { - return { - isValid: false, - error: "Invalid date/time format", - }; - } - return { isValid: true }; - } catch (error) { - return { - isValid: false, - error: error instanceof Error ? error.message : "Invalid format", - }; - } - } - - if (!timeString) { - return { isValid: true }; - } - - try { - const result = convertLocalTimeToUTC(timeString, timezone); - if (result === null) { - return { - isValid: false, - error: "Invalid date/time format", - }; - } - return { isValid: true }; - } catch (error) { - return { - isValid: false, - error: error instanceof Error ? error.message : "Invalid format", - }; - } -} - -export interface SetValidationResult { - isValid: boolean; - rowIndex: number; - errors: { - time_start?: string; - time_end?: string; - stage_name?: string; - artist_names?: string; - }; -} - -export function validateSetData( - set: { - stage_name: string; - artist_names: string; - time_start?: string; - date_start?: string; - time_end?: string; - date_end?: string; - }, - rowIndex: number, - timezone: string, -): SetValidationResult { - const errors: SetValidationResult["errors"] = {}; - - if (!set.stage_name || set.stage_name.trim() === "") { - errors.stage_name = "Stage name is required"; - } - - if (!set.artist_names || set.artist_names.trim() === "") { - errors.artist_names = "Artist name(s) required"; - } - - const timeStartValidation = validateTimeString( - set.time_start, - set.date_start, - timezone, - ); - if (!timeStartValidation.isValid) { - errors.time_start = timeStartValidation.error; - } - - const timeEndValidation = validateTimeString( - set.time_end, - set.date_end, - timezone, - ); - if (!timeEndValidation.isValid) { - errors.time_end = timeEndValidation.error; - } - - return { - isValid: Object.keys(errors).length === 0, - rowIndex, - errors, - }; -} diff --git a/src/services/csv/types.ts b/src/services/csv/types.ts deleted file mode 100644 index 00db3b70..00000000 --- a/src/services/csv/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface ImportResult { - success: boolean; - message: string; - inserted?: number; - updated?: number; - errors?: string[]; -} diff --git a/src/services/scheduleImport/api.ts b/src/services/scheduleImport/api.ts new file mode 100644 index 00000000..0b6d5ba5 --- /dev/null +++ b/src/services/scheduleImport/api.ts @@ -0,0 +1,66 @@ +import { FunctionsHttpError } from "@supabase/supabase-js"; +import { z } from "zod"; +import { supabase } from "@/integrations/supabase/client"; +import { buildCommitPayload } from "./buildCommitPayload"; +import { + commitResultSchema, + diffResultSchema, + type CommitResult, + type CsvRow, + type DiffResult, +} from "./types"; + +export async function callDiffSchedule( + festivalEditionId: string, + timezone: string, + rows: CsvRow[], +): Promise { + return invokeEdgeFunction( + "diff-schedule", + { festivalEditionId, timezone, rows }, + diffResultSchema, + ); +} + +export async function callCommitSchedule( + festivalEditionId: string, + payload: ReturnType, +): Promise { + return invokeEdgeFunction( + "commit-schedule", + { festivalEditionId, ...payload }, + commitResultSchema, + ); +} + +async function invokeEdgeFunction( + name: string, + body: Record, + schema: z.ZodType, +): Promise { + const { data, error } = await supabase.functions.invoke(name, { body }); + if (error) throw await edgeFunctionError(error); + if (data?.error) throw new Error(data.error); + return schema.parse(data); +} + +// On a non-2xx response supabase-js only exposes a generic message, so read +// the function's JSON error body (validation issues / RPC exception) to give +// the UI something actionable. +async function edgeFunctionError(error: unknown): Promise { + if (error instanceof FunctionsHttpError) { + try { + const body = await error.context.json(); + if (body?.error) { + return new Error( + body.issues + ? `${body.error}: ${JSON.stringify(body.issues)}` + : body.error, + ); + } + } catch { + // no JSON body — fall through to the generic message + } + } + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/src/services/scheduleImport/buildCommitPayload.test.ts b/src/services/scheduleImport/buildCommitPayload.test.ts new file mode 100644 index 00000000..1290480d --- /dev/null +++ b/src/services/scheduleImport/buildCommitPayload.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest"; +import { buildCommitPayload } from "./buildCommitPayload"; +import { type DiffResult } from "./types"; + +describe("buildCommitPayload", () => { + it("passes through clean artistsToCreate/stagesToCreate untouched", () => { + const diff = makeDiff({ + cleanOperations: { + artistsToCreate: [{ name: "Carl Cox", slug: "carl-cox" }], + stagesToCreate: [{ name: "Secret Forest" }], + setsToCreate: [], + setsToUpdate: [], + }, + }); + + const payload = buildCommitPayload(diff, {}, {}); + expect(payload.artistsToCreate).toEqual([ + { name: "Carl Cox", slug: "carl-cox" }, + ]); + expect(payload.stagesToCreate).toEqual([{ name: "Secret Forest" }]); + }); + + it("appends a stage to create when a mismatch is resolved as 'create'", () => { + const diff = makeDiff({ + conflicts: { + stageNameMismatches: [ + { + csvValue: "Mainstage", + closestDbValue: "Main Stage", + dbStageId: "stage-1", + }, + ], + orphanedSets: [], + }, + }); + + const payload = buildCommitPayload( + diff, + { Mainstage: { action: "create" } }, + {}, + ); + expect(payload.stagesToCreate).toEqual([{ name: "Mainstage" }]); + }); + + it("remaps set stageName when mismatch is resolved as 'map'", () => { + const diff = makeDiff({ + cleanOperations: { + artistsToCreate: [], + stagesToCreate: [], + setsToCreate: [ + { + name: "Carl Cox", + description: null, + stageName: "Mainstage", + timeStart: null, + timeEnd: null, + artistSlugs: ["carl-cox"], + }, + ], + setsToUpdate: [], + }, + conflicts: { + stageNameMismatches: [ + { + csvValue: "Mainstage", + closestDbValue: "Main Stage", + dbStageId: "stage-1", + }, + ], + orphanedSets: [], + }, + }); + + const payload = buildCommitPayload( + diff, + { Mainstage: { action: "map", dbStageName: "Main Stage" } }, + {}, + ); + expect(payload.setsToCreate[0].stageName).toBe("Main Stage"); + expect(payload.stagesToCreate).toEqual([]); + }); + + it("keeps non-mismatched stage names as-is", () => { + const diff = makeDiff({ + cleanOperations: { + artistsToCreate: [], + stagesToCreate: [], + setsToCreate: [ + { + name: "Carl Cox", + description: null, + stageName: "Main Stage", + timeStart: null, + timeEnd: null, + artistSlugs: ["carl-cox"], + }, + ], + setsToUpdate: [], + }, + }); + + const payload = buildCommitPayload(diff, {}, {}); + expect(payload.setsToCreate[0].stageName).toBe("Main Stage"); + }); + + it("filters orphan archive ids based on resolutions", () => { + const diff = makeDiff({ + conflicts: { + stageNameMismatches: [], + orphanedSets: [ + { id: "set-a", name: "A", stage: null, timeStart: null }, + { id: "set-b", name: "B", stage: null, timeStart: null }, + { id: "set-c", name: "C", stage: null, timeStart: null }, + ], + }, + }); + + const payload = buildCommitPayload( + diff, + {}, + { "set-a": "archive", "set-b": "keep" }, + ); + // set-a marked archive, set-b marked keep, set-c defaults to keep + expect(payload.setIdsToArchive).toEqual(["set-a"]); + }); +}); + +function makeDiff(overrides: Partial = {}): DiffResult { + return { + summary: { + newArtists: 0, + newStages: 0, + setsMatched: 0, + setsToCreate: 0, + setsOrphaned: 0, + }, + newArtistNames: [], + cleanOperations: { + artistsToCreate: [], + stagesToCreate: [], + setsToCreate: [], + setsToUpdate: [], + }, + conflicts: { stageNameMismatches: [], orphanedSets: [] }, + ...overrides, + }; +} diff --git a/src/services/scheduleImport/buildCommitPayload.ts b/src/services/scheduleImport/buildCommitPayload.ts new file mode 100644 index 00000000..fee6fbbd --- /dev/null +++ b/src/services/scheduleImport/buildCommitPayload.ts @@ -0,0 +1,59 @@ +import { + type DiffResult, + type OrphanResolution, + type SetPayload, + type StageMismatchResolution, +} from "./types"; + +export function buildCommitPayload( + diff: DiffResult, + stageMismatchResolutions: Record, + orphanResolutions: Record, +): { + artistsToCreate: { name: string; slug: string }[]; + stagesToCreate: { name: string }[]; + setsToCreate: SetPayload[]; + setsToUpdate: ({ id: string } & SetPayload)[]; + setIdsToArchive: string[]; +} { + const mismatchedCsvValues = new Set( + diff.conflicts.stageNameMismatches.map((m) => m.csvValue), + ); + + const extraStagesToCreate: { name: string }[] = []; + for (const mismatch of diff.conflicts.stageNameMismatches) { + const resolution = stageMismatchResolutions[mismatch.csvValue]; + if (resolution?.action === "create") { + extraStagesToCreate.push({ name: mismatch.csvValue }); + } + } + + const setIdsToArchive = diff.conflicts.orphanedSets + .filter((s) => (orphanResolutions[s.id] ?? "keep") === "archive") + .map((s) => s.id); + + return { + artistsToCreate: diff.cleanOperations.artistsToCreate, + stagesToCreate: [ + ...diff.cleanOperations.stagesToCreate, + ...extraStagesToCreate, + ], + setsToCreate: diff.cleanOperations.setsToCreate.map((s) => ({ + ...s, + stageName: resolveSetStageName(s.stageName), + })), + setsToUpdate: diff.cleanOperations.setsToUpdate.map((s) => ({ + ...s, + stageName: resolveSetStageName(s.stageName), + })), + setIdsToArchive, + }; + + function resolveSetStageName(stageName: string | null): string | null { + if (!stageName) return null; + if (!mismatchedCsvValues.has(stageName)) return stageName; + const resolution = stageMismatchResolutions[stageName]; + if (!resolution) return stageName; + return resolution.action === "map" ? resolution.dbStageName : stageName; + } +} diff --git a/src/services/scheduleImport/parseCsv.test.ts b/src/services/scheduleImport/parseCsv.test.ts new file mode 100644 index 00000000..e93d8aa5 --- /dev/null +++ b/src/services/scheduleImport/parseCsv.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { parseScheduleCsv } from "./parseCsv"; + +describe("parseScheduleCsv", () => { + it("parses a full row with every column", () => { + const csv = [ + "Artists,Set Name,Stage,Date,Start Time,End Time,Description", + "Carl Cox,Carl Cox Live,Main Stage,2026-07-11,22:00,00:00,House set", + ].join("\n"); + + expect(parseScheduleCsv(csv)).toEqual([ + { + artists: ["Carl Cox"], + setName: "Carl Cox Live", + stage: "Main Stage", + date: "2026-07-11", + startTime: "22:00", + endTime: "00:00", + description: "House set", + }, + ]); + }); + + it("splits pipe-separated artists for B2B sets", () => { + const csv = ["Artists,Stage", "Carl Cox | Peggy Gou,Main"].join("\n"); + expect(parseScheduleCsv(csv)[0].artists).toEqual(["Carl Cox", "Peggy Gou"]); + }); + + it("omits optional columns when not present in the header", () => { + const csv = ["Artists,Date", "DJ Tennis,2026-07-12"].join("\n"); + expect(parseScheduleCsv(csv)).toEqual([ + { + artists: ["DJ Tennis"], + setName: undefined, + stage: undefined, + date: "2026-07-12", + startTime: undefined, + endTime: undefined, + description: undefined, + }, + ]); + }); + + it("skips rows with no artists", () => { + const csv = ["Artists,Stage", "Carl Cox,Main", ",Side", "Peggy Gou,"].join( + "\n", + ); + const rows = parseScheduleCsv(csv); + expect(rows.map((r) => r.artists)).toEqual([["Carl Cox"], ["Peggy Gou"]]); + }); + + it("is case-insensitive about header names", () => { + const csv = ["ARTISTS,STAGE", "Carl Cox,Main"].join("\n"); + expect(parseScheduleCsv(csv)[0].stage).toBe("Main"); + }); + + it("throws when the CSV has unmatched quotes", () => { + const csv = ["Artists,Stage", '"Carl Cox,Main'].join("\n"); + expect(() => parseScheduleCsv(csv)).toThrow(/Could not parse CSV/); + }); + + it("de-duplicates repeated artists within a row (case-insensitive)", () => { + const csv = ["Artists,Stage", "Carl Cox | carl cox | Peggy Gou,Main"].join( + "\n", + ); + expect(parseScheduleCsv(csv)[0].artists).toEqual(["Carl Cox", "Peggy Gou"]); + }); + + it("throws when an artist name has no letters or digits", () => { + const csv = ["Artists,Stage", "!!!,Main"].join("\n"); + expect(() => parseScheduleCsv(csv)).toThrow(/no letters or digits/); + }); + + it("throws when a stage name has no letters or digits", () => { + const csv = ["Artists,Stage", "Carl Cox,---"].join("\n"); + expect(() => parseScheduleCsv(csv)).toThrow(/no letters or digits/); + }); +}); diff --git a/src/services/scheduleImport/parseCsv.ts b/src/services/scheduleImport/parseCsv.ts new file mode 100644 index 00000000..8886548e --- /dev/null +++ b/src/services/scheduleImport/parseCsv.ts @@ -0,0 +1,73 @@ +import Papa from "papaparse"; +import { type CsvRow } from "./types"; + +export function parseScheduleCsv(csvContent: string): CsvRow[] { + const parsed = Papa.parse>(csvContent, { + header: true, + skipEmptyLines: true, + transformHeader: (h) => h.trim().toLowerCase(), + }); + + if (parsed.errors.length > 0) { + const first = parsed.errors[0]; + const where = first.row != null ? ` (row ${first.row + 1})` : ""; + throw new Error(`Could not parse CSV${where}: ${first.message}`); + } + + const rows = parsed.data + .map((row) => { + const artists = dedupeArtists( + (row.artists ?? "") + .split("|") + .map((a) => a.trim()) + .filter(Boolean), + ); + + return { + artists, + setName: row["set name"]?.trim() || undefined, + stage: row.stage?.trim() || undefined, + date: row.date?.trim() || undefined, + startTime: row["start time"]?.trim() || undefined, + endTime: row["end time"]?.trim() || undefined, + description: row.description?.trim() || undefined, + }; + }) + .filter((row) => row.artists.length > 0); + + for (const row of rows) { + for (const artist of row.artists) { + if (!hasSluggableChars(artist)) { + throw new Error( + `Artist name "${artist}" has no letters or digits and can't be imported.`, + ); + } + } + if (row.stage && !hasSluggableChars(row.stage)) { + throw new Error( + `Stage name "${row.stage}" has no letters or digits and can't be imported.`, + ); + } + } + + return rows; +} + +// A name with no [a-z0-9] slugifies to an empty string, which downstream +// breaks slug-based lookups and the slug unique constraints. Reject it here +// with a clear message instead of failing opaquely at commit time. +function hasSluggableChars(value: string): boolean { + return /[a-z0-9]/i.test(value); +} + +// A B2B cell like "Carl Cox | Carl Cox" must not list the same artist twice: +// duplicates change the diff's roster key and send duplicate slugs downstream. +function dedupeArtists(names: string[]): string[] { + const seen = new Set(); + return names.filter((name) => { + const key = name.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/services/scheduleImport/types.ts b/src/services/scheduleImport/types.ts new file mode 100644 index 00000000..77ac10d0 --- /dev/null +++ b/src/services/scheduleImport/types.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +export type CsvRow = { + artists: string[]; + setName?: string; + stage?: string; + date?: string; + startTime?: string; + endTime?: string; + description?: string; +}; + +export const setPayloadSchema = z.object({ + name: z.string(), + description: z.string().nullable(), + stageName: z.string().nullable(), + timeStart: z.string().nullable(), + timeEnd: z.string().nullable(), + artistSlugs: z.array(z.string()), +}); +export type SetPayload = z.infer; + +export const diffResultSchema = z.object({ + summary: z.object({ + newArtists: z.number(), + newStages: z.number(), + setsMatched: z.number(), + setsToCreate: z.number(), + setsOrphaned: z.number(), + }), + newArtistNames: z.array(z.string()), + cleanOperations: z.object({ + artistsToCreate: z.array(z.object({ name: z.string(), slug: z.string() })), + stagesToCreate: z.array(z.object({ name: z.string() })), + setsToCreate: z.array(setPayloadSchema), + setsToUpdate: z.array(setPayloadSchema.extend({ id: z.string() })), + }), + conflicts: z.object({ + stageNameMismatches: z.array( + z.object({ + csvValue: z.string(), + closestDbValue: z.string(), + dbStageId: z.string(), + }), + ), + orphanedSets: z.array( + z.object({ + id: z.string(), + name: z.string(), + stage: z.string().nullable(), + timeStart: z.string().nullable(), + }), + ), + }), +}); +export type DiffResult = z.infer; + +export const commitResultSchema = z.object({ + setsCreated: z.number(), + setsUpdated: z.number(), + setsArchived: z.number(), +}); +export type CommitResult = z.infer; + +export type StageMismatchResolution = + | { action: "map"; dbStageName: string } + | { action: "create" }; + +export type OrphanResolution = "archive" | "keep"; diff --git a/src/test/setup.ts b/src/test/setup.ts index 0c6b74ba..7d9033dc 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,8 +1,19 @@ import "@testing-library/jest-dom/vitest"; +import { vi } from "vitest"; + +// Stub the Supabase env vars so the client module can initialise even when +// VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_KEY aren't set in the test +// environment. Tests that exercise data fetching mock the relevant query +// hooks; the client itself never actually issues a request. +vi.stubEnv("VITE_SUPABASE_URL", "http://localhost:54321"); +vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_KEY", "test-anon-key"); // Polyfill for ArrayBuffer.prototype.resizable and SharedArrayBuffer.prototype.growable // These are needed by webidl-conversions package -if (typeof ArrayBuffer !== "undefined" && !Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "resizable")) { +if ( + typeof ArrayBuffer !== "undefined" && + !Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "resizable") +) { Object.defineProperty(ArrayBuffer.prototype, "resizable", { get() { return false; @@ -11,7 +22,10 @@ if (typeof ArrayBuffer !== "undefined" && !Object.getOwnPropertyDescriptor(Array }); } -if (typeof SharedArrayBuffer !== "undefined" && !Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, "growable")) { +if ( + typeof SharedArrayBuffer !== "undefined" && + !Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, "growable") +) { Object.defineProperty(SharedArrayBuffer.prototype, "growable", { get() { return false; diff --git a/supabase/functions/_shared/auth.ts b/supabase/functions/_shared/auth.ts new file mode 100644 index 00000000..00609120 --- /dev/null +++ b/supabase/functions/_shared/auth.ts @@ -0,0 +1,83 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +export const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +export function getAdminClient() { + return createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "", + ); +} + +type AuthResult = + | { userId: string; errorResponse: null } + | { userId: null; errorResponse: { status: number; body: string } }; + +export async function requireAdmin(req: Request): Promise { + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return { + userId: null, + errorResponse: { + status: 401, + body: JSON.stringify({ error: "Unauthorized" }), + }, + }; + } + + const userClient = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_ANON_KEY") ?? "", + { global: { headers: { Authorization: authHeader } } }, + ); + + const { + data: { user }, + error: userError, + } = await userClient.auth.getUser(); + if (userError || !user) { + return { + userId: null, + errorResponse: { + status: 401, + body: JSON.stringify({ error: "Unauthorized" }), + }, + }; + } + + const adminClient = getAdminClient(); + const { data: adminRole, error: adminRoleError } = await adminClient + .from("admin_roles") + .select("role") + .eq("user_id", user.id) + .in("role", ["admin", "super_admin"]) + .maybeSingle(); + + if (adminRoleError) { + console.error("requireAdmin: admin_roles lookup failed:", adminRoleError); + return { + userId: null, + errorResponse: { + status: 500, + body: JSON.stringify({ error: "Failed to verify admin role" }), + }, + }; + } + + if (!adminRole) { + return { + userId: null, + errorResponse: { + status: 403, + body: JSON.stringify({ error: "Forbidden" }), + }, + }; + } + + return { userId: user.id, errorResponse: null }; +} diff --git a/supabase/functions/_shared/database.types.ts b/supabase/functions/_shared/database.types.ts new file mode 100644 index 00000000..b70e1952 --- /dev/null +++ b/supabase/functions/_shared/database.types.ts @@ -0,0 +1,973 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type Database = { + graphql_public: { + Tables: { + [_ in never]: never; + }; + Views: { + [_ in never]: never; + }; + Functions: { + graphql: { + Args: { + extensions?: Json; + operationName?: string; + query?: string; + variables?: Json; + }; + Returns: Json; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; + public: { + Tables: { + admin_roles: { + Row: { + created_at: string; + created_by: string; + id: string; + role: Database["public"]["Enums"]["admin_role"]; + user_id: string; + }; + Insert: { + created_at?: string; + created_by: string; + id?: string; + role: Database["public"]["Enums"]["admin_role"]; + user_id: string; + }; + Update: { + created_at?: string; + created_by?: string; + id?: string; + role?: Database["public"]["Enums"]["admin_role"]; + user_id?: string; + }; + Relationships: []; + }; + artist_knowledge: { + Row: { + artist_id: string; + created_at: string; + id: string; + user_id: string; + }; + Insert: { + artist_id: string; + created_at?: string; + id?: string; + user_id: string; + }; + Update: { + artist_id?: string; + created_at?: string; + id?: string; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: "artist_knowledge_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "artists"; + referencedColumns: ["id"]; + }, + ]; + }; + artist_music_genres: { + Row: { + artist_id: string; + created_at: string; + id: string; + music_genre_id: string; + }; + Insert: { + artist_id: string; + created_at?: string; + id?: string; + music_genre_id: string; + }; + Update: { + artist_id?: string; + created_at?: string; + id?: string; + music_genre_id?: string; + }; + Relationships: [ + { + foreignKeyName: "artist_music_genres_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "artists"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "artist_music_genres_music_genre_id_fkey"; + columns: ["music_genre_id"]; + isOneToOne: false; + referencedRelation: "music_genres"; + referencedColumns: ["id"]; + }, + ]; + }; + artist_notes: { + Row: { + artist_id: string; + created_at: string; + id: string; + note_content: string; + updated_at: string; + user_id: string; + }; + Insert: { + artist_id: string; + created_at?: string; + id?: string; + note_content: string; + updated_at?: string; + user_id: string; + }; + Update: { + artist_id?: string; + created_at?: string; + id?: string; + note_content?: string; + updated_at?: string; + user_id?: string; + }; + Relationships: []; + }; + artists: { + Row: { + added_by: string; + archived: boolean; + created_at: string; + description: string | null; + estimated_date: string | null; + id: string; + image_url: string | null; + name: string; + slug: string; + soundcloud_url: string | null; + spotify_url: string | null; + stage: string | null; + time_end: string | null; + time_start: string | null; + updated_at: string; + }; + Insert: { + added_by: string; + archived?: boolean; + created_at?: string; + description?: string | null; + estimated_date?: string | null; + id?: string; + image_url?: string | null; + name: string; + slug: string; + soundcloud_url?: string | null; + spotify_url?: string | null; + stage?: string | null; + time_end?: string | null; + time_start?: string | null; + updated_at?: string; + }; + Update: { + added_by?: string; + archived?: boolean; + created_at?: string; + description?: string | null; + estimated_date?: string | null; + id?: string; + image_url?: string | null; + name?: string; + slug?: string; + soundcloud_url?: string | null; + spotify_url?: string | null; + stage?: string | null; + time_end?: string | null; + time_start?: string | null; + updated_at?: string; + }; + Relationships: []; + }; + custom_links: { + Row: { + created_at: string | null; + display_order: number | null; + festival_id: string; + id: string; + link_type: Database["public"]["Enums"]["link_type"]; + title: string; + updated_at: string | null; + url: string; + }; + Insert: { + created_at?: string | null; + display_order?: number | null; + festival_id: string; + id?: string; + link_type?: Database["public"]["Enums"]["link_type"]; + title: string; + updated_at?: string | null; + url: string; + }; + Update: { + created_at?: string | null; + display_order?: number | null; + festival_id?: string; + id?: string; + link_type?: Database["public"]["Enums"]["link_type"]; + title?: string; + updated_at?: string | null; + url?: string; + }; + Relationships: [ + { + foreignKeyName: "custom_links_festival_id_fkey"; + columns: ["festival_id"]; + isOneToOne: false; + referencedRelation: "festivals"; + referencedColumns: ["id"]; + }, + ]; + }; + festival_editions: { + Row: { + archived: boolean; + created_at: string; + description: string | null; + end_date: string | null; + festival_id: string; + id: string; + is_active: boolean; + location: string | null; + name: string; + published: boolean | null; + slug: string; + start_date: string | null; + updated_at: string; + year: number; + }; + Insert: { + archived?: boolean; + created_at?: string; + description?: string | null; + end_date?: string | null; + festival_id: string; + id?: string; + is_active?: boolean; + location?: string | null; + name: string; + published?: boolean | null; + slug: string; + start_date?: string | null; + updated_at?: string; + year: number; + }; + Update: { + archived?: boolean; + created_at?: string; + description?: string | null; + end_date?: string | null; + festival_id?: string; + id?: string; + is_active?: boolean; + location?: string | null; + name?: string; + published?: boolean | null; + slug?: string; + start_date?: string | null; + updated_at?: string; + year?: number; + }; + Relationships: [ + { + foreignKeyName: "festival_editions_festival_id_fkey"; + columns: ["festival_id"]; + isOneToOne: false; + referencedRelation: "festivals"; + referencedColumns: ["id"]; + }, + ]; + }; + festival_info: { + Row: { + created_at: string; + facebook_url: string | null; + festival_id: string; + id: string; + info_text: string | null; + instagram_url: string | null; + map_image_url: string | null; + updated_at: string; + }; + Insert: { + created_at?: string; + facebook_url?: string | null; + festival_id: string; + id?: string; + info_text?: string | null; + instagram_url?: string | null; + map_image_url?: string | null; + updated_at?: string; + }; + Update: { + created_at?: string; + facebook_url?: string | null; + festival_id?: string; + id?: string; + info_text?: string | null; + instagram_url?: string | null; + map_image_url?: string | null; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "festival_info_festival_id_fkey"; + columns: ["festival_id"]; + isOneToOne: true; + referencedRelation: "festivals"; + referencedColumns: ["id"]; + }, + ]; + }; + festivals: { + Row: { + archived: boolean; + created_at: string; + description: string | null; + id: string; + logo_url: string | null; + name: string; + published: boolean | null; + slug: string; + updated_at: string; + }; + Insert: { + archived?: boolean; + created_at?: string; + description?: string | null; + id?: string; + logo_url?: string | null; + name: string; + published?: boolean | null; + slug: string; + updated_at?: string; + }; + Update: { + archived?: boolean; + created_at?: string; + description?: string | null; + id?: string; + logo_url?: string | null; + name?: string; + published?: boolean | null; + slug?: string; + updated_at?: string; + }; + Relationships: []; + }; + group_invites: { + Row: { + created_at: string; + created_by: string; + expires_at: string | null; + group_id: string; + id: string; + invite_token: string; + is_active: boolean; + max_uses: number | null; + used_count: number; + }; + Insert: { + created_at?: string; + created_by: string; + expires_at?: string | null; + group_id: string; + id?: string; + invite_token: string; + is_active?: boolean; + max_uses?: number | null; + used_count?: number; + }; + Update: { + created_at?: string; + created_by?: string; + expires_at?: string | null; + group_id?: string; + id?: string; + invite_token?: string; + is_active?: boolean; + max_uses?: number | null; + used_count?: number; + }; + Relationships: [ + { + foreignKeyName: "group_invites_group_id_fkey"; + columns: ["group_id"]; + isOneToOne: false; + referencedRelation: "groups"; + referencedColumns: ["id"]; + }, + ]; + }; + group_members: { + Row: { + group_id: string; + id: string; + joined_at: string; + role: string; + user_id: string; + }; + Insert: { + group_id: string; + id?: string; + joined_at?: string; + role?: string; + user_id: string; + }; + Update: { + group_id?: string; + id?: string; + joined_at?: string; + role?: string; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: "group_members_group_id_fkey"; + columns: ["group_id"]; + isOneToOne: false; + referencedRelation: "groups"; + referencedColumns: ["id"]; + }, + ]; + }; + groups: { + Row: { + archived: boolean; + created_at: string; + created_by: string; + description: string | null; + id: string; + name: string; + slug: string; + updated_at: string; + }; + Insert: { + archived?: boolean; + created_at?: string; + created_by: string; + description?: string | null; + id?: string; + name: string; + slug: string; + updated_at?: string; + }; + Update: { + archived?: boolean; + created_at?: string; + created_by?: string; + description?: string | null; + id?: string; + name?: string; + slug?: string; + updated_at?: string; + }; + Relationships: []; + }; + music_genres: { + Row: { + created_at: string; + created_by: string | null; + id: string; + name: string; + }; + Insert: { + created_at?: string; + created_by?: string | null; + id?: string; + name: string; + }; + Update: { + created_at?: string; + created_by?: string | null; + id?: string; + name?: string; + }; + Relationships: []; + }; + profiles: { + Row: { + completed_onboarding: boolean | null; + created_at: string; + email: string | null; + id: string; + username: string | null; + }; + Insert: { + completed_onboarding?: boolean | null; + created_at?: string; + email?: string | null; + id: string; + username?: string | null; + }; + Update: { + completed_onboarding?: boolean | null; + created_at?: string; + email?: string | null; + id?: string; + username?: string | null; + }; + Relationships: []; + }; + set_artists: { + Row: { + artist_id: string; + created_at: string; + id: string; + role: string | null; + set_id: string; + }; + Insert: { + artist_id: string; + created_at?: string; + id?: string; + role?: string | null; + set_id: string; + }; + Update: { + artist_id?: string; + created_at?: string; + id?: string; + role?: string | null; + set_id?: string; + }; + Relationships: [ + { + foreignKeyName: "set_artists_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "artists"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "set_artists_set_id_fkey"; + columns: ["set_id"]; + isOneToOne: false; + referencedRelation: "sets"; + referencedColumns: ["id"]; + }, + ]; + }; + sets: { + Row: { + archived: boolean; + created_at: string; + created_by: string; + description: string | null; + festival_edition_id: string; + id: string; + name: string; + slug: string; + stage_id: string | null; + time_end: string | null; + time_start: string | null; + updated_at: string; + }; + Insert: { + archived?: boolean; + created_at?: string; + created_by: string; + description?: string | null; + festival_edition_id: string; + id?: string; + name: string; + slug: string; + stage_id?: string | null; + time_end?: string | null; + time_start?: string | null; + updated_at?: string; + }; + Update: { + archived?: boolean; + created_at?: string; + created_by?: string; + description?: string | null; + festival_edition_id?: string; + id?: string; + name?: string; + slug?: string; + stage_id?: string | null; + time_end?: string | null; + time_start?: string | null; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "sets_festival_edition_id_fkey"; + columns: ["festival_edition_id"]; + isOneToOne: false; + referencedRelation: "festival_editions"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "sets_stage_id_fkey"; + columns: ["stage_id"]; + isOneToOne: false; + referencedRelation: "stages"; + referencedColumns: ["id"]; + }, + ]; + }; + soundcloud: { + Row: { + artist_id: string; + created_at: string | null; + display_name: string | null; + followers_count: number | null; + id: string; + last_sync: string | null; + playlist_title: string | null; + playlist_url: string | null; + soundcloud_id: number | null; + updated_at: string | null; + username: string | null; + }; + Insert: { + artist_id: string; + created_at?: string | null; + display_name?: string | null; + followers_count?: number | null; + id?: string; + last_sync?: string | null; + playlist_title?: string | null; + playlist_url?: string | null; + soundcloud_id?: number | null; + updated_at?: string | null; + username?: string | null; + }; + Update: { + artist_id?: string; + created_at?: string | null; + display_name?: string | null; + followers_count?: number | null; + id?: string; + last_sync?: string | null; + playlist_title?: string | null; + playlist_url?: string | null; + soundcloud_id?: number | null; + updated_at?: string | null; + username?: string | null; + }; + Relationships: [ + { + foreignKeyName: "soundcloud_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: true; + referencedRelation: "artists"; + referencedColumns: ["id"]; + }, + ]; + }; + stages: { + Row: { + archived: boolean; + color: string | null; + created_at: string; + festival_edition_id: string; + id: string; + name: string; + slug: string; + stage_order: number; + updated_at: string; + }; + Insert: { + archived?: boolean; + color?: string | null; + created_at?: string; + festival_edition_id: string; + id?: string; + name: string; + slug: string; + stage_order?: number; + updated_at?: string; + }; + Update: { + archived?: boolean; + color?: string | null; + created_at?: string; + festival_edition_id?: string; + id?: string; + name?: string; + slug?: string; + stage_order?: number; + updated_at?: string; + }; + Relationships: []; + }; + votes: { + Row: { + created_at: string; + id: string; + set_id: string; + updated_at: string; + user_id: string; + vote_type: number; + }; + Insert: { + created_at?: string; + id?: string; + set_id: string; + updated_at?: string; + user_id: string; + vote_type: number; + }; + Update: { + created_at?: string; + id?: string; + set_id?: string; + updated_at?: string; + user_id?: string; + vote_type?: number; + }; + Relationships: [ + { + foreignKeyName: "votes_set_id_fkey"; + columns: ["set_id"]; + isOneToOne: false; + referencedRelation: "sets"; + referencedColumns: ["id"]; + }, + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + bootstrap_super_admin: { Args: { user_email: string }; Returns: boolean }; + can_edit_artists: { Args: { check_user_id: string }; Returns: boolean }; + check_username_exists: { + Args: { check_username: string; exclude_user_id?: string }; + Returns: boolean; + }; + duplicate_set_with_votes: + | { + Args: { + new_time_end: string; + new_time_start: string; + source_set_id: string; + }; + Returns: string; + } + | { + Args: { + new_description?: string; + new_stage_id?: string; + new_time_end: string; + new_time_start: string; + source_set_id: string; + }; + Returns: string; + }; + get_user_id_by_email: { Args: { user_email: string }; Returns: string }; + has_admin_role: { + Args: { + check_role: Database["public"]["Enums"]["admin_role"]; + check_user_id: string; + }; + Returns: boolean; + }; + is_admin: { Args: { check_user_id: string }; Returns: boolean }; + is_group_creator: { Args: { group_id_param: string }; Returns: boolean }; + is_group_member: { Args: { group_id_param: string }; Returns: boolean }; + promote_user_to_admin: { + Args: { + target_role?: Database["public"]["Enums"]["admin_role"]; + user_email: string; + }; + Returns: boolean; + }; + use_invite_token: { + Args: { token: string; user_id: string }; + Returns: { + group_id: string; + message: string; + success: boolean; + }[]; + }; + users_share_group: { + Args: { user1_id: string; user2_id: string }; + Returns: boolean; + }; + validate_invite_token: { + Args: { token: string }; + Returns: { + group_id: string; + group_name: string; + invite_id: string; + is_valid: boolean; + reason: string; + }[]; + }; + validate_profile_update: { + Args: { new_username?: string; user_id: string }; + Returns: string; + }; + }; + Enums: { + admin_role: "super_admin" | "admin" | "moderator"; + link_type: "website" | "tickets" | "custom"; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +type DatabaseWithoutInternals = Omit; + +type DefaultSchema = DatabaseWithoutInternals[Extract< + keyof Database, + "public" +>]; + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R; + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I; + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I; + } + ? I + : never + : never; + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U; + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never; + +export const Constants = { + graphql_public: { + Enums: {}, + }, + public: { + Enums: { + admin_role: ["super_admin", "admin", "moderator"], + link_type: ["website", "tickets", "custom"], + }, + }, +} as const; diff --git a/supabase/functions/commit-schedule/commit-schedule.test.ts b/supabase/functions/commit-schedule/commit-schedule.test.ts new file mode 100644 index 00000000..439ac5a9 --- /dev/null +++ b/supabase/functions/commit-schedule/commit-schedule.test.ts @@ -0,0 +1,250 @@ +// Integration tests for commit-schedule. +// Run against a local Supabase instance: deno test --allow-env --allow-net commit-schedule.test.ts +// +// These tests require SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY env vars. +// They test the commit_schedule RPC directly, which is the meaningful logic layer. +// The Edge Function itself is a thin auth + dispatch wrapper. + +import { assertEquals, assertExists } from "jsr:@std/assert@1"; +import { createClient } from "npm:@supabase/supabase-js@2.57.4"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; +const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + +function skipIfNoEnv() { + if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { + console.warn( + "Skipping integration tests: SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not set", + ); + return true; + } + return false; +} + +function adminClient() { + return createClient(SUPABASE_URL, SERVICE_ROLE_KEY); +} + +async function getTestEditionId( + db: ReturnType, +): Promise { + const { data } = await db + .from("festival_editions") + .select("id") + .limit(1) + .single(); + assertExists(data, "No festival edition found — run test:setup first"); + return data.id; +} + +async function getTestUserId( + db: ReturnType, +): Promise { + const { data } = await db + .from("admin_roles") + .select("user_id") + .limit(1) + .single(); + assertExists(data, "No admin user found — run test:setup first"); + return data.user_id; +} + +Deno.test("commit_schedule: creates new artist and set", async () => { + if (skipIfNoEnv()) return; + const db = adminClient(); + const editionId = await getTestEditionId(db); + const userId = await getTestUserId(db); + const slug = `test-artist-${Date.now()}`; + const setName = `Test Artist Set ${slug}`; + + const { data, error } = await db.rpc("commit_schedule", { + p_festival_edition_id: editionId, + p_user_id: userId, + p_artists_to_create: [{ name: "Test Artist", slug }], + p_stages_to_create: [], + p_sets_to_create: [ + { + name: setName, + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [slug], + }, + ], + p_sets_to_update: [], + p_set_ids_to_archive: [], + }); + + assertEquals(error, null); + assertEquals(data.setsCreated, 1); + assertEquals(data.setsUpdated, 0); + + // Cleanup + await db + .from("sets") + .delete() + .eq("festival_edition_id", editionId) + .eq("name", setName); + await db.from("artists").delete().eq("slug", slug); +}); + +Deno.test( + "commit_schedule: updates existing set without creating duplicate", + async () => { + if (skipIfNoEnv()) return; + const db = adminClient(); + const editionId = await getTestEditionId(db); + const userId = await getTestUserId(db); + const slug = `test-update-artist-${Date.now()}`; + + // Create artist and set + await db + .from("artists") + .insert({ name: "Update Test", slug, added_by: userId }); + const { data: artist } = await db + .from("artists") + .select("id") + .eq("slug", slug) + .single(); + const { data: set } = await db + .from("sets") + .insert({ + festival_edition_id: editionId, + name: "Old Name", + slug: "old-name", + created_by: userId, + }) + .select("id") + .single(); + await db + .from("set_artists") + .insert({ set_id: set!.id, artist_id: artist!.id }); + + const { data, error } = await db.rpc("commit_schedule", { + p_festival_edition_id: editionId, + p_user_id: userId, + p_artists_to_create: [], + p_stages_to_create: [], + p_sets_to_create: [], + p_sets_to_update: [ + { + id: set!.id, + name: "New Name", + description: "Updated", + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [slug], + }, + ], + p_set_ids_to_archive: [], + }); + + assertEquals(error, null); + assertEquals(data.setsUpdated, 1); + + const { data: updated } = await db + .from("sets") + .select("name, description") + .eq("id", set!.id) + .single(); + assertEquals(updated!.name, "New Name"); + assertEquals(updated!.description, "Updated"); + + // Cleanup + await db.from("sets").delete().eq("id", set!.id); + await db.from("artists").delete().eq("slug", slug); + }, +); + +Deno.test("commit_schedule: archives orphaned sets", async () => { + if (skipIfNoEnv()) return; + const db = adminClient(); + const editionId = await getTestEditionId(db); + const userId = await getTestUserId(db); + + const { data: set } = await db + .from("sets") + .insert({ + festival_edition_id: editionId, + name: "Orphan Set", + slug: "orphan-set", + created_by: userId, + }) + .select("id") + .single(); + + const { data, error } = await db.rpc("commit_schedule", { + p_festival_edition_id: editionId, + p_user_id: userId, + p_artists_to_create: [], + p_stages_to_create: [], + p_sets_to_create: [], + p_sets_to_update: [], + p_set_ids_to_archive: [set!.id], + }); + + assertEquals(error, null); + assertEquals(data.setsArchived, 1); + + const { data: archived } = await db + .from("sets") + .select("archived") + .eq("id", set!.id) + .single(); + assertEquals(archived!.archived, true); + + // Cleanup + await db.from("sets").delete().eq("id", set!.id); +}); + +Deno.test( + "commit_schedule: midnight-crossing times stored correctly", + async () => { + if (skipIfNoEnv()) return; + const db = adminClient(); + const editionId = await getTestEditionId(db); + const userId = await getTestUserId(db); + const slug = `test-midnight-${Date.now()}`; + + await db + .from("artists") + .insert({ name: "Late Night DJ", slug, added_by: userId }); + + const { error } = await db.rpc("commit_schedule", { + p_festival_edition_id: editionId, + p_user_id: userId, + p_artists_to_create: [], + p_stages_to_create: [], + p_sets_to_create: [ + { + name: "Late Night Set", + description: null, + stageName: null, + timeStart: "2026-07-11T23:00:00.000Z", + timeEnd: "2026-07-12T01:00:00.000Z", + artistSlugs: [slug], + }, + ], + p_sets_to_update: [], + p_set_ids_to_archive: [], + }); + + assertEquals(error, null); + + const { data: sets } = await db + .from("sets") + .select("id, time_start, time_end, set_artists(artist_id, artists(slug))") + .eq("festival_edition_id", editionId) + .eq("name", "Late Night Set"); + + assertExists(sets?.[0]); + assertEquals(sets![0].time_start, "2026-07-11T23:00:00+00:00"); + assertEquals(sets![0].time_end, "2026-07-12T01:00:00+00:00"); + + // Cleanup + await db.from("sets").delete().eq("id", sets![0].id); + await db.from("artists").delete().eq("slug", slug); + }, +); diff --git a/supabase/functions/commit-schedule/deno.json b/supabase/functions/commit-schedule/deno.json new file mode 100644 index 00000000..38af4024 --- /dev/null +++ b/supabase/functions/commit-schedule/deno.json @@ -0,0 +1,3 @@ +{ + "nodeModulesDir": "none" +} diff --git a/supabase/functions/commit-schedule/deno.lock b/supabase/functions/commit-schedule/deno.lock new file mode 100644 index 00000000..500fbda3 --- /dev/null +++ b/supabase/functions/commit-schedule/deno.lock @@ -0,0 +1,154 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.13", + "npm:@supabase/supabase-js@2.57.4": "2.57.4" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.13": { + "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0" + } + }, + "npm": { + "@supabase/auth-js@2.71.1": { + "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", + "dependencies": [ + "@supabase/node-fetch" + ] + }, + "@supabase/functions-js@2.4.6": { + "integrity": "sha512-bhjZ7rmxAibjgmzTmQBxJU6ZIBCCJTc3Uwgvdi4FewueUTAGO5hxZT1Sj6tiD+0dSXf9XI87BDdJrg12z8Uaew==", + "dependencies": [ + "@supabase/node-fetch" + ] + }, + "@supabase/node-fetch@2.6.15": { + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "dependencies": [ + "whatwg-url" + ] + }, + "@supabase/postgrest-js@1.21.4": { + "integrity": "sha512-TxZCIjxk6/dP9abAi89VQbWWMBbybpGWyvmIzTd79OeravM13OjR/YEYeyUOPcM1C3QyvXkvPZhUfItvmhY1IQ==", + "dependencies": [ + "@supabase/node-fetch" + ] + }, + "@supabase/realtime-js@2.15.5": { + "integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==", + "dependencies": [ + "@supabase/node-fetch", + "@types/phoenix", + "@types/ws", + "ws" + ] + }, + "@supabase/storage-js@2.12.1": { + "integrity": "sha512-QWg3HV6Db2J81VQx0PqLq0JDBn4Q8B1FYn1kYcbla8+d5WDmTdwwMr+EJAxNOSs9W4mhKMv+EYCpCrTFlTj4VQ==", + "dependencies": [ + "@supabase/node-fetch" + ] + }, + "@supabase/supabase-js@2.57.4": { + "integrity": "sha512-LcbTzFhHYdwfQ7TRPfol0z04rLEyHabpGYANME6wkQ/kLtKNmI+Vy+WEM8HxeOZAtByUFxoUTTLwhXmrh+CcVw==", + "dependencies": [ + "@supabase/auth-js", + "@supabase/functions-js", + "@supabase/node-fetch", + "@supabase/postgrest-js", + "@supabase/realtime-js", + "@supabase/storage-js" + ] + }, + "@types/node@25.8.0": { + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "dependencies": [ + "undici-types" + ] + }, + "@types/phoenix@1.6.6": { + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==" + }, + "@types/ws@8.18.1": { + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": [ + "@types/node" + ] + }, + "tr46@0.0.3": { + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "undici-types@7.24.6": { + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==" + }, + "webidl-conversions@3.0.1": { + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url@5.0.0": { + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "ws@8.20.1": { + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==" + } + }, + "redirects": { + "https://esm.sh/@supabase/node-fetch@^2.6.13?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", + "https://esm.sh/@supabase/node-fetch@^2.6.14?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", + "https://esm.sh/@supabase/supabase-js@2": "https://esm.sh/@supabase/supabase-js@2.57.4", + "https://esm.sh/tr46@~0.0.3?target=denonext": "https://esm.sh/tr46@0.0.3?target=denonext", + "https://esm.sh/webidl-conversions@^3.0.0?target=denonext": "https://esm.sh/webidl-conversions@3.0.1?target=denonext", + "https://esm.sh/whatwg-url@^5.0.0?target=denonext": "https://esm.sh/whatwg-url@5.0.0?target=denonext" + }, + "remote": { + "https://deno.land/std@0.168.0/async/abortable.ts": "80b2ac399f142cc528f95a037a7d0e653296352d95c681e284533765961de409", + "https://deno.land/std@0.168.0/async/deadline.ts": "2c2deb53c7c28ca1dda7a3ad81e70508b1ebc25db52559de6b8636c9278fd41f", + "https://deno.land/std@0.168.0/async/debounce.ts": "60301ffb37e730cd2d6f9dadfd0ecb2a38857681bd7aaf6b0a106b06e5210a98", + "https://deno.land/std@0.168.0/async/deferred.ts": "77d3f84255c3627f1cc88699d8472b664d7635990d5358c4351623e098e917d6", + "https://deno.land/std@0.168.0/async/delay.ts": "5a9bfba8de38840308a7a33786a0155a7f6c1f7a859558ddcec5fe06e16daf57", + "https://deno.land/std@0.168.0/async/mod.ts": "7809ad4bb223e40f5fdc043e5c7ca04e0e25eed35c32c3c32e28697c553fa6d9", + "https://deno.land/std@0.168.0/async/mux_async_iterator.ts": "770a0ff26c59f8bbbda6b703a2235f04e379f73238e8d66a087edc68c2a2c35f", + "https://deno.land/std@0.168.0/async/pool.ts": "6854d8cd675a74c73391c82005cbbe4cc58183bddcd1fbbd7c2bcda42b61cf69", + "https://deno.land/std@0.168.0/async/retry.ts": "e8e5173623915bbc0ddc537698fa418cf875456c347eda1ed453528645b42e67", + "https://deno.land/std@0.168.0/async/tee.ts": "3a47cc4e9a940904fd4341f0224907e199121c80b831faa5ec2b054c6d2eff5e", + "https://deno.land/std@0.168.0/http/server.ts": "e99c1bee8a3f6571ee4cdeb2966efad465b8f6fe62bec1bdb59c1f007cc4d155", + "https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", + "https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1", + "https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774", + "https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", + "https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e", + "https://esm.sh/@supabase/auth-js@2.71.1/denonext/auth-js.mjs": "d55f67342e652b8bdce35b0ff13ad5cc294b7e96dbd68f859b464b07c6864967", + "https://esm.sh/@supabase/functions-js@2.4.6/denonext/functions-js.mjs": "d6cc049a0430f428ff0b71a0d3c48d45a243ddd48c68febcdb5cb8a02476a1dc", + "https://esm.sh/@supabase/node-fetch@2.6.15/denonext/node-fetch.mjs": "0bae9052231f4f6dbccc7234d05ea96923dbf967be12f402764580b6bf9f713d", + "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext": "4d28c4ad97328403184353f68434f2b6973971507919e9150297413664919cf3", + "https://esm.sh/@supabase/postgrest-js@1.21.4/denonext/postgrest-js.mjs": "c3769b11ef02debc78ecf6ab4e152d3cf7dbd05bbbafeb72c160e76cc57cda3c", + "https://esm.sh/@supabase/realtime-js@2.15.5/denonext/realtime-js.mjs": "518bdc73c29b502ba4dcf7ce2dff0ff8c1cbd8e5978f7ea2435af8214ea45dd5", + "https://esm.sh/@supabase/storage-js@2.12.1/denonext/storage-js.mjs": "7a5a47546486972c0627b620e7413300b4e82ac6e26b53d2c31933e13c2d652e", + "https://esm.sh/@supabase/supabase-js@2.57.4": "05a369085eb4a4c99d85ccece97f0cf1e05357122e0e74373da1f0e91b014902", + "https://esm.sh/@supabase/supabase-js@2.57.4/denonext/supabase-js.mjs": "b31f4ec51272218b68cfdcef9de5aa7abd0f1da1262fa0b9377c62eb18fe494b", + "https://esm.sh/tr46@0.0.3/denonext/tr46.mjs": "5753ec0a99414f4055f0c1f97691100f13d88e48a8443b00aebb90a512785fa2", + "https://esm.sh/tr46@0.0.3?target=denonext": "19cb9be0f0d418a0c3abb81f2df31f080e9540a04e43b0f699bce1149cba0cbb", + "https://esm.sh/webidl-conversions@3.0.1/denonext/webidl-conversions.mjs": "54b5c2d50a294853c4ccebf9d5ed8988c94f4e24e463d84ec859a866ea5fafec", + "https://esm.sh/webidl-conversions@3.0.1?target=denonext": "4e20318d50528084616c79d7b3f6e7f0fe7b6d09013bd01b3974d7448d767e29", + "https://esm.sh/whatwg-url@5.0.0/denonext/whatwg-url.mjs": "29b16d74ee72624c915745bbd25b617cfd2248c6af0f5120d131e232a9a9af79", + "https://esm.sh/whatwg-url@5.0.0?target=denonext": "f001a2cadf81312d214ca330033f474e74d81a003e21e8c5d70a1f46dc97b02d" + } +} diff --git a/supabase/functions/commit-schedule/index.ts b/supabase/functions/commit-schedule/index.ts new file mode 100644 index 00000000..0b59ca72 --- /dev/null +++ b/supabase/functions/commit-schedule/index.ts @@ -0,0 +1,102 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; +import { getAdminClient, requireAdmin, corsHeaders } from "../_shared/auth.ts"; + +// timeStart/timeEnd arrive as ISO strings or null. Coerce "" (and undefined) +// to null so the RPC's ::timestamptz cast doesn't choke on an empty string. +const nullableTimestamp = z + .string() + .nullish() + .transform((v) => v || null); + +const setPayloadSchema = z.object({ + name: z.string().min(1), + description: z.string().nullish(), + stageName: z.string().nullish(), + timeStart: nullableTimestamp, + timeEnd: nullableTimestamp, + artistSlugs: z.array(z.string().min(1)).min(1), +}); + +const commitRequestSchema = z.object({ + festivalEditionId: z.string().uuid(), + artistsToCreate: z + .array(z.object({ name: z.string().min(1), slug: z.string().min(1) })) + .default([]), + stagesToCreate: z.array(z.object({ name: z.string().min(1) })).default([]), + setsToCreate: z.array(setPayloadSchema).default([]), + setsToUpdate: z + .array(setPayloadSchema.extend({ id: z.string().uuid() })) + .default([]), + setIdsToArchive: z.array(z.string().uuid()).default([]), +}); + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + const auth = await requireAdmin(req); + if (auth.errorResponse) { + return new Response(auth.errorResponse.body, { + status: auth.errorResponse.status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + try { + const parsed = commitRequestSchema.safeParse(await req.json()); + if (!parsed.success) { + return new Response( + JSON.stringify({ + error: "Invalid request", + issues: parsed.error.issues, + }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + const { + festivalEditionId, + artistsToCreate, + stagesToCreate, + setsToCreate, + setsToUpdate, + setIdsToArchive, + } = parsed.data; + + const db = getAdminClient(); + + const { data, error } = await db.rpc("commit_schedule", { + p_festival_edition_id: festivalEditionId, + p_user_id: auth.userId, + p_artists_to_create: artistsToCreate, + p_stages_to_create: stagesToCreate, + p_sets_to_create: setsToCreate, + p_sets_to_update: setsToUpdate, + p_set_ids_to_archive: setIdsToArchive, + }); + + if (error) { + console.error("commit_schedule RPC error:", error); + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify(data), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("commit-schedule error:", error); + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/functions/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts new file mode 100644 index 00000000..e2a71d15 --- /dev/null +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -0,0 +1,264 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { computeDiff } from "./computeDiff.ts"; +import type { DbArtist, DbSet, DbStage } from "./types.ts"; + +Deno.test("new artist in CSV creates artist", () => { + const result = computeDiff( + [{ artists: ["New DJ"] }], + [], + [], + [], + "Europe/Lisbon", + ); + assertEquals(result.cleanOperations.artistsToCreate.length, 1); + assertEquals(result.cleanOperations.artistsToCreate[0].name, "New DJ"); + assertEquals(result.cleanOperations.artistsToCreate[0].slug, "new-dj"); + assertEquals(result.summary.newArtists, 1); +}); + +Deno.test("existing artist is not duplicated", () => { + const artist = makeArtist("Carl Cox"); + const result = computeDiff( + [{ artists: ["Carl Cox"] }], + [], + [], + [artist], + "Europe/Lisbon", + ); + assertEquals(result.cleanOperations.artistsToCreate.length, 0); + assertEquals(result.summary.newArtists, 0); +}); + +Deno.test("same new artist in multiple rows is created once", () => { + const result = computeDiff( + [{ artists: ["New DJ"] }, { artists: ["New DJ"] }], + [], + [], + [], + "Europe/Lisbon", + ); + assertEquals(result.cleanOperations.artistsToCreate.length, 1); +}); + +Deno.test("CSV row with no DB match creates new set", () => { + const result = computeDiff( + [{ artists: ["Carl Cox"] }], + [], + [], + [makeArtist("Carl Cox")], + "Europe/Lisbon", + ); + assertEquals(result.cleanOperations.setsToCreate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate.length, 0); + assertEquals(result.summary.setsToCreate, 1); +}); + +Deno.test("CSV row matching existing set produces update", () => { + const artist = makeArtist("Carl Cox"); + const set = makeSet("set-1", "Carl Cox", [artist]); + const result = computeDiff( + [{ artists: ["Carl Cox"] }], + [], + [set], + [artist], + "Europe/Lisbon", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-1"); + assertEquals(result.cleanOperations.setsToCreate.length, 0); + assertEquals(result.summary.setsMatched, 1); +}); + +Deno.test("set in DB but absent from CSV is orphaned", () => { + const artist = makeArtist("DJ Tennis"); + const set = makeSet("set-2", "DJ Tennis", [artist]); + const result = computeDiff([], [], [set], [artist], "Europe/Lisbon"); + assertEquals(result.conflicts.orphanedSets.length, 1); + assertEquals(result.conflicts.orphanedSets[0].id, "set-2"); + assertEquals(result.summary.setsOrphaned, 1); +}); + +Deno.test("B2B set matched by combined artist key", () => { + const cox = makeArtist("Carl Cox"); + const gou = makeArtist("Peggy Gou"); + const set = makeSet("set-b2b", "Carl Cox b2b Peggy Gou", [cox, gou]); + const result = computeDiff( + [{ artists: ["Carl Cox", "Peggy Gou"] }], + [], + [set], + [cox, gou], + "Europe/Lisbon", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b2b"); +}); + +Deno.test("B2B artist order in CSV does not affect match", () => { + const cox = makeArtist("Carl Cox"); + const gou = makeArtist("Peggy Gou"); + const set = makeSet("set-b2b", "Carl Cox b2b Peggy Gou", [cox, gou]); + const result = computeDiff( + [{ artists: ["Peggy Gou", "Carl Cox"] }], + [], + [set], + [cox, gou], + "Europe/Lisbon", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); +}); + +Deno.test("exact stage name match uses canonical DB name in payload", () => { + const artist = makeArtist("Carl Cox"); + const stage = makeStage("stage-1", "Main Stage"); + const result = computeDiff( + [{ artists: ["Carl Cox"], stage: "Main Stage" }], + [stage], + [], + [artist], + "Europe/Lisbon", + ); + assertEquals(result.cleanOperations.setsToCreate[0].stageName, "Main Stage"); +}); + +Deno.test("stage name mismatch surfaced as conflict", () => { + const artist = makeArtist("Carl Cox"); + const stage = makeStage("stage-1", "Main Stage"); + const result = computeDiff( + [{ artists: ["Carl Cox"], stage: "Mainstage" }], + [stage], + [], + [artist], + "Europe/Lisbon", + ); + assertEquals(result.conflicts.stageNameMismatches.length, 1); + assertEquals(result.conflicts.stageNameMismatches[0].csvValue, "Mainstage"); + assertEquals( + result.conflicts.stageNameMismatches[0].closestDbValue, + "Main Stage", + ); +}); + +Deno.test("unknown stage creates new stage", () => { + const artist = makeArtist("Carl Cox"); + const result = computeDiff( + [{ artists: ["Carl Cox"], stage: "Secret Forest" }], + [], + [], + [artist], + "Europe/Lisbon", + ); + assertEquals(result.cleanOperations.stagesToCreate.length, 1); + assertEquals(result.cleanOperations.stagesToCreate[0].name, "Secret Forest"); +}); + +Deno.test("end time before start time triggers midnight advance", () => { + const artist = makeArtist("Carl Cox"); + const result = computeDiff( + [ + { + artists: ["Carl Cox"], + date: "2026-07-11", + startTime: "23:00", + endTime: "01:00", + }, + ], + [], + [], + [artist], + "UTC", + ); + const created = result.cleanOperations.setsToCreate[0]; + assertEquals(created.timeStart, "2026-07-11T23:00:00.000Z"); + assertEquals(created.timeEnd, "2026-07-12T01:00:00.000Z"); +}); + +Deno.test("set name falls back to b2b join when not provided", () => { + const artist1 = makeArtist("Carl Cox"); + const artist2 = makeArtist("Peggy Gou"); + const result = computeDiff( + [{ artists: ["Carl Cox", "Peggy Gou"] }], + [], + [], + [artist1, artist2], + "UTC", + ); + assertEquals( + result.cleanOperations.setsToCreate[0].name, + "Carl Cox b2b Peggy Gou", + ); +}); + +Deno.test("explicit set name takes precedence over b2b fallback", () => { + const artist = makeArtist("Carl Cox"); + const result = computeDiff( + [{ artists: ["Carl Cox"], setName: "Carl Cox Live" }], + [], + [], + [artist], + "UTC", + ); + assertEquals(result.cleanOperations.setsToCreate[0].name, "Carl Cox Live"); +}); + +Deno.test("same stage mismatch from multiple rows surfaced once", () => { + const artist1 = makeArtist("Artist A"); + const artist2 = makeArtist("Artist B"); + const stage = makeStage("stage-1", "Main Stage"); + const result = computeDiff( + [ + { artists: ["Artist A"], stage: "Mainstage" }, + { artists: ["Artist B"], stage: "Mainstage" }, + ], + [stage], + [], + [artist1, artist2], + "UTC", + ); + assertEquals(result.conflicts.stageNameMismatches.length, 1); +}); + +Deno.test("multiple candidates disambiguated by stage", () => { + const artist = makeArtist("Carl Cox"); + const stage1 = makeStage("s1", "Stage One"); + const stage2 = makeStage("s2", "Stage Two"); + const set1 = makeSet("set-a", "Carl Cox", [artist], "s1"); + const set2 = makeSet("set-b", "Carl Cox", [artist], "s2"); + const result = computeDiff( + [{ artists: ["Carl Cox"], stage: "Stage Two" }], + [stage1, stage2], + [set1, set2], + [artist], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); + assertEquals(result.conflicts.orphanedSets.length, 1); + assertEquals(result.conflicts.orphanedSets[0].id, "set-a"); +}); + +function makeArtist(name: string): DbArtist { + const slug = name.toLowerCase().replace(/\s+/g, "-"); + return { id: `id-${slug}`, name, slug }; +} + +function makeStage(id: string, name: string): DbStage { + return { id, name }; +} + +function makeSet( + id: string, + name: string, + artists: DbArtist[], + stageId: string | null = null, + timeStart: string | null = null, +): DbSet { + return { + id, + name, + description: null, + stage_id: stageId, + time_start: timeStart, + time_end: null, + set_artists: artists.map((a) => ({ artist_id: a.id, artists: a })), + }; +} diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts new file mode 100644 index 00000000..c5f2e65d --- /dev/null +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -0,0 +1,164 @@ +import { artistKey } from "./helpers.ts"; +import { + buildIndexes, + computeTimes, + findMatchingSet, + resolveArtists, + resolveStage, + type StageResolution, +} from "./resolvers.ts"; +import type { + CsvRow, + DbArtist, + DbSet, + DbStage, + DiffResult, + SetPayload, +} from "./types.ts"; + +export function computeDiff( + rows: CsvRow[], + dbStages: DbStage[], + dbSets: DbSet[], + dbArtists: DbArtist[], + timezone: string, +): DiffResult { + const indexes = buildIndexes(dbStages, dbSets, dbArtists); + const state = createState(); + + for (const row of rows) { + const { slugs: artistSlugs, newArtists } = resolveArtists( + row.artists, + indexes.existingArtistSlugs, + ); + collectNewArtists(state, newArtists); + + const stage = resolveStage(row.stage, dbStages, indexes.stageByNameLower); + const resolvedStage = applyStageResolution(state, stage); + + const { timeStart, timeEnd } = computeTimes(row, timezone); + + const candidates = + indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? []; + const matched = findMatchingSet( + candidates, + resolvedStage.id, + row.date, + timezone, + state.matchedSetIds, + ); + + const payload: SetPayload = { + name: row.setName?.trim() || row.artists.join(" b2b "), + description: row.description ?? null, + stageName: resolvedStage.name, + timeStart, + timeEnd, + artistSlugs, + }; + + if (matched) { + state.matchedSetIds.add(matched.id); + state.setsToUpdate.push({ id: matched.id, ...payload }); + } else { + state.setsToCreate.push(payload); + } + } + + const orphanedSets = dbSets + .filter((s) => !state.matchedSetIds.has(s.id)) + .map((s) => ({ + id: s.id, + name: s.name, + stage: indexes.stageById.get(s.stage_id ?? "")?.name ?? null, + timeStart: s.time_start, + })); + + return { + summary: { + newArtists: state.artistsToCreate.length, + newStages: state.stagesToCreate.length, + setsMatched: state.matchedSetIds.size, + setsToCreate: state.setsToCreate.length, + setsOrphaned: orphanedSets.length, + }, + newArtistNames: state.artistsToCreate.map((a) => a.name), + cleanOperations: { + artistsToCreate: state.artistsToCreate, + stagesToCreate: state.stagesToCreate, + setsToCreate: state.setsToCreate, + setsToUpdate: state.setsToUpdate, + }, + conflicts: { stageNameMismatches: state.stageNameMismatches, orphanedSets }, + }; +} + +// Everything computeDiff accumulates while walking the CSV rows. +type DiffState = { + matchedSetIds: Set; + seenNewArtistSlugs: Set; + seenNewStageNames: Set; + seenMismatchedStages: Set; + artistsToCreate: { name: string; slug: string }[]; + stagesToCreate: { name: string }[]; + stageNameMismatches: DiffResult["conflicts"]["stageNameMismatches"]; + setsToCreate: SetPayload[]; + setsToUpdate: ({ id: string } & SetPayload)[]; +}; + +function createState(): DiffState { + return { + matchedSetIds: new Set(), + seenNewArtistSlugs: new Set(), + seenNewStageNames: new Set(), + seenMismatchedStages: new Set(), + artistsToCreate: [], + stagesToCreate: [], + stageNameMismatches: [], + setsToCreate: [], + setsToUpdate: [], + }; +} + +// Registers any artists not yet seen across the import as new. +function collectNewArtists( + state: DiffState, + newArtists: { name: string; slug: string }[], +): void { + for (const artist of newArtists) { + if (!state.seenNewArtistSlugs.has(artist.slug)) { + state.seenNewArtistSlugs.add(artist.slug); + state.artistsToCreate.push(artist); + } + } +} + +// Records a stage resolution into state and returns the id/name to use for +// the row's set payload. +function applyStageResolution( + state: DiffState, + stage: StageResolution, +): { id: string | null; name: string | null } { + switch (stage.kind) { + case "exact": + return { id: stage.id, name: stage.name }; + case "mismatch": + if (!state.seenMismatchedStages.has(stage.resolvedName)) { + state.seenMismatchedStages.add(stage.resolvedName); + state.stageNameMismatches.push({ + csvValue: stage.resolvedName, + closestDbValue: stage.closest.name, + dbStageId: stage.closest.id, + }); + } + return { id: null, name: stage.resolvedName }; + case "new": + if (!state.seenNewStageNames.has(stage.resolvedName)) { + state.seenNewStageNames.add(stage.resolvedName); + state.stagesToCreate.push({ name: stage.resolvedName }); + } + return { id: null, name: stage.resolvedName }; + case "none": + return { id: null, name: null }; + } +} diff --git a/supabase/functions/diff-schedule/deno.json b/supabase/functions/diff-schedule/deno.json new file mode 100644 index 00000000..38af4024 --- /dev/null +++ b/supabase/functions/diff-schedule/deno.json @@ -0,0 +1,3 @@ +{ + "nodeModulesDir": "none" +} diff --git a/supabase/functions/diff-schedule/deno.lock b/supabase/functions/diff-schedule/deno.lock new file mode 100644 index 00000000..9af6c8a4 --- /dev/null +++ b/supabase/functions/diff-schedule/deno.lock @@ -0,0 +1,67 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.13" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.13": { + "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0" + } + }, + "redirects": { + "https://esm.sh/@supabase/node-fetch@^2.6.13?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", + "https://esm.sh/@supabase/node-fetch@^2.6.14?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", + "https://esm.sh/@supabase/supabase-js@2": "https://esm.sh/@supabase/supabase-js@2.57.4", + "https://esm.sh/tr46@~0.0.3?target=denonext": "https://esm.sh/tr46@0.0.3?target=denonext", + "https://esm.sh/webidl-conversions@^3.0.0?target=denonext": "https://esm.sh/webidl-conversions@3.0.1?target=denonext", + "https://esm.sh/whatwg-url@^5.0.0?target=denonext": "https://esm.sh/whatwg-url@5.0.0?target=denonext" + }, + "remote": { + "https://deno.land/std@0.168.0/async/abortable.ts": "80b2ac399f142cc528f95a037a7d0e653296352d95c681e284533765961de409", + "https://deno.land/std@0.168.0/async/deadline.ts": "2c2deb53c7c28ca1dda7a3ad81e70508b1ebc25db52559de6b8636c9278fd41f", + "https://deno.land/std@0.168.0/async/debounce.ts": "60301ffb37e730cd2d6f9dadfd0ecb2a38857681bd7aaf6b0a106b06e5210a98", + "https://deno.land/std@0.168.0/async/deferred.ts": "77d3f84255c3627f1cc88699d8472b664d7635990d5358c4351623e098e917d6", + "https://deno.land/std@0.168.0/async/delay.ts": "5a9bfba8de38840308a7a33786a0155a7f6c1f7a859558ddcec5fe06e16daf57", + "https://deno.land/std@0.168.0/async/mod.ts": "7809ad4bb223e40f5fdc043e5c7ca04e0e25eed35c32c3c32e28697c553fa6d9", + "https://deno.land/std@0.168.0/async/mux_async_iterator.ts": "770a0ff26c59f8bbbda6b703a2235f04e379f73238e8d66a087edc68c2a2c35f", + "https://deno.land/std@0.168.0/async/pool.ts": "6854d8cd675a74c73391c82005cbbe4cc58183bddcd1fbbd7c2bcda42b61cf69", + "https://deno.land/std@0.168.0/async/retry.ts": "e8e5173623915bbc0ddc537698fa418cf875456c347eda1ed453528645b42e67", + "https://deno.land/std@0.168.0/async/tee.ts": "3a47cc4e9a940904fd4341f0224907e199121c80b831faa5ec2b054c6d2eff5e", + "https://deno.land/std@0.168.0/http/server.ts": "e99c1bee8a3f6571ee4cdeb2966efad465b8f6fe62bec1bdb59c1f007cc4d155", + "https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", + "https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1", + "https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774", + "https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", + "https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e", + "https://esm.sh/@supabase/auth-js@2.71.1/denonext/auth-js.mjs": "d55f67342e652b8bdce35b0ff13ad5cc294b7e96dbd68f859b464b07c6864967", + "https://esm.sh/@supabase/functions-js@2.4.6/denonext/functions-js.mjs": "d6cc049a0430f428ff0b71a0d3c48d45a243ddd48c68febcdb5cb8a02476a1dc", + "https://esm.sh/@supabase/node-fetch@2.6.15/denonext/node-fetch.mjs": "0bae9052231f4f6dbccc7234d05ea96923dbf967be12f402764580b6bf9f713d", + "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext": "4d28c4ad97328403184353f68434f2b6973971507919e9150297413664919cf3", + "https://esm.sh/@supabase/postgrest-js@1.21.4/denonext/postgrest-js.mjs": "c3769b11ef02debc78ecf6ab4e152d3cf7dbd05bbbafeb72c160e76cc57cda3c", + "https://esm.sh/@supabase/realtime-js@2.15.5/denonext/realtime-js.mjs": "518bdc73c29b502ba4dcf7ce2dff0ff8c1cbd8e5978f7ea2435af8214ea45dd5", + "https://esm.sh/@supabase/storage-js@2.12.1/denonext/storage-js.mjs": "7a5a47546486972c0627b620e7413300b4e82ac6e26b53d2c31933e13c2d652e", + "https://esm.sh/@supabase/supabase-js@2.57.4": "05a369085eb4a4c99d85ccece97f0cf1e05357122e0e74373da1f0e91b014902", + "https://esm.sh/@supabase/supabase-js@2.57.4/denonext/supabase-js.mjs": "b31f4ec51272218b68cfdcef9de5aa7abd0f1da1262fa0b9377c62eb18fe494b", + "https://esm.sh/tr46@0.0.3/denonext/tr46.mjs": "5753ec0a99414f4055f0c1f97691100f13d88e48a8443b00aebb90a512785fa2", + "https://esm.sh/tr46@0.0.3?target=denonext": "19cb9be0f0d418a0c3abb81f2df31f080e9540a04e43b0f699bce1149cba0cbb", + "https://esm.sh/webidl-conversions@3.0.1/denonext/webidl-conversions.mjs": "54b5c2d50a294853c4ccebf9d5ed8988c94f4e24e463d84ec859a866ea5fafec", + "https://esm.sh/webidl-conversions@3.0.1?target=denonext": "4e20318d50528084616c79d7b3f6e7f0fe7b6d09013bd01b3974d7448d767e29", + "https://esm.sh/whatwg-url@5.0.0/denonext/whatwg-url.mjs": "29b16d74ee72624c915745bbd25b617cfd2248c6af0f5120d131e232a9a9af79", + "https://esm.sh/whatwg-url@5.0.0?target=denonext": "f001a2cadf81312d214ca330033f474e74d81a003e21e8c5d70a1f46dc97b02d" + } +} diff --git a/supabase/functions/diff-schedule/helpers.test.ts b/supabase/functions/diff-schedule/helpers.test.ts new file mode 100644 index 00000000..a9fbae36 --- /dev/null +++ b/supabase/functions/diff-schedule/helpers.test.ts @@ -0,0 +1,39 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { advanceDateByOne, artistKey, localToUtc, toSlug } from "./helpers.ts"; + +Deno.test("toSlug converts name to lowercase hyphenated slug", () => { + assertEquals(toSlug("Carl Cox"), "carl-cox"); + assertEquals(toSlug("DJ Tennis"), "dj-tennis"); + assertEquals(toSlug(" Peggy Gou "), "peggy-gou"); + assertEquals(toSlug("Aphex Twin"), "aphex-twin"); + assertEquals(toSlug("deadmau5"), "deadmau5"); + assertEquals(toSlug("Four Tet"), "four-tet"); +}); + +Deno.test("artistKey sorts slugs and joins with pipe", () => { + assertEquals(artistKey(["carl-cox"]), "carl-cox"); + assertEquals(artistKey(["carl-cox", "peggy-gou"]), "carl-cox|peggy-gou"); + assertEquals(artistKey(["peggy-gou", "carl-cox"]), "carl-cox|peggy-gou"); + assertEquals(artistKey(["c", "b", "a"]), "a|b|c"); +}); + +Deno.test("advanceDateByOne advances date by one day", () => { + assertEquals(advanceDateByOne("2026-07-11"), "2026-07-12"); + assertEquals(advanceDateByOne("2026-07-31"), "2026-08-01"); + assertEquals(advanceDateByOne("2026-12-31"), "2027-01-01"); +}); + +Deno.test("localToUtc converts Lisbon summer time (UTC+1) to UTC", () => { + const result = localToUtc("2026-07-11", "23:00", "Europe/Lisbon"); + assertEquals(result, "2026-07-11T22:00:00.000Z"); +}); + +Deno.test("localToUtc converts Lisbon winter time (UTC+0) to UTC", () => { + const result = localToUtc("2026-01-15", "22:00", "Europe/Lisbon"); + assertEquals(result, "2026-01-15T22:00:00.000Z"); +}); + +Deno.test("localToUtc converts midnight correctly", () => { + const result = localToUtc("2026-07-11", "00:00", "Europe/Lisbon"); + assertEquals(result, "2026-07-10T23:00:00.000Z"); +}); diff --git a/supabase/functions/diff-schedule/helpers.ts b/supabase/functions/diff-schedule/helpers.ts new file mode 100644 index 00000000..1c298afa --- /dev/null +++ b/supabase/functions/diff-schedule/helpers.ts @@ -0,0 +1,39 @@ +export function toSlug(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function artistKey(slugs: string[]): string { + return [...slugs].sort().join("|"); +} + +export function advanceDateByOne(dateStr: string): string { + const d = new Date(dateStr + "T00:00:00Z"); + d.setUTCDate(d.getUTCDate() + 1); + return d.toISOString().split("T")[0]; +} + +export function localToUtc( + dateStr: string, + timeStr: string, + timezone: string, +): string { + const localIso = `${dateStr}T${timeStr}:00`; + const naiveUtc = new Date(localIso + "Z"); + // sv-SE locale gives "YYYY-MM-DD HH:MM:SS" — unambiguously parseable as UTC + const localInTz = new Date( + naiveUtc.toLocaleString("sv-SE", { timeZone: timezone }) + "Z", + ); + const offsetMs = naiveUtc.getTime() - localInTz.getTime(); + return new Date(naiveUtc.getTime() + offsetMs).toISOString(); +} + +export function utcToLocalDate(utcIso: string, timezone: string): string { + // sv-SE renders as "YYYY-MM-DD HH:MM:SS" so we can take the date portion. + return new Date(utcIso) + .toLocaleString("sv-SE", { timeZone: timezone }) + .split(" ")[0]; +} diff --git a/supabase/functions/diff-schedule/index.ts b/supabase/functions/diff-schedule/index.ts new file mode 100644 index 00000000..abcc4d6d --- /dev/null +++ b/supabase/functions/diff-schedule/index.ts @@ -0,0 +1,128 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; +import { getAdminClient, requireAdmin, corsHeaders } from "../_shared/auth.ts"; +import { computeDiff } from "./computeDiff.ts"; + +function isValidTimezone(tz: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }); + return true; + } catch { + return false; + } +} + +// Drop case-insensitive duplicates, keeping the first occurrence's casing. +// Mirrors parseCsv's dedupeArtists so a direct (non-wizard) caller can't skew +// the diff's roster key or send duplicate slugs downstream. +function dedupeArtists(names: string[]): string[] { + const seen = new Set(); + return names.filter((name) => { + const key = name.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +const csvRowSchema = z.object({ + artists: z.array(z.string().trim().min(1)).min(1).transform(dedupeArtists), + setName: z.string().optional(), + stage: z.string().optional(), + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD") + .optional(), + startTime: z + .string() + .regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM") + .optional(), + endTime: z + .string() + .regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM") + .optional(), + description: z.string().optional(), +}); + +const diffRequestSchema = z.object({ + festivalEditionId: z.string().uuid(), + timezone: z.string().min(1).refine(isValidTimezone, "Invalid IANA timezone"), + rows: z + .array(csvRowSchema) + .max(5000, "Too many rows — split the import (max 5000 rows)"), +}); + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + const auth = await requireAdmin(req); + if (auth.errorResponse) { + return new Response(auth.errorResponse.body, { + status: auth.errorResponse.status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + try { + const parsed = diffRequestSchema.safeParse(await req.json()); + if (!parsed.success) { + return new Response( + JSON.stringify({ + error: "Invalid request", + issues: parsed.error.issues, + }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + const { festivalEditionId, timezone, rows } = parsed.data; + + const db = getAdminClient(); + + const [stagesRes, setsRes, artistsRes] = await Promise.all([ + db + .from("stages") + .select("id, name") + .eq("festival_edition_id", festivalEditionId) + .eq("archived", false), + db + .from("sets") + .select( + "id, name, description, stage_id, time_start, time_end, set_artists(artist_id, artists(id, name, slug))", + ) + .eq("festival_edition_id", festivalEditionId) + .eq("archived", false) + .order("time_start", { nullsFirst: false }) + .order("id"), + db.from("artists").select("id, name, slug").eq("archived", false), + ]); + + if (stagesRes.error) throw stagesRes.error; + if (setsRes.error) throw setsRes.error; + if (artistsRes.error) throw artistsRes.error; + + const result = computeDiff( + rows, + stagesRes.data ?? [], + setsRes.data ?? [], + artistsRes.data ?? [], + timezone, + ); + + return new Response(JSON.stringify(result), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("diff-schedule error:", error); + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/functions/diff-schedule/resolvers.test.ts b/supabase/functions/diff-schedule/resolvers.test.ts new file mode 100644 index 00000000..a52ed533 --- /dev/null +++ b/supabase/functions/diff-schedule/resolvers.test.ts @@ -0,0 +1,137 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { + buildIndexes, + computeTimes, + findMatchingSet, + resolveArtists, + resolveStage, +} from "./resolvers.ts"; +import type { DbArtist, DbSet, DbStage } from "./types.ts"; + +Deno.test("resolveArtists returns slugs and flags only unknown artists", () => { + const result = resolveArtists(["Carl Cox", "New DJ"], new Set(["carl-cox"])); + assertEquals(result.slugs, ["carl-cox", "new-dj"]); + assertEquals(result.newArtists, [{ name: "New DJ", slug: "new-dj" }]); +}); + +Deno.test("resolveArtists does not mutate its arguments", () => { + const existing = new Set(["carl-cox"]); + resolveArtists(["New DJ"], existing); + assertEquals(existing.has("new-dj"), false); +}); + +Deno.test("buildIndexes groups sets by sorted artist key", () => { + const cox = makeArtist("Carl Cox"); + const gou = makeArtist("Peggy Gou"); + const indexes = buildIndexes([], [makeSet("set-1", [cox, gou])], [cox, gou]); + assertEquals(indexes.setsByArtistKey.get("carl-cox|peggy-gou")?.length, 1); + assertEquals(indexes.existingArtistSlugs.has("carl-cox"), true); +}); + +Deno.test("resolveStage returns exact match with canonical name", () => { + const stage = makeStage("s1", "Main Stage"); + const result = resolveStage( + "Main Stage", + [stage], + new Map([["main stage", stage]]), + ); + assertEquals(result, { kind: "exact", id: "s1", name: "Main Stage" }); +}); + +Deno.test("resolveStage flags a close name as a mismatch", () => { + const stage = makeStage("s1", "Main Stage"); + const result = resolveStage( + "Mainstage", + [stage], + new Map([["main stage", stage]]), + ); + assertEquals(result.kind, "mismatch"); +}); + +Deno.test("resolveStage treats an unknown name as new", () => { + const result = resolveStage("Secret Forest", [], new Map()); + assertEquals(result, { kind: "new", resolvedName: "Secret Forest" }); +}); + +Deno.test("resolveStage does not substring-match a short DB stage name", () => { + const stage = makeStage("s1", "A"); + const result = resolveStage("Beach", [stage], new Map([["a", stage]])); + assertEquals(result, { kind: "new", resolvedName: "Beach" }); +}); + +Deno.test("resolveStage returns none when no stage given", () => { + assertEquals(resolveStage(undefined, [], new Map()), { kind: "none" }); +}); + +Deno.test("computeTimes converts local start/end to UTC", () => { + const result = computeTimes( + { date: "2026-07-11", startTime: "23:00", endTime: "01:00" }, + "UTC", + ); + assertEquals(result.timeStart, "2026-07-11T23:00:00.000Z"); + assertEquals(result.timeEnd, "2026-07-12T01:00:00.000Z"); +}); + +Deno.test("computeTimes returns nulls when date is missing", () => { + assertEquals(computeTimes({ startTime: "23:00" }, "UTC"), { + timeStart: null, + timeEnd: null, + }); +}); + +Deno.test("findMatchingSet returns the only available candidate", () => { + const set = makeSet("set-1", []); + assertEquals(findMatchingSet([set], null, undefined, "UTC", new Set()), set); +}); + +Deno.test("findMatchingSet skips already-matched candidates", () => { + const set = makeSet("set-1", []); + assertEquals( + findMatchingSet([set], null, undefined, "UTC", new Set(["set-1"])), + null, + ); +}); + +Deno.test("findMatchingSet disambiguates by stage id", () => { + const a = makeSet("set-a", [], "s1"); + const b = makeSet("set-b", [], "s2"); + assertEquals( + findMatchingSet([a, b], "s2", undefined, "UTC", new Set())?.id, + "set-b", + ); +}); + +Deno.test("findMatchingSet disambiguates by date", () => { + const a = makeSet("set-a", [], null, "2026-07-11T20:00:00.000Z"); + const b = makeSet("set-b", [], null, "2026-07-12T20:00:00.000Z"); + assertEquals( + findMatchingSet([a, b], null, "2026-07-12", "UTC", new Set())?.id, + "set-b", + ); +}); + +function makeArtist(name: string): DbArtist { + const slug = name.toLowerCase().replace(/\s+/g, "-"); + return { id: `id-${slug}`, name, slug }; +} + +function makeStage(id: string, name: string): DbStage { + return { id, name }; +} + +function makeSet( + id: string, + artists: DbArtist[], + stageId: string | null = null, + timeStart: string | null = null, +): DbSet { + return { + id, + name: id, + description: null, + stage_id: stageId, + time_start: timeStart, + time_end: null, + set_artists: artists.map((a) => ({ artist_id: a.id, artists: a })), + }; +} diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts new file mode 100644 index 00000000..8dfea71d --- /dev/null +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -0,0 +1,136 @@ +import type { CsvRow, DbArtist, DbSet, DbStage } from "./types.ts"; +import { + advanceDateByOne, + artistKey, + localToUtc, + toSlug, + utcToLocalDate, +} from "./helpers.ts"; + +export type DbIndexes = { + stageByNameLower: Map; + stageById: Map; + existingArtistSlugs: Set; + setsByArtistKey: Map; +}; + +export function buildIndexes( + dbStages: DbStage[], + dbSets: DbSet[], + dbArtists: DbArtist[], +): DbIndexes { + const setsByArtistKey = new Map(); + for (const set of dbSets) { + const slugs = set.set_artists.map((sa) => sa.artists.slug); + const key = artistKey(slugs); + const bucket = setsByArtistKey.get(key) ?? []; + bucket.push(set); + setsByArtistKey.set(key, bucket); + } + return { + stageByNameLower: new Map(dbStages.map((s) => [s.name.toLowerCase(), s])), + stageById: new Map(dbStages.map((s) => [s.id, s])), + existingArtistSlugs: new Set(dbArtists.map((a) => a.slug)), + setsByArtistKey, + }; +} + +export function resolveArtists( + artistNames: string[], + existingSlugs: Set, +): { slugs: string[]; newArtists: { name: string; slug: string }[] } { + const slugs: string[] = []; + const newArtists: { name: string; slug: string }[] = []; + for (const name of artistNames) { + const slug = toSlug(name); + slugs.push(slug); + if (!existingSlugs.has(slug)) { + newArtists.push({ name, slug }); + } + } + return { slugs, newArtists }; +} + +export type StageResolution = + | { kind: "exact"; id: string; name: string } + | { kind: "mismatch"; resolvedName: string; closest: DbStage } + | { kind: "new"; resolvedName: string } + | { kind: "none" }; + +export function resolveStage( + rawStage: string | undefined, + dbStages: DbStage[], + stageByNameLower: Map, +): StageResolution { + if (!rawStage) return { kind: "none" }; + + const lower = rawStage.toLowerCase(); + const exactMatch = stageByNameLower.get(lower); + if (exactMatch) { + return { kind: "exact", id: exactMatch.id, name: exactMatch.name }; + } + + const strippedInput = strip(lower); + const closeMatch = dbStages.find((s) => { + const strippedDb = strip(s.name); + if (strippedDb === strippedInput) return true; + // Substring matching false-positives on short names (a DB stage "a" + // matches any CSV stage containing the letter), so require both + // stripped names to be long enough before comparing as substrings. + if (strippedDb.length < 3 || strippedInput.length < 3) return false; + return ( + strippedDb.includes(strippedInput) || strippedInput.includes(strippedDb) + ); + }); + + if (closeMatch) { + return { kind: "mismatch", resolvedName: rawStage, closest: closeMatch }; + } + return { kind: "new", resolvedName: rawStage }; +} + +export function computeTimes( + row: Pick, + timezone: string, +): { timeStart: string | null; timeEnd: string | null } { + let timeStart: string | null = null; + let timeEnd: string | null = null; + if (row.date && row.startTime) { + timeStart = localToUtc(row.date, row.startTime, timezone); + } + if (row.date && row.endTime) { + const crossesMidnight = + row.startTime != null && row.endTime < row.startTime; + const endDate = crossesMidnight ? advanceDateByOne(row.date) : row.date; + timeEnd = localToUtc(endDate, row.endTime, timezone); + } + return { timeStart, timeEnd }; +} + +export function findMatchingSet( + candidates: DbSet[], + resolvedStageId: string | null, + date: string | undefined, + timezone: string, + alreadyMatched: Set, +): DbSet | null { + const available = candidates.filter((s) => !alreadyMatched.has(s.id)); + if (available.length <= 1) return available[0] ?? null; + + if (resolvedStageId) { + const byStage = available.find((s) => s.stage_id === resolvedStageId); + if (byStage) return byStage; + } + if (date) { + const byDate = available.find( + (s) => + s.time_start != null && utcToLocalDate(s.time_start, timezone) === date, + ); + if (byDate) return byDate; + } + return available[0]; +} + +function strip(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]/g, ""); +} diff --git a/supabase/functions/diff-schedule/types.ts b/supabase/functions/diff-schedule/types.ts new file mode 100644 index 00000000..9aa657f3 --- /dev/null +++ b/supabase/functions/diff-schedule/types.ts @@ -0,0 +1,66 @@ +import type { Database } from "../_shared/database.types.ts"; + +export type CsvRow = { + artists: string[]; + setName?: string; + stage?: string; + date?: string; + startTime?: string; + endTime?: string; + description?: string; +}; + +// Narrow the generated row types to just the columns the diff needs. +// The diff query selects a subset; mirroring it here keeps the consumer +// surface tight while still letting tsc catch column drift. +type StageRow = Database["public"]["Tables"]["stages"]["Row"]; +type ArtistRow = Database["public"]["Tables"]["artists"]["Row"]; +type SetRow = Database["public"]["Tables"]["sets"]["Row"]; + +export type DbStage = Pick; +export type DbArtist = Pick; +export type DbSet = Pick< + SetRow, + "id" | "name" | "description" | "stage_id" | "time_start" | "time_end" +> & { + set_artists: { artist_id: string; artists: DbArtist }[]; +}; + +export type SetPayload = { + name: string; + description: string | null; + stageName: string | null; + timeStart: string | null; + timeEnd: string | null; + artistSlugs: string[]; +}; + +export type DiffResult = { + summary: { + newArtists: number; + newStages: number; + setsMatched: number; + setsToCreate: number; + setsOrphaned: number; + }; + newArtistNames: string[]; + cleanOperations: { + artistsToCreate: { name: string; slug: string }[]; + stagesToCreate: { name: string }[]; + setsToCreate: SetPayload[]; + setsToUpdate: ({ id: string } & SetPayload)[]; + }; + conflicts: { + stageNameMismatches: { + csvValue: string; + closestDbValue: string; + dbStageId: string; + }[]; + orphanedSets: { + id: string; + name: string; + stage: string | null; + timeStart: string | null; + }[]; + }; +}; diff --git a/supabase/functions/get-artist-soundcloud-playlist/deno.json b/supabase/functions/get-artist-soundcloud-playlist/deno.json new file mode 100644 index 00000000..38af4024 --- /dev/null +++ b/supabase/functions/get-artist-soundcloud-playlist/deno.json @@ -0,0 +1,3 @@ +{ + "nodeModulesDir": "none" +} diff --git a/supabase/functions/get-artist-soundcloud-playlist/deno.lock b/supabase/functions/get-artist-soundcloud-playlist/deno.lock new file mode 100644 index 00000000..6becfed6 --- /dev/null +++ b/supabase/functions/get-artist-soundcloud-playlist/deno.lock @@ -0,0 +1,29 @@ +{ + "version": "5", + "remote": { + "https://deno.land/std@0.168.0/async/abortable.ts": "80b2ac399f142cc528f95a037a7d0e653296352d95c681e284533765961de409", + "https://deno.land/std@0.168.0/async/deadline.ts": "2c2deb53c7c28ca1dda7a3ad81e70508b1ebc25db52559de6b8636c9278fd41f", + "https://deno.land/std@0.168.0/async/debounce.ts": "60301ffb37e730cd2d6f9dadfd0ecb2a38857681bd7aaf6b0a106b06e5210a98", + "https://deno.land/std@0.168.0/async/deferred.ts": "77d3f84255c3627f1cc88699d8472b664d7635990d5358c4351623e098e917d6", + "https://deno.land/std@0.168.0/async/delay.ts": "5a9bfba8de38840308a7a33786a0155a7f6c1f7a859558ddcec5fe06e16daf57", + "https://deno.land/std@0.168.0/async/mod.ts": "7809ad4bb223e40f5fdc043e5c7ca04e0e25eed35c32c3c32e28697c553fa6d9", + "https://deno.land/std@0.168.0/async/mux_async_iterator.ts": "770a0ff26c59f8bbbda6b703a2235f04e379f73238e8d66a087edc68c2a2c35f", + "https://deno.land/std@0.168.0/async/pool.ts": "6854d8cd675a74c73391c82005cbbe4cc58183bddcd1fbbd7c2bcda42b61cf69", + "https://deno.land/std@0.168.0/async/retry.ts": "e8e5173623915bbc0ddc537698fa418cf875456c347eda1ed453528645b42e67", + "https://deno.land/std@0.168.0/async/tee.ts": "3a47cc4e9a940904fd4341f0224907e199121c80b831faa5ec2b054c6d2eff5e", + "https://deno.land/std@0.168.0/http/server.ts": "e99c1bee8a3f6571ee4cdeb2966efad465b8f6fe62bec1bdb59c1f007cc4d155", + "https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", + "https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1", + "https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774", + "https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", + "https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e" + } +} diff --git a/supabase/functions/sync-artist-data/deno.json b/supabase/functions/sync-artist-data/deno.json new file mode 100644 index 00000000..38af4024 --- /dev/null +++ b/supabase/functions/sync-artist-data/deno.json @@ -0,0 +1,3 @@ +{ + "nodeModulesDir": "none" +} diff --git a/supabase/functions/sync-artist-data/deno.lock b/supabase/functions/sync-artist-data/deno.lock new file mode 100644 index 00000000..9535d81a --- /dev/null +++ b/supabase/functions/sync-artist-data/deno.lock @@ -0,0 +1,52 @@ +{ + "version": "5", + "redirects": { + "https://esm.sh/@supabase/node-fetch@^2.6.13?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", + "https://esm.sh/@supabase/node-fetch@^2.6.14?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", + "https://esm.sh/@supabase/supabase-js@2": "https://esm.sh/@supabase/supabase-js@2.57.4", + "https://esm.sh/tr46@~0.0.3?target=denonext": "https://esm.sh/tr46@0.0.3?target=denonext", + "https://esm.sh/webidl-conversions@^3.0.0?target=denonext": "https://esm.sh/webidl-conversions@3.0.1?target=denonext", + "https://esm.sh/whatwg-url@^5.0.0?target=denonext": "https://esm.sh/whatwg-url@5.0.0?target=denonext" + }, + "remote": { + "https://deno.land/std@0.168.0/async/abortable.ts": "80b2ac399f142cc528f95a037a7d0e653296352d95c681e284533765961de409", + "https://deno.land/std@0.168.0/async/deadline.ts": "2c2deb53c7c28ca1dda7a3ad81e70508b1ebc25db52559de6b8636c9278fd41f", + "https://deno.land/std@0.168.0/async/debounce.ts": "60301ffb37e730cd2d6f9dadfd0ecb2a38857681bd7aaf6b0a106b06e5210a98", + "https://deno.land/std@0.168.0/async/deferred.ts": "77d3f84255c3627f1cc88699d8472b664d7635990d5358c4351623e098e917d6", + "https://deno.land/std@0.168.0/async/delay.ts": "5a9bfba8de38840308a7a33786a0155a7f6c1f7a859558ddcec5fe06e16daf57", + "https://deno.land/std@0.168.0/async/mod.ts": "7809ad4bb223e40f5fdc043e5c7ca04e0e25eed35c32c3c32e28697c553fa6d9", + "https://deno.land/std@0.168.0/async/mux_async_iterator.ts": "770a0ff26c59f8bbbda6b703a2235f04e379f73238e8d66a087edc68c2a2c35f", + "https://deno.land/std@0.168.0/async/pool.ts": "6854d8cd675a74c73391c82005cbbe4cc58183bddcd1fbbd7c2bcda42b61cf69", + "https://deno.land/std@0.168.0/async/retry.ts": "e8e5173623915bbc0ddc537698fa418cf875456c347eda1ed453528645b42e67", + "https://deno.land/std@0.168.0/async/tee.ts": "3a47cc4e9a940904fd4341f0224907e199121c80b831faa5ec2b054c6d2eff5e", + "https://deno.land/std@0.168.0/http/server.ts": "e99c1bee8a3f6571ee4cdeb2966efad465b8f6fe62bec1bdb59c1f007cc4d155", + "https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", + "https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1", + "https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774", + "https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", + "https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e", + "https://esm.sh/@supabase/auth-js@2.71.1/denonext/auth-js.mjs": "d55f67342e652b8bdce35b0ff13ad5cc294b7e96dbd68f859b464b07c6864967", + "https://esm.sh/@supabase/functions-js@2.4.6/denonext/functions-js.mjs": "d6cc049a0430f428ff0b71a0d3c48d45a243ddd48c68febcdb5cb8a02476a1dc", + "https://esm.sh/@supabase/node-fetch@2.6.15/denonext/node-fetch.mjs": "0bae9052231f4f6dbccc7234d05ea96923dbf967be12f402764580b6bf9f713d", + "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext": "4d28c4ad97328403184353f68434f2b6973971507919e9150297413664919cf3", + "https://esm.sh/@supabase/postgrest-js@1.21.4/denonext/postgrest-js.mjs": "c3769b11ef02debc78ecf6ab4e152d3cf7dbd05bbbafeb72c160e76cc57cda3c", + "https://esm.sh/@supabase/realtime-js@2.15.5/denonext/realtime-js.mjs": "518bdc73c29b502ba4dcf7ce2dff0ff8c1cbd8e5978f7ea2435af8214ea45dd5", + "https://esm.sh/@supabase/storage-js@2.12.1/denonext/storage-js.mjs": "7a5a47546486972c0627b620e7413300b4e82ac6e26b53d2c31933e13c2d652e", + "https://esm.sh/@supabase/supabase-js@2.57.4": "05a369085eb4a4c99d85ccece97f0cf1e05357122e0e74373da1f0e91b014902", + "https://esm.sh/@supabase/supabase-js@2.57.4/denonext/supabase-js.mjs": "b31f4ec51272218b68cfdcef9de5aa7abd0f1da1262fa0b9377c62eb18fe494b", + "https://esm.sh/tr46@0.0.3/denonext/tr46.mjs": "5753ec0a99414f4055f0c1f97691100f13d88e48a8443b00aebb90a512785fa2", + "https://esm.sh/tr46@0.0.3?target=denonext": "19cb9be0f0d418a0c3abb81f2df31f080e9540a04e43b0f699bce1149cba0cbb", + "https://esm.sh/webidl-conversions@3.0.1/denonext/webidl-conversions.mjs": "54b5c2d50a294853c4ccebf9d5ed8988c94f4e24e463d84ec859a866ea5fafec", + "https://esm.sh/webidl-conversions@3.0.1?target=denonext": "4e20318d50528084616c79d7b3f6e7f0fe7b6d09013bd01b3974d7448d767e29", + "https://esm.sh/whatwg-url@5.0.0/denonext/whatwg-url.mjs": "29b16d74ee72624c915745bbd25b617cfd2248c6af0f5120d131e232a9a9af79", + "https://esm.sh/whatwg-url@5.0.0?target=denonext": "f001a2cadf81312d214ca330033f474e74d81a003e21e8c5d70a1f46dc97b02d" + } +} diff --git a/supabase/migrations/20260522000000_commit_schedule_rpc.sql b/supabase/migrations/20260522000000_commit_schedule_rpc.sql new file mode 100644 index 00000000..d75e154f --- /dev/null +++ b/supabase/migrations/20260522000000_commit_schedule_rpc.sql @@ -0,0 +1,388 @@ +-- Helpers for commit_schedule. Named with the commit_schedule__ prefix so it +-- is obvious they're internal to that RPC. +-- +-- The ON CONFLICT clauses below rely on artists_slug_unique and +-- stages_edition_name_unique. The constraints are added in the next two +-- migrations (20260522000001, 20260522000002); ON CONFLICT is resolved at +-- function-call time, not at CREATE FUNCTION time, so the ordering is fine. + +CREATE OR REPLACE FUNCTION public.commit_schedule__slugify(p_name TEXT) +RETURNS TEXT +LANGUAGE sql +IMMUTABLE +SET search_path = public +AS $$ + -- Matches src/lib/slug.ts generateSlug and diff-schedule's toSlug: + -- replace non-alphanumeric runs with a single hyphen, trim, collapse. + SELECT TRIM( + BOTH '-' FROM + REGEXP_REPLACE( + REGEXP_REPLACE(LOWER(TRIM(p_name)), '[^a-z0-9]+', '-', 'g'), + '-+', '-', 'g' + ) + ); +$$; + +CREATE OR REPLACE FUNCTION public.commit_schedule__resolve_stage_id( + p_festival_edition_id UUID, + p_stage_name TEXT +) +RETURNS UUID +LANGUAGE plpgsql +STABLE +SET search_path = public +AS $$ +DECLARE + v_stage_id UUID; +BEGIN + IF p_stage_name IS NULL THEN + RETURN NULL; + END IF; + + SELECT s.id + INTO v_stage_id + FROM stages s + WHERE s.festival_edition_id = p_festival_edition_id + AND s.name = p_stage_name + AND s.archived = false + LIMIT 1; + + IF v_stage_id IS NULL THEN + RAISE EXCEPTION 'Stage % not found in edition %', p_stage_name, p_festival_edition_id; + END IF; + + RETURN v_stage_id; +END; +$$; + +-- Treat NULL, '' and whitespace-only as "no timestamp" so a malformed caller +-- (the RPC is reachable outside the Edge Function) can't abort the whole +-- transaction with a cast error on an empty string. +-- STABLE, not IMMUTABLE: text -> timestamptz depends on the session TimeZone +-- when the input carries no explicit offset. +CREATE OR REPLACE FUNCTION public.commit_schedule__parse_ts(p_value TEXT) +RETURNS TIMESTAMPTZ +LANGUAGE sql +STABLE +AS $$ + SELECT NULLIF(TRIM(p_value), '')::TIMESTAMPTZ; +$$; + +-- Upsert artists in the import payload. The diff step only loads +-- archived = false artists, so if a slug collides with an existing archived +-- artist the CSV row was treated as new. Update the name AND unarchive so +-- sets aren't linked to a hidden artist. added_by is required (NOT NULL) and +-- attributes the create to the importing user. +CREATE OR REPLACE FUNCTION public.commit_schedule__upsert_artists( + p_artists_to_create JSONB, + p_user_id UUID +) +RETURNS VOID +LANGUAGE sql +SET search_path = public +AS $$ + INSERT INTO artists (name, slug, added_by) + SELECT elem->>'name', elem->>'slug', p_user_id + FROM jsonb_array_elements(COALESCE(p_artists_to_create, '[]'::jsonb)) AS elem + ON CONFLICT (slug) DO UPDATE + SET name = EXCLUDED.name, + archived = false; +$$; + +-- Upsert stages in the import payload. The diff step only loads +-- archived = false stages, so an archived stage with the same (edition, name) +-- looks new to the diff; DO UPDATE unarchives it rather than leaving sets +-- pointed at a hidden stage. +-- +-- Matching on name alone is enough: slugify and the diff's strip() both +-- collapse non-alphanumerics, so any two names that would collide on +-- (edition, slug) also strip-collide and are flagged by the diff as a +-- mismatch -- they never reach here as a plain new stage. +CREATE OR REPLACE FUNCTION public.commit_schedule__upsert_stages( + p_festival_edition_id UUID, + p_stages_to_create JSONB +) +RETURNS VOID +LANGUAGE sql +SET search_path = public +AS $$ + INSERT INTO stages (festival_edition_id, name, slug) + SELECT + p_festival_edition_id, + elem->>'name', + commit_schedule__slugify(elem->>'name') + FROM jsonb_array_elements(COALESCE(p_stages_to_create, '[]'::jsonb)) AS elem + ON CONFLICT (festival_edition_id, name) DO UPDATE + SET archived = false; +$$; + +CREATE OR REPLACE FUNCTION public.commit_schedule__sync_set_artists( + p_set_id UUID, + p_festival_edition_id UUID, + p_artist_slugs JSONB +) +RETURNS VOID +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_input_count INT; + v_resolved_count INT; +BEGIN + -- A set must have at least one artist. A NULL/empty roster means a bad + -- payload (omitted field, manual call) — bail before the DELETE below + -- silently strips the set's roster. + IF p_artist_slugs IS NULL + OR jsonb_typeof(p_artist_slugs) <> 'array' + OR jsonb_array_length(p_artist_slugs) = 0 THEN + RAISE EXCEPTION 'Empty artist roster in payload for set %', p_set_id; + END IF; + + -- Validate that every distinct input slug resolves to an artist before we + -- delete the existing links. The diff path is supposed to create missing + -- artists in step 1 of commit_schedule, so a mismatch means a bad payload + -- (typo, race, manual call) — bail loudly rather than silently producing + -- a set with a partial roster. + SELECT COUNT(DISTINCT slug_val) + INTO v_input_count + FROM jsonb_array_elements_text(p_artist_slugs) AS slug_val; + + SELECT COUNT(DISTINCT a.id) + INTO v_resolved_count + FROM jsonb_array_elements_text(p_artist_slugs) AS slug_val + JOIN artists a ON a.slug = slug_val; + + IF v_resolved_count <> v_input_count THEN + RAISE EXCEPTION + 'Unknown artist slug(s) in payload for set % (got % distinct slugs, resolved %)', + p_set_id, v_input_count, v_resolved_count; + END IF; + + -- Edition-scoped delete defends against a forged set id even if the caller + -- already verified it. + DELETE FROM set_artists sa + USING sets s + WHERE sa.set_id = s.id + AND s.id = p_set_id + AND s.festival_edition_id = p_festival_edition_id; + + INSERT INTO set_artists (set_id, artist_id) + SELECT p_set_id, a.id + FROM jsonb_array_elements_text(p_artist_slugs) AS slug_val + JOIN artists a ON a.slug = slug_val + ON CONFLICT (set_id, artist_id) DO NOTHING; +END; +$$; + +-- Update existing sets from the payload, re-syncing each set's artist roster. +-- Raises if a payload id doesn't match a set in the edition. Returns the +-- number of sets updated. +-- +-- stage_id/time_start/time_end are preserved when the payload omits them +-- (resolves to NULL): a CSV without Date/Time columns corrects names and +-- rosters without wiping schedule metadata already on the matched sets. +CREATE OR REPLACE FUNCTION public.commit_schedule__update_sets( + p_festival_edition_id UUID, + p_sets_to_update JSONB +) +RETURNS INT +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_set_elem JSONB; + v_set_id UUID; + v_row_count INT; + v_updated INT := 0; +BEGIN + FOR v_set_elem IN + SELECT value FROM jsonb_array_elements(COALESCE(p_sets_to_update, '[]'::jsonb)) + LOOP + v_set_id := (v_set_elem->>'id')::UUID; + + UPDATE sets + SET + name = v_set_elem->>'name', + description = NULLIF(v_set_elem->>'description', ''), + stage_id = COALESCE( + commit_schedule__resolve_stage_id( + p_festival_edition_id, v_set_elem->>'stageName' + ), + sets.stage_id + ), + time_start = COALESCE( + commit_schedule__parse_ts(v_set_elem->>'timeStart'), sets.time_start + ), + time_end = COALESCE( + commit_schedule__parse_ts(v_set_elem->>'timeEnd'), sets.time_end + ), + updated_at = NOW() + WHERE id = v_set_id + AND festival_edition_id = p_festival_edition_id; + + GET DIAGNOSTICS v_row_count = ROW_COUNT; + + IF v_row_count = 0 THEN + RAISE EXCEPTION 'Set % not found in edition %', v_set_id, p_festival_edition_id; + END IF; + + v_updated := v_updated + v_row_count; + + PERFORM commit_schedule__sync_set_artists( + v_set_id, p_festival_edition_id, v_set_elem->'artistSlugs' + ); + END LOOP; + + RETURN v_updated; +END; +$$; + +-- Insert new sets from the payload and sync each set's artist roster. +-- Returns the number of sets created. +CREATE OR REPLACE FUNCTION public.commit_schedule__create_sets( + p_festival_edition_id UUID, + p_user_id UUID, + p_sets_to_create JSONB +) +RETURNS INT +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_set_elem JSONB; + v_new_set_id UUID; + v_created INT := 0; +BEGIN + FOR v_set_elem IN + SELECT value FROM jsonb_array_elements(COALESCE(p_sets_to_create, '[]'::jsonb)) + LOOP + INSERT INTO sets ( + festival_edition_id, name, slug, description, stage_id, + time_start, time_end, created_by + ) + VALUES ( + p_festival_edition_id, + v_set_elem->>'name', + commit_schedule__slugify(v_set_elem->>'name'), + NULLIF(v_set_elem->>'description', ''), + commit_schedule__resolve_stage_id( + p_festival_edition_id, v_set_elem->>'stageName' + ), + commit_schedule__parse_ts(v_set_elem->>'timeStart'), + commit_schedule__parse_ts(v_set_elem->>'timeEnd'), + p_user_id + ) + RETURNING id INTO v_new_set_id; + + -- Always suffix the slug with a short id chunk so two sets with the same + -- name (common when an artist plays multiple days) don't collide on the + -- (edition, slug) lookup used by the set detail pages. + UPDATE sets + SET slug = slug || '-' || SUBSTRING(v_new_set_id::text, 1, 8) + WHERE id = v_new_set_id; + + v_created := v_created + 1; + + PERFORM commit_schedule__sync_set_artists( + v_new_set_id, p_festival_edition_id, v_set_elem->'artistSlugs' + ); + END LOOP; + + RETURN v_created; +END; +$$; + +-- Archive sets the diff flagged as orphaned (present in the DB, absent from +-- the CSV). Returns the number of sets archived. +CREATE OR REPLACE FUNCTION public.commit_schedule__archive_sets( + p_festival_edition_id UUID, + p_set_ids_to_archive UUID[] +) +RETURNS INT +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_archived INT := 0; +BEGIN + IF p_set_ids_to_archive IS NOT NULL + AND array_length(p_set_ids_to_archive, 1) > 0 THEN + UPDATE sets + SET archived = true, updated_at = NOW() + WHERE id = ANY(p_set_ids_to_archive) + AND festival_edition_id = p_festival_edition_id; + + GET DIAGNOSTICS v_archived = ROW_COUNT; + END IF; + + RETURN v_archived; +END; +$$; + +-- RPC: commit_schedule +-- Executes a fully resolved schedule import inside a single transaction. +-- Called by the commit-schedule Edge Function using the service role key. +CREATE OR REPLACE FUNCTION public.commit_schedule( + p_festival_edition_id UUID, + p_user_id UUID, + p_artists_to_create JSONB, -- [{ name, slug }] + p_stages_to_create JSONB, -- [{ name }] + p_sets_to_create JSONB, -- [{ name, description, stageName, timeStart, timeEnd, artistSlugs }] + p_sets_to_update JSONB, -- [{ id, name, description, stageName, timeStart, timeEnd, artistSlugs }] + p_set_ids_to_archive UUID[] +) +RETURNS JSONB +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_sets_created INT; + v_sets_updated INT; + v_sets_archived INT; +BEGIN + PERFORM commit_schedule__upsert_artists(p_artists_to_create, p_user_id); + PERFORM commit_schedule__upsert_stages(p_festival_edition_id, p_stages_to_create); + + v_sets_updated := commit_schedule__update_sets( + p_festival_edition_id, p_sets_to_update + ); + v_sets_created := commit_schedule__create_sets( + p_festival_edition_id, p_user_id, p_sets_to_create + ); + v_sets_archived := commit_schedule__archive_sets( + p_festival_edition_id, p_set_ids_to_archive + ); + + RETURN jsonb_build_object( + 'setsCreated', v_sets_created, + 'setsUpdated', v_sets_updated, + 'setsArchived', v_sets_archived + ); +END; +$$; + +-- commit_schedule and its helpers are only meant to be invoked by the +-- commit-schedule Edge Function (service role). Postgres grants EXECUTE to +-- PUBLIC by default; revoke that so an authenticated PostgREST client can't +-- call the RPC directly and bypass the Edge Function's admin-only gate. +REVOKE EXECUTE ON FUNCTION public.commit_schedule__slugify(TEXT) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.commit_schedule__resolve_stage_id(UUID, TEXT) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.commit_schedule__parse_ts(TEXT) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.commit_schedule__upsert_artists(JSONB, UUID) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.commit_schedule__upsert_stages(UUID, JSONB) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.commit_schedule__sync_set_artists(UUID, UUID, JSONB) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.commit_schedule__update_sets(UUID, JSONB) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.commit_schedule__create_sets(UUID, UUID, JSONB) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.commit_schedule__archive_sets(UUID, UUID[]) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.commit_schedule(UUID, UUID, JSONB, JSONB, JSONB, JSONB, UUID[]) FROM PUBLIC; + +GRANT EXECUTE ON FUNCTION public.commit_schedule__slugify(TEXT) TO service_role; +GRANT EXECUTE ON FUNCTION public.commit_schedule__resolve_stage_id(UUID, TEXT) TO service_role; +GRANT EXECUTE ON FUNCTION public.commit_schedule__parse_ts(TEXT) TO service_role; +GRANT EXECUTE ON FUNCTION public.commit_schedule__upsert_artists(JSONB, UUID) TO service_role; +GRANT EXECUTE ON FUNCTION public.commit_schedule__upsert_stages(UUID, JSONB) TO service_role; +GRANT EXECUTE ON FUNCTION public.commit_schedule__sync_set_artists(UUID, UUID, JSONB) TO service_role; +GRANT EXECUTE ON FUNCTION public.commit_schedule__update_sets(UUID, JSONB) TO service_role; +GRANT EXECUTE ON FUNCTION public.commit_schedule__create_sets(UUID, UUID, JSONB) TO service_role; +GRANT EXECUTE ON FUNCTION public.commit_schedule__archive_sets(UUID, UUID[]) TO service_role; +GRANT EXECUTE ON FUNCTION public.commit_schedule(UUID, UUID, JSONB, JSONB, JSONB, JSONB, UUID[]) TO service_role; diff --git a/supabase/migrations/20260522000001_add_artists_slug_unique.sql b/supabase/migrations/20260522000001_add_artists_slug_unique.sql new file mode 100644 index 00000000..1649216b --- /dev/null +++ b/supabase/migrations/20260522000001_add_artists_slug_unique.sql @@ -0,0 +1,31 @@ +-- Add unique constraint on artists.slug. +-- Required by commit_schedule's ON CONFLICT (slug) upsert, but the constraint +-- itself is a table-wide invariant so it lives in its own migration. +-- +-- Dedupe first: append the full id (guaranteed unique) to any slug with +-- collisions, keeping the canonical row on its original slug. Order by +-- archived ASC so an active artist keeps the slug and an archived duplicate +-- is the one rewritten -- otherwise slug-based links to the active row break. +UPDATE public.artists a +SET slug = a.slug || '-' || a.id::text +WHERE a.id IN ( + SELECT id + FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY slug ORDER BY archived ASC, id) AS rn + FROM public.artists + ) ranked + WHERE rn > 1 +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'artists_slug_unique' + AND conrelid = 'public.artists'::regclass + ) THEN + ALTER TABLE public.artists + ADD CONSTRAINT artists_slug_unique UNIQUE (slug); + END IF; +END$$; diff --git a/supabase/migrations/20260522000002_add_stages_edition_name_unique.sql b/supabase/migrations/20260522000002_add_stages_edition_name_unique.sql new file mode 100644 index 00000000..fce2eb11 --- /dev/null +++ b/supabase/migrations/20260522000002_add_stages_edition_name_unique.sql @@ -0,0 +1,32 @@ +-- Add unique constraint on stages(festival_edition_id, name). +-- Required by commit_schedule's ON CONFLICT (festival_edition_id, name) upsert. +-- PR #28 introduces an equivalent constraint named stages_name_festival_edition_id_key; +-- if that lands first this migration becomes a no-op. +-- +-- Dedupe first: any (edition, name) collisions get the offending row's id +-- suffixed onto the stage name. Order by archived ASC so an active stage +-- keeps the canonical name and an archived duplicate is the one renamed. +UPDATE public.stages s +SET name = s.name || ' (' || s.id::text || ')' +WHERE s.id IN ( + SELECT id + FROM ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY festival_edition_id, name ORDER BY archived ASC, id) AS rn + FROM public.stages + ) ranked + WHERE rn > 1 +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname IN ('stages_edition_name_unique', 'stages_name_festival_edition_id_key') + AND conrelid = 'public.stages'::regclass + ) THEN + ALTER TABLE public.stages + ADD CONSTRAINT stages_edition_name_unique UNIQUE (festival_edition_id, name); + END IF; +END$$; diff --git a/vitest.config.ts b/vitest.config.ts index e8ed08e3..7520c2f2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ "**/.{idea,git,cache,output,temp}/**", "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*", "**/tests/e2e/**", // Exclude Playwright E2E tests + "supabase/**", // Exclude Deno-only Edge Function tests ], }, resolve: {