[
  {
    "path": ".all-contributorsrc",
    "content": "{\n  \"files\": [\n    \"README.md\"\n  ],\n  \"imageSize\": 60,\n  \"contributorsPerLine\": 7,\n  \"contributorsSortAlphabetically\": false,\n  \"badgeTemplate\": \"[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=flat-square)](#contributors)\",\n  \"types\": {\n    \"maintenance\": {\n      \"symbol\": \"💪\",\n      \"description\": \"Maintainer\",\n      \"link\": \"[<%= symbol %>](\\\"<%= description %>\\\"),\"\n    }\n  },\n  \"contributors\": [\n    {\n      \"login\": \"davehakkens\",\n      \"name\": \"Dave Hakkens\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/13672737?v=4\",\n      \"profile\": \"http://davehakkens.nl\",\n      \"contributions\": [\n        \"design\",\n        \"ideas\",\n        \"projectManagement\",\n        \"maintenance\"\n      ]\n    },\n    {\n      \"login\": \"chrismclarke\",\n      \"name\": \"Chris Clarke\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/10515065?v=4\",\n      \"profile\": \"https://c2dev.co.uk/\",\n      \"contributions\": [\n        \"code\",\n        \"maintenance\"\n      ]\n    },\n    {\n      \"login\": \"thisislawatts\",\n      \"name\": \"Luke Watts\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/472589?v=4\",\n      \"profile\": \"https://thisis.la/\",\n      \"contributions\": [\n        \"code\",\n        \"maintenance\"\n      ]\n    },\n    {\n      \"login\": \"amuroBosetti\",\n      \"name\": \"Mauro Bosetti\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/46928545?v=4\",\n      \"profile\": \"https://github.com/amuroBosetti\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"patrycjapraczyk\",\n      \"name\": \"patrycjapraczyk\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/35103888?v=4\",\n      \"profile\": \"https://github.com/patrycjapraczyk\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"tedspare\",\n      \"name\": \"Ted Spare\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/36117635?v=4\",\n      \"profile\": \"https://tedspare.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"eliasvelardezft\",\n      \"name\": \"Elias Velardez\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/40184787?v=4\",\n      \"profile\": \"https://www.linkedin.com/in/eliasvelardez\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"AlfonsoGhislieri\",\n      \"name\": \"Alfonso\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/652368?v=4\",\n      \"profile\": \"https://github.com/AlfonsoGhislieri\",\n      \"contributions\": [\n        \"code\",\n        \"maintenance\"\n      ]\n    },\n    {\n      \"login\": \"Xyli0\",\n      \"name\": \"Xyli0\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/10441748?v=4\",\n      \"profile\": \"https://github.com/Xyli0\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"laianbraum\",\n      \"name\": \"Laian Braum\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/61033391?v=4\",\n      \"profile\": \"http://www.linkedin.com/in/laianbraum\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"osouthwell-scottlogic\",\n      \"name\": \"osouthwell-scottlogic\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/98388720?v=4\",\n      \"profile\": \"https://github.com/osouthwell-scottlogic\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"asheerrizvi\",\n      \"name\": \"Asheer Rizvi\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/17976252?v=4\",\n      \"profile\": \"http://asheerrizvi.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"franknoirot\",\n      \"name\": \"Frank Noirot\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/23481541?v=4\",\n      \"profile\": \"https://franknoirot.co\",\n      \"contributions\": [\n        \"design\"\n      ]\n    },\n    {\n      \"login\": \"LucasGabrielBecker\",\n      \"name\": \"Lucas Becker \",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/48301172?v=4\",\n      \"profile\": \"https://github.com/LucasGabrielBecker\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"cschilbe\",\n      \"name\": \"Conrad Schilbe\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/485557?v=4\",\n      \"profile\": \"https://github.com/cschilbe\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ThakurKarthik\",\n      \"name\": \"Thakur Karthik\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/26309938?v=4\",\n      \"profile\": \"https://github.com/ThakurKarthik\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"danitrod\",\n      \"name\": \"Daniel T. Rodrigues\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/45438149?v=4\",\n      \"profile\": \"https://www.linkedin.com/in/danitrod/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"adrianduke\",\n      \"name\": \"Adrian Duke\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/711058?v=4\",\n      \"profile\": \"https://github.com/adrianduke\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"missalyss\",\n      \"name\": \"Alyssa Helgason\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/19866110?v=4\",\n      \"profile\": \"https://github.com/missalyss\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Kiebert\",\n      \"name\": \"Kieb\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/3414938?v=4\",\n      \"profile\": \"https://github.com/Kiebert\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Sc4ramouche\",\n      \"name\": \"Kovechenkov Vladislav\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/25829136?v=4\",\n      \"profile\": \"https://github.com/Sc4ramouche\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"cerkiewny\",\n      \"name\": \"Devtato\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/4504330?v=4\",\n      \"profile\": \"http://devtato.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"NoHara42\",\n      \"name\": \"Ned O'Hara\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/43496778?v=4\",\n      \"profile\": \"https://github.com/NoHara42\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"SophXN\",\n      \"name\": \"Sophia Nguyen\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/80185757?v=4\",\n      \"profile\": \"https://github.com/SophXN\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"evakill\",\n      \"name\": \"Eva Killenberg\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/37253846?v=4\",\n      \"profile\": \"https://www.evakillenberg.com\",\n      \"contributions\": [\n        \"code\",\n        \"maintenance\"\n      ]\n    },\n    {\n      \"login\": \"iSCJT\",\n      \"name\": \"Sean Thompson\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/80723794?v=4\",\n      \"profile\": \"https://speckledbanana.com\",\n      \"contributions\": [\n        \"code\",\n        \"maintenance\"\n      ]\n    },\n    {\n      \"login\": \"NguyenVanDo51\",\n      \"name\": \"Nguyễn Văn Đỏ\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/30190734?v=4\",\n      \"profile\": \"https://github.com/NguyenVanDo51\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"KungRaseri\",\n      \"name\": \"KungRaseri\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1054240?v=4\",\n      \"profile\": \"https://kungraseri.dev\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"BaltacMihai\",\n      \"name\": \"Mihai-Cristian Bâltac\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/72079422?v=4\",\n      \"profile\": \"https://github.com/BaltacMihai\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"CDeighton\",\n      \"name\": \"Cullum Deighton\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/13475443?v=4\",\n      \"profile\": \"https://github.com/CDeighton\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"d-skowronski\",\n      \"name\": \"Dawid Skowroński\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/98740166?v=4\",\n      \"profile\": \"https://github.com/d-skowronski\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jonboiser\",\n      \"name\": \"Jonathan Boiser\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/10248067?v=4\",\n      \"profile\": \"http://jonboiser.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"benfurber\",\n      \"name\": \"benfurber\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/16688508?v=4\",\n      \"profile\": \"https://github.com/benfurber\",\n      \"contributions\": [\n        \"code\",\n        \"maintenance\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"AlimurtuzaCodes\",\n      \"name\": \"Alimurtuza\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/88965204?v=4\",\n      \"profile\": \"https://github.com/AlimurtuzaCodes\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"AliAbuSalam\",\n      \"name\": \"Askell\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/17426615?v=4\",\n      \"profile\": \"https://github.com/AliAbuSalam\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"manacespereira\",\n      \"name\": \"Manacés Pereira\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/8915867?v=4\",\n      \"profile\": \"https://linkedin.com/in/manacesneto\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"5niperspider\",\n      \"name\": \"Georg Karl\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/62932392?v=4\",\n      \"profile\": \"https://github.com/5niperspider\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"asdFletcher\",\n      \"name\": \"asdFletcher\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/42685363?v=4\",\n      \"profile\": \"https://www.linkedin.com/in/fletcher-larue/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"bagiyal\",\n      \"name\": \"Abhishek Bagiyal\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/63339447?v=4\",\n      \"profile\": \"https://github.com/bagiyal\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"CrowsVeldt\",\n      \"name\": \"Zack\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/8883408?v=4\",\n      \"profile\": \"https://github.com/CrowsVeldt\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"benkelaar\",\n      \"name\": \"Bart Enkelaar\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1822855?v=4\",\n      \"profile\": \"https://careers.bol.com/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"laviodias\",\n      \"name\": \"Lávio Vale\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/44332001?v=4\",\n      \"profile\": \"https://github.com/laviodias\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"manuelrurda\",\n      \"name\": \"Manuel Rodriguez Urdapilelta\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/62727899?v=4\",\n      \"profile\": \"https://github.com/manuelrurda\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"LiptonB\",\n      \"name\": \"Ben Lipton\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/467965?v=4\",\n      \"profile\": \"https://github.com/LiptonB\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ayachish\",\n      \"name\": \"Ayachi Sharma\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/102033230?v=4\",\n      \"profile\": \"https://github.com/ayachish\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"arthurtyukayev\",\n      \"name\": \"Arthur Tyukayev\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/9029936?v=4\",\n      \"profile\": \"https://tyukayev.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jgable\",\n      \"name\": \"Jacob Gable\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/164497?v=4\",\n      \"profile\": \"https://github.com/jgable\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"BeeMargarida\",\n      \"name\": \"Ana Margarida Silva\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/25725586?v=4\",\n      \"profile\": \"https://beemargarida.github.io\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"cjh1212\",\n      \"name\": \"cjh1212\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/45911291?v=4\",\n      \"profile\": \"https://github.com/cjh1212\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"pizzaisdavid\",\n      \"name\": \"David Germain\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/4391884?v=4\",\n      \"profile\": \"https://pizzaisdavid.medium.com/\",\n      \"contributions\": [\n        \"doc\",\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ajotka\",\n      \"name\": \"AJOTKA\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/15144546?v=4\",\n      \"profile\": \"http://ajotka.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"CheRayLiu\",\n      \"name\": \"Ray Liu\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/17478640?v=4\",\n      \"profile\": \"http://rayliu.me\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Erkanerkisi\",\n      \"name\": \"Erkan Erkişi\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/22741824?v=4\",\n      \"profile\": \"http://erkanerkisi.github.io\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"denyilm\",\n      \"name\": \"denyilm\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/65300462?v=4\",\n      \"profile\": \"https://github.com/denyilm\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"zweertsk\",\n      \"name\": \"Koen\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/131855633?v=4\",\n      \"profile\": \"https://github.com/zweertsk\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"goratt12\",\n      \"name\": \"Guy Ribak\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/23094928?v=4\",\n      \"profile\": \"https://github.com/goratt12\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"onim-at\",\n      \"name\": \"Cosimo Chetta\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/45094836?v=4\",\n      \"profile\": \"https://github.com/onim-at\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"rchick\",\n      \"name\": \"Roger Chick\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/555883?v=4\",\n      \"profile\": \"http://uk.linkedin.com/in/rogerchick\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"IgnasPlace\",\n      \"name\": \"Ignas\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/76262712?v=4\",\n      \"profile\": \"http://ignasplace.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"mariojsnunes\",\n      \"name\": \"Mário Nunes\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/8073622?v=4\",\n      \"profile\": \"https://github.com/mariojsnunes\",\n      \"contributions\": [\n        \"code\",\n        \"maintenance\"\n      ]\n    },\n    {\n      \"login\": \"oktomus\",\n      \"name\": \"Kevin Masson\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/4656466?v=4\",\n      \"profile\": \"https://github.com/oktomus\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"darigovresearch\",\n      \"name\": \"Darigov Research\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/30328618?v=4\",\n      \"profile\": \"https://www.darigovresearch.com/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Shamauk\",\n      \"name\": \"Zachary Doucet\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/21955868?v=4\",\n      \"profile\": \"http://cs.mcgill.ca/~zdouce\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"viracoding\",\n      \"name\": \"viracoding\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/20618068?v=4\",\n      \"profile\": \"https://github.com/viracoding\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Gashmoh\",\n      \"name\": \"Gashmoh\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/24207256?v=4\",\n      \"profile\": \"https://github.com/Gashmoh\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"dariusmihut\",\n      \"name\": \"dariusmihut\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/7417010?v=4\",\n      \"profile\": \"https://github.com/dariusmihut\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"dcustodio\",\n      \"name\": \"David Custódio\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/2907004?v=4\",\n      \"profile\": \"https://github.com/dcustodio\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"saile515\",\n      \"name\": \"Elias Jörgensen\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/63782477?v=4\",\n      \"profile\": \"https://www.eliasjorgensen.se\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"devChary\",\n      \"name\": \"devChary\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/26999371?v=4\",\n      \"profile\": \"http://www.jagan-chary.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"paposeco\",\n      \"name\": \"Fabi\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/13892562?v=4\",\n      \"profile\": \"https://github.com/paposeco\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Robert-LC\",\n      \"name\": \"Robert\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/72999492?v=4\",\n      \"profile\": \"https://github.com/Robert-LC\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"phlapjack\",\n      \"name\": \"Phillip Atkinson\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1590042?v=4\",\n      \"profile\": \"https://github.com/phlapjack\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"exabyssus\",\n      \"name\": \"Andris\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6299387?v=4\",\n      \"profile\": \"http://agw.lv/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"EdwardAndress\",\n      \"name\": \"Edward Andress\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/7963978?v=4\",\n      \"profile\": \"https://github.com/EdwardAndress\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"tuliobluz\",\n      \"name\": \"Túlio Luz\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/21323883?v=4\",\n      \"profile\": \"https://github.com/tuliobluz\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"codisart\",\n      \"name\": \"Louis\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1767237?v=4\",\n      \"profile\": \"https://github.com/codisart\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"V24039\",\n      \"name\": \"Venu G Soganadgi\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/52736045?v=4\",\n      \"profile\": \"https://venugsportfolio.netlify.app/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Augustindou\",\n      \"name\": \"Augustindou\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/44368825?v=4\",\n      \"profile\": \"https://github.com/Augustindou\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"prashik0202\",\n      \"name\": \"Prashik Gamre\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/88095936?v=4\",\n      \"profile\": \"https://prashikgamre.vercel.app/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"simontree\",\n      \"name\": \"Robert\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/59532700?v=4\",\n      \"profile\": \"https://github.com/simontree\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"kimskovhusandersen\",\n      \"name\": \"Kim Skovhus Andersen\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/5513342?v=4\",\n      \"profile\": \"https://github.com/kimskovhusandersen\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"leoasimon\",\n      \"name\": \"leoasimon\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/89898967?v=4\",\n      \"profile\": \"https://github.com/leoasimon\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"lucaasrojas\",\n      \"name\": \"Lucas Rojas\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/26610409?v=4\",\n      \"profile\": \"https://lucaasrojas-portfolio.vercel.app/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"luismgsantos\",\n      \"name\": \"Luís Santos\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/13033016?v=4\",\n      \"profile\": \"http://luismgsantos.github.io\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"koeppel\",\n      \"name\": \"Janik Köppel\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/12177323?v=4\",\n      \"profile\": \"https://github.com/koeppel\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Shalankwa\",\n      \"name\": \"Jonathan Goodman\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/31330598?v=4\",\n      \"profile\": \"https://github.com/Shalankwa\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"LahuenGR\",\n      \"name\": \"LahuenGR\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/101137877?v=4\",\n      \"profile\": \"https://github.com/LahuenGR\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"JoseAConcepcion\",\n      \"name\": \"José Antonio Concepción\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/99701565?v=4\",\n      \"profile\": \"https://github.com/JoseAConcepcion\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jaykayudo\",\n      \"name\": \"Joshua Kelechi\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/58009744?v=4\",\n      \"profile\": \"https://github.com/jaykayudo\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"mchen10\",\n      \"name\": \"Michael Chen\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/16161485?v=4\",\n      \"profile\": \"https://github.com/mchen10\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"motuz0001\",\n      \"name\": \"Matúš Motyka\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/61076969?v=4\",\n      \"profile\": \"https://github.com/motuz0001\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"cypherpepe\",\n      \"name\": \"Cypher Pepe\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/125112044?v=4\",\n      \"profile\": \"https://github.com/cypherpepe\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"dalibormrska\",\n      \"name\": \"Dalibor Mrška\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/35503298?v=4\",\n      \"profile\": \"https://www.behance.net/dalibormrska\",\n      \"contributions\": [\n        \"design\"\n      ]\n    },\n    {\n      \"login\": \"sky-coderay\",\n      \"name\": \"Skylar Ray\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/137945430?v=4\",\n      \"profile\": \"https://github.com/sky-coderay\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"johannes-ross\",\n      \"name\": \"Johannes Roß\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/74828657?v=4\",\n      \"profile\": \"http://johannesross.de\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"detrina\",\n      \"name\": \"Devkuni\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/155117116?v=4\",\n      \"profile\": \"https://github.com/detrina\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Bilogweb3\",\n      \"name\": \"Bilog WEB3\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/155262265?v=4\",\n      \"profile\": \"https://github.com/Bilogweb3\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"ebradbury\",\n      \"name\": \"Elliot Bradbury\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/253679?v=4\",\n      \"profile\": \"https://github.com/ebradbury\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"joseh29\",\n      \"name\": \"José\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/70706814?v=4\",\n      \"profile\": \"http://www.linkedin.com/in/joseh29\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"maximevtush\",\n      \"name\": \"Maxim Evtush\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/154841002?v=4\",\n      \"profile\": \"https://github.com/maximevtush\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"kilavvy\",\n      \"name\": \"kilavvy\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/140459108?v=4\",\n      \"profile\": \"https://github.com/kilavvy\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"mohitsharma23\",\n      \"name\": \"Mohit Sharma\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/32203733?v=4\",\n      \"profile\": \"https://github.com/mohitsharma23\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"paulaFenner\",\n      \"name\": \"Paula Fenner\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/18422622?v=4\",\n      \"profile\": \"https://github.com/paulaFenner\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ismaelabujadur\",\n      \"name\": \"Ismael Abu-jadur Garcia\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/112013216?v=4\",\n      \"profile\": \"http://ismaelabujadur.github.io\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"NickTheWilder\",\n      \"name\": \"Nick Wilder\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/38483425?v=4\",\n      \"profile\": \"https://nickthewilder.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"dizer-ti\",\n      \"name\": \"James Niken\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/155266991?v=4\",\n      \"profile\": \"https://github.com/dizer-ti\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"omahs\",\n      \"name\": \"omahs\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/73983677?v=4\",\n      \"profile\": \"https://github.com/omahs\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"GTaf\",\n      \"name\": \"GTaf\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/3484782?v=4\",\n      \"profile\": \"https://github.com/GTaf\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"cajohn2757\",\n      \"name\": \"Corey Johnson\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/71300075?v=4\",\n      \"profile\": \"https://github.com/cajohn2757\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"devianweb\",\n      \"name\": \"Ian Webster\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/87659522?v=4\",\n      \"profile\": \"https://github.com/devianweb\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"JCSergent\",\n      \"name\": \"JC Sergent\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/34434102?v=4\",\n      \"profile\": \"https://github.com/JCSergent\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"fel4-dev\",\n      \"name\": \"Fel4\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/94372355?v=4\",\n      \"profile\": \"https://github.com/fel4-dev\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"matteobu\",\n      \"name\": \"Matteo Bucciol\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/62759388?v=4\",\n      \"profile\": \"http://matteo.codes\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ernestorbemx\",\n      \"name\": \"ernestorbemx\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/204041962?v=4\",\n      \"profile\": \"https://github.com/ernestorbemx\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jproberson\",\n      \"name\": \"jproberson\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/50461518?v=4\",\n      \"profile\": \"https://github.com/jproberson\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Abhishek-singh88\",\n      \"name\": \"Abhishek Singh\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/177325053?v=4\",\n      \"profile\": \"https://abhishekdotsol.vercel.app/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"chris-staunton\",\n      \"name\": \"Chris Staunton\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/60261694?v=4\",\n      \"profile\": \"https://github.com/chris-staunton\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"rejected-l\",\n      \"name\": \"Rej Ect\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/99460023?v=4\",\n      \"profile\": \"https://github.com/rejected-l\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"othman-shamla\",\n      \"name\": \"othman-shamla\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/16326221?v=4\",\n      \"profile\": \"https://github.com/othman-shamla\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"rsgb\",\n      \"name\": \"RSGB\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/96707736?v=4\",\n      \"profile\": \"https://github.com/rsgb\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"GMetaxakis\",\n      \"name\": \"Georgios Metaxakis\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/4234419?v=4\",\n      \"profile\": \"https://github.com/GMetaxakis\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"tmchow\",\n      \"name\": \"Trevin Chow\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/517103?v=4\",\n      \"profile\": \"https://trev.in\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"elbotho\",\n      \"name\": \"Botho\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1258870?v=4\",\n      \"profile\": \"https://botho.cc\",\n      \"contributions\": [\n        \"code\"\n      ]\n    }\n  ],\n  \"projectName\": \"community-platform\",\n  \"projectOwner\": \"ONEARMY\",\n  \"repoType\": \"github\",\n  \"repoHost\": \"https://github.com\",\n  \"commitConvention\": \"angular\",\n  \"commitType\": \"docs\"\n}\n"
  },
  {
    "path": ".circleci/config.yml",
    "content": "version: 2.1\n######################################################################################################\n# Pre-Requisites\n#\n# In order to use these scripts various env variables need to be set on CircleCI\n# See `packages/documentation/docs/Deployment/circle-ci.md` for more information\n#\n# For general config info see: https://circleci.com/docs/2.0/configuration-reference\n######################################################################################################\n\n######################################################################################################\n#  Orbs - preconfigured environments for running specific jobs\n######################################################################################################\n\norbs:\n  # for use with cimg image, to install web browsers\n  browser-tools: circleci/browser-tools@1.4.5\n  # used to track coverage\n  codecov: codecov/codecov@3.2.5\n\n######################################################################################################\n#  Aliases - code snippets that can be included inline in any other markup\n######################################################################################################\naliases:\n  # use a base image running node v18 with chrome/firefox browsers preinstalled\n  # This can be applied to any job via `docker: *docker` syntax\n  - &docker\n    - image: cimg/node:22.18.0-browsers\n\n  # Use base image with support for node version parameter and matrix\n  # This can be applied to any job via `<<: *docker_matrix` syntax\n  - docker_matrix: &docker_matrix\n      parameters:\n        node-version:\n          type: string\n          default: 22.18.0-browsers\n      docker:\n        - image: cimg/node:<< parameters.node-version >>\n\n    # These can also be created as commands, but slighly tidier to just use inline\n    # restore/install/save can all be done with a single circle-ci orb, but less flexible and breaks intellisense\n  - &install_bun\n    run:\n      name: Install Bun\n      command: curl -fsSL https://bun.sh/install | bash && echo 'export PATH=\"$HOME/.bun/bin:$PATH\"' >> $BASH_ENV\n  - &restore_bun_cache\n    restore_cache:\n      name: Restore bun cache\n      keys:\n        - v1-bun-{{ checksum \"bun.lock\" }}\n        - v1-bun-\n  - &install_packages\n    run:\n      name: Install Packages\n      command: bun install --frozen-lockfile\n  - &save_bun_cache\n    save_cache:\n      paths:\n        - /home/circleci/.bun/install/cache\n      key: v1-bun-{{ checksum \"bun.lock\" }}\n\n  - &filter_only_default_branch\n    filters:\n      branches:\n        only:\n          - master\n\n######################################################################################################\n#  Commands - Reusable collections of steps\n######################################################################################################\ncommands:\n  setup_repo:\n    description: checkout repo and install packages\n    # no parameters currently used, but could be specified here to use within steps\n    # parameters:\n    steps:\n      - checkout\n      - *install_bun\n      - *restore_bun_cache\n      - *install_packages\n      - *save_bun_cache\n\n######################################################################################################\n#  Jobs - Independently specified lists of tasks and environments for execution\n######################################################################################################\njobs:\n  lint:\n    docker: *docker\n    resource_class: medium+\n    environment:\n      CYPRESS_INSTALL_BINARY: 0\n    steps:\n      - setup_repo\n      - run:\n          command: bun run lint\n      - run:\n          command: bun run --filter oa-components lint\n  # Prepare node module caches so that future tasks run more quickly\n  # NOTE - not currently used as we only have one workflow\n  setup:\n    docker: *docker\n    environment:\n      CYPRESS_INSTALL_BINARY: 0\n    steps:\n      - setup_repo\n\n  # Create a production build\n  # NOTE - not currently used in test workflow as different build_env required for each machine\n  test_unit:\n    docker: *docker\n    resource_class: medium+\n    environment:\n      CYPRESS_INSTALL_BINARY: 0\n    steps:\n      - setup_repo\n      - run:\n          # NOTE - run-in-band to try reduce memory leaks (https://github.com/facebook/jest/issues/7874)\n          command: bun run test:unit\n      - run:\n          command: bun run test:components\n      - store_artifacts:\n          path: coverage\n      - store_artifacts:\n          path: packages/components/coverage\n      - codecov/upload\n      - store_artifacts:\n          path: packages/components/reports\n      - store_test_results:\n          path: packages/components/reports\n      - store_artifacts:\n          path: reports\n      - store_test_results:\n          path: reports\n\n  build:\n    <<: *docker_matrix\n    environment:\n      GENERATE_SOURCEMAP: 'false'\n      SKIP_PREFLIGHT_CHECK: 'true'\n      NODE_OPTIONS: '--max-old-space-size=5632'\n      CYPRESS_INSTALL_BINARY: 0\n    # If experiencing out-of-memory issues can increase resource_class below and max space size above\n    # https://circleci.com/docs/2.0/configuration-reference/#resourceclass\n    resource_class: large\n    steps:\n      - setup_repo\n      # As environment variables can only be set from strings add additional dynamic variable mappings here\n      # https://discuss.circleci.com/t/using-environment-variables-in-config-yml-not-working/14237/13\n      - run:\n          name: Set branch environment\n          command: |\n            echo 'export VITE_SITE_VARIANT=test-ci' >> $BASH_ENV\n      - run:\n          name: Check environment variables\n          command: |\n            echo $VITE_SITE_VARIANT\n      - run:\n          command: bun run build\n      - persist_to_workspace:\n          root: .\n          paths:\n            - build\n  storybook:\n    docker: *docker\n    resource_class: medium\n    environment:\n      CYPRESS_INSTALL_BINARY: 0\n    steps:\n      - setup_repo\n      - attach_workspace:\n          at: '.'\n      - run:\n          command: bun run storybook:build\n  deploy:\n    docker:\n      - image: cimg/node:22.18.0\n    resource_class: medium+\n    parameters:\n      # optional environment variables to set during build process\n      DEPLOY_ALIAS:\n        type: string\n        default: 'default'\n      FLY_APP_NAME:\n        type: string\n        default: 'default'\n      FLY_TOML:\n        type: string\n        default: 'default'\n    environment:\n      CYPRESS_INSTALL_BINARY: 0\n    steps:\n      - setup_repo\n      - attach_workspace:\n          at: '.'\n      - run:\n          name: Prune Docker resources\n          command: |\n            docker system prune -a\n      - run:\n          name: Install fly command\n          command: curl -L https://fly.io/install.sh | sh -s -- --non-interactive --setup-path\n      - run:\n          name: Add fly to PATH\n          command: echo \"export PATH=\\\"/home/circleci/.fly/bin:$PATH\\\"\" >> $BASH_ENV\n      - run:\n          name: Login fly\n          command: flyctl auth token $FLY_API_TOKEN --debug --verbose\n      - run:\n          name: Deploy to fly\n          command: |\n            flyctl deploy \\\n              --app << parameters.FLY_APP_NAME >> \\\n              --config << parameters.FLY_TOML >> \\\n              --debug --verbose \\\n              --build-secret NODE_ENV=\"production\" \\\n              --build-secret VITE_BRANCH=\"$VITE_BRANCH\" \\\n              --build-secret VITE_SENTRY_DSN=\"$VITE_SENTRY_DSN\"\n      - run:\n          name: Set Server Secrets\n          command: flyctl -a << parameters.FLY_APP_NAME >> secrets set SUPABASE_API_URL=$SUPABASE_API_URL SUPABASE_KEY=$SUPABASE_KEY SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL RESEND_API_KEY=$RESEND_API_KEY TENANT_ID=$TENANT_ID PATREON_CLIENT_SECRET=$PATREON_CLIENT_SECRET TOKEN_SECRET=\"$TOKEN_SECRET\"\n  # deploy-supabase:\n  #   docker:\n  #     - image: cimg/node:20.7.0\n  #   steps:\n  #     - checkout\n  #     - attach_workspace:\n  #         at: '.'\n  #     - run: npx supabase@2.6.8 login --token $SUPABASE_ACCESS_TOKEN\n  #     - run: (yes || true) | npx supabase@2.6.8 db push --db-url $SUPABASE_DB_URL --debug --password $SUPABASE_DB_PASSWORD\n  # - run: npx supabase@2.6.8 functions deploy --no-verify-jwt\n  # Run cypress e2e tests on chrome\n  test_e2e:\n    docker: *docker\n    resource_class: medium+\n    # build matrix will run 4 parallel builds handled by cypress, so don't need to specify more here\n    parallelism: 1\n    parameters:\n      CI_NODE:\n        type: integer\n      CI_BROWSER:\n        type: string\n    steps:\n      - setup_repo\n      # retrieve build folder\n      - attach_workspace:\n          at: '.'\n      # install testing browsers are required\n      - when:\n          condition:\n            equal: ['chrome', << parameters.CI_BROWSER >>]\n          steps:\n            - browser-tools/install-chrome\n      - when:\n          condition:\n            equal: ['firefox', << parameters.CI_BROWSER >>]\n          steps:\n            - browser-tools/install-firefox\n      # call main testing script\n      - run:\n          command: npm run test ci prod\n          environment:\n            VITE_SITE_VARIANT: test-ci\n            CI_BROWSER: << parameters.CI_BROWSER >>\n            CI_NODE: << parameters.CI_NODE >>\n            CI_GROUP: ci-<< parameters.CI_BROWSER >>\n      - store_artifacts:\n          path: ./packages/cypress/src/screenshots/\n\n  release:\n    docker: *docker\n    resource_class: medium+\n    environment:\n      CYPRESS_INSTALL_BINARY: 0\n    steps:\n      - setup_repo\n      - attach_workspace:\n          at: '.'\n      - run:\n          command: npx semantic-release@22\n\n######################################################################################################\n#  Workflows - Collections of jobs to define overall processes\n######################################################################################################\nworkflows:\n  version: 2\n  main_workflow:\n    max_auto_reruns: 1\n    # by default jobs will run concurrently, so specify requires if want to run sequentially\n    jobs:\n      - lint:\n          name: Lint code\n      #---------------------- Test ----------------------\n      # Note - when calling test we also let the test script handle building as it injects random variables for seeding the DB\n      - build:\n          requires:\n            - 'Lint code'\n          name: Build Application\n      - test_unit:\n          name: 'Unit tests'\n          requires:\n            - 'Lint code'\n      - storybook:\n          name: Build Storybook\n          requires:\n            - 'Lint code'\n      - test_e2e:\n          name: e2e-<< matrix.CI_BROWSER >>-<< matrix.CI_NODE >>\n          requires:\n            - 'Build Application'\n            - 'Unit tests'\n            - 'Build Storybook'\n          context:\n            - e2e-tests\n          matrix:\n            parameters:\n              CI_NODE: [1, 2, 3, 4]\n              CI_BROWSER: ['chrome']\n      #---------------------- Approval ----------------------\n      - approve:\n          type: approval\n          name: 'Approve Production deployment'\n          requires:\n            - test_e2e\n          <<: *filter_only_default_branch\n      #---------------------- Deploy ----------------------\n      # - deploy-supabase:\n      #     name: 'Deploy to Supabase'\n      #     requires:\n      #       - 'Approve Production deployment'\n      #     <<: *filter_only_default_branch\n      #     context:\n      #       - supabase-deploy\n      - deploy:\n          name: 'Deploy: community.fixing.fashion'\n          requires:\n            - 'Approve Production deployment'\n          <<: *filter_only_default_branch\n          DEPLOY_ALIAS: fixing-fashion-prod\n          FLY_APP_NAME: community-platform-ff\n          FLY_TOML: fly-ff.toml\n          context:\n            - circle-ci-patreon-context\n            - fixing-fashion-prod\n            - fly-deploy\n            - supabase-deploy\n      - deploy:\n          name: 'Deploy: community.preciousplastic.com'\n          requires:\n            - 'Approve Production deployment'\n          <<: *filter_only_default_branch\n          DEPLOY_ALIAS: 'production'\n          FLY_APP_NAME: community-platform-pp\n          FLY_TOML: fly-pp.toml\n          context:\n            - circle-ci-patreon-context\n            - community-platform-production\n            - fly-deploy\n            - supabase-deploy\n      - deploy:\n          name: 'Deploy: community.projectkamp.com'\n          requires:\n            - 'Approve Production deployment'\n          <<: *filter_only_default_branch\n          DEPLOY_ALIAS: project-kamp-production\n          FLY_APP_NAME: community-platform-pk\n          FLY_TOML: fly-pk.toml\n          context:\n            - circle-ci-patreon-context\n            - project-kamp-production\n            - fly-deploy\n            - supabase-deploy\n      - release:\n          name: Release new version to GitHub\n          context:\n            - release-context\n          requires:\n            - 'Deploy: community.preciousplastic.com'\n          <<: *filter_only_default_branch\n"
  },
  {
    "path": ".dockerignore",
    "content": "*node_modules*\ndump\nbuild\n.circleci\n.github\n.nxs\n.yarn\n.yarnrc.yml\nyarn.lock\ndocs\npackages/cypress\npackages/documentation\n# Build artifacts and caches\ncoverage\nreports\n*.log\n.env.local\n.env.*.local\n# Storybook\n**/storybook-static\n# Git\n.git\n.gitignore\n# IDE\n.vscode\n.idea\n*.swp\n*.swo\n# Testing\n*.test.ts\n*.test.tsx\n*.spec.ts\n*.spec.tsx"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "* @ONEARMY/maintainers"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: '[bug]'\nlabels: \"Type:Bug\\U0001F41B\"\nassignees: ''\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behaviour:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behaviour**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/build-new-component.md",
    "content": "---\nname: Build new component\nabout: Describe the new component you want to build\ntitle: ''\nlabels: ''\nassignees: ''\n---\n\n## Component infos\n\n### Description\n\nWrite a quick description of what is the component for, and as mainy details you consider useful to the person that will have to build it.\n\n### Page related\n\nWill be used in :\n\n- Related page name (#issue-number)\n\n### Mockup\n\n[Add here the mockup of the component]\n\n### Example(s)\n\nIf you have any examples of existing similar components, add the link here. It can be a link to a code snippet and/or to a page of a projet/website where we can interact with the similar component.\n\n### Build suggestion\n\nHere describe how you would build it, briefly but clearly. For example : the component MyComponent will take 2 props : `prop1`& `prop2`. It will extend the existing `Heading` component.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request-or-suggestion.md",
    "content": "---\nname: Feature request or suggestion\nabout: Suggest an idea for this project\ntitle: '[feature request]'\nlabels: 'Feedback: Question'\nassignees: ''\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/actions/destroy-fly-preview-app/Dockerfile",
    "content": "FROM alpine\n\nRUN apk add --no-cache curl jq\n\nRUN curl -L https://fly.io/install.sh | FLYCTL_INSTALL=/usr/local sh\n\nCOPY entrypoint.sh /entrypoint.sh\n\nRUN chmod +x /entrypoint.sh\n\nENTRYPOINT [\"/entrypoint.sh\"]"
  },
  {
    "path": ".github/actions/destroy-fly-preview-app/action.yml",
    "content": "name: \"Destroy fly.io app\"\ndescription: \"Destroy fly.io app if matching app name found\"\nauthor: iSCJT\nbranding:\n  icon: \"delete\"\n  color: \"red\"\nruns:\n  using: \"docker\"\n  image: \"Dockerfile\"\ninputs:\n  name:\n    description: Fly app name"
  },
  {
    "path": ".github/actions/destroy-fly-preview-app/entrypoint.sh",
    "content": "#!/bin/sh -l\n\nset -ex\n\n# Change underscores to hyphens.\napp=\"${INPUT_NAME//_/-}\"\n\n\nif ! flyctl status --app \"$app\"; then\n  echo \"App name not found\"\n  exit 1\nfi\n\nflyctl apps destroy \"$app\" -y\necho \"App $app successfully destroyed\"\nexit 0"
  },
  {
    "path": ".github/labels.yml",
    "content": "# This is currently just an export of labels used in the project\n# In the future we could also use to add more and keep in sync via actions such as:\n# https://github.com/micnncim/action-label-syncer\n[\n  { 'name': 'Backend', 'color': 'EF592F', 'description': '' },\n  { 'name': 'Code: Tidying', 'color': 'B2864C', 'description': '' },\n  { 'name': 'Design', 'color': '2FF7AB', 'description': null },\n  { 'name': 'Difficulty:Easy', 'color': 'FFDDB2', 'description': '' },\n  { 'name': 'Difficulty:Hard', 'color': 'C198E2', 'description': '' },\n  { 'name': 'Difficulty:Super-Easy', 'color': 'C2E0C6', 'description': '' },\n  { 'name': 'Difficulty:medium', 'color': '629AFC', 'description': '' },\n  { 'name': 'Discussions', 'color': 'CC317C', 'description': '' },\n  { 'name': 'Documentation', 'color': 'E0D323', 'description': '' },\n  { 'name': 'Frontend', 'color': '5319E7', 'description': '' },\n  { 'name': 'Global Good 🌍', 'color': 'B3E572', 'description': '' },\n  { 'name': 'Good first issue', 'color': '74B5E3', 'description': null },\n  { 'name': 'Help wanted', 'color': '56D639', 'description': '' },\n  { 'name': 'In progress', 'color': '440B89', 'description': '' },\n  { 'name': 'Mod: DevOps 🤖', 'color': 'BFD4F2', 'description': '' },\n  { 'name': 'Mod: Discussions 💬', 'color': 'BFD4F2', 'description': '' },\n  { 'name': 'Mod: Library 📰', 'color': 'BFD4F2', 'description': '' },\n  { 'name': 'Mod: Maps 🗺', 'color': 'BFD4F2', 'description': '' },\n  { 'name': 'Mod: Other ⬜️', 'color': 'BFD4F2', 'description': '' },\n  { 'name': 'Mod: Profiles 👱', 'color': 'BFD4F2', 'description': '' },\n  { 'name': 'Mod: Research 🔬', 'color': 'BFD4F2', 'description': '' },\n  { 'name': 'Mod: Security👮', 'color': 'BFD4F2', 'description': '' },\n  { 'name': 'Module Overview 👀', 'color': 'CEF45D', 'description': '' },\n  { 'name': 'Non-Dev', 'color': 'ADADAD', 'description': '' },\n  { 'name': 'Priority: High❕', 'color': 'FFB266', 'description': '' },\n  { 'name': 'Priority: Low', 'color': 'C2E0C6', 'description': '' },\n  { 'name': 'Priority: Medium', 'color': 'FFFF00', 'description': '' },\n  { 'name': 'Priority: Urgent❕❕❕', 'color': 'FF0000', 'description': '' },\n  {\n    'name': 'Review: Assigned 👉',\n    'color': 'D7C0A1',\n    'description': 'Waiting on review from a specific dev',\n  },\n  {\n    'name': 'Review: Changes Requested 🗨️',\n    'color': 'D7C0A1',\n    'description': 'Code reviewed, pending update to changes requested',\n  },\n  {\n    'name': 'Review allow-preview ✅',\n    'color': 'FFF',\n    'description': 'Has received manual check for malicious code and can be safely built for preview',\n  },\n]\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## PR Checklist\n\n- [ ] - Unit and/or e2e tests for the changes that have been added (for bug fixes / features)\n\n## What kind of change does this PR introduce?\n\n- [ ] 🐛 Bugfix — fixes incorrect behavior without changing functionality\n- [ ] ✨ Feature — adds new functionality\n- [ ] ♻️ Refactoring — improves code structure with no functional changes\n- [ ] ⚡️ Performance — improves speed, memory, or efficiency\n- [ ] 🧪 Tests — adds or updates tests only\n- [ ] 🔧 Tools / CI — changes to build, deploy, or developer tooling\n- [ ] 📝 Documentation — updates docs, comments, or READMEs\n- [ ] 📦 Dependencies — upgrades, downgrades, or removes packages\n- [ ] 🔖 Other:\n\n## What is the new behavior?\n\n_Describe the new behaviour_\n_If useful, provide screenshot or capture to highlight main changes_\n\n## Does this PR introduce a DB Schema Change or Migration?\n\n- [ ] Yes\n- [ ] No\n\n## Git Issues\n\nCloses #\n\n## What happens next?\n\nThank you for the contribution! We will review it ASAP.\n\nIf you need more immediate feedback you can reach out to us on Discord in the [Community Platform `development` channel](https://discord.com/channels/586676777334865928/938781727017558018).\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "name: 'CodeQL'\n\non:\n  push:\n    branches: [master]\n  pull_request:\n    branches: [master]\n  schedule:\n    - cron: '35 21 * * 2'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: ['javascript-typescript']\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@v4\n        with:\n          languages: ${{ matrix.language }}\n\n      - name: Autobuild\n        uses: github/codeql-action/autobuild@v4\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v4\n        with:\n          category: '/language:${{ matrix.language }}'\n"
  },
  {
    "path": ".github/workflows/pr-preview-fly-deploy.yml",
    "content": "name: Deploy Fly PR Preview\non:\n  pull_request_target:\n    types: [labeled, synchronize]\n\nenv:\n  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n  FLY_REGION: ams\n  FLY_ORG: one-army\n  SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}\n  SUPABASE_DB_PASSWORD: ${{ secrets.PREVIEW_DB_PASSWORD }}\n  SUPABASE_PROJECT_ID: ${{ secrets.PREVIEW_PROJECT_ID }}\n  FLY_APP_NAME: community-platform-pr-${{ github.event.number }}\n\njobs:\n  preview_app:\n    if: contains(github.event.pull_request.labels.*.name, 'Review allow-preview ✅')\n    runs-on: ubuntu-latest\n    continue-on-error: false\n    outputs:\n      url: ${{ steps.deploy.outputs.url }}\n    concurrency:\n      group: pr-${{ github.event.number }}-${{ github.sha }}\n\n    environment:\n      name: preview\n      url: ${{ steps.deploy.outputs.url }}\n\n    steps:\n      - name: Get code\n        uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n\n      - name: Install Fly CLI\n        run: |\n          curl -L https://fly.io/install.sh | sh\n          echo \"$HOME/.fly/bin\" >> \"$GITHUB_PATH\"\n\n      - name: Deploy PR app to Fly.io\n        id: deploy\n        uses: superfly/fly-pr-review-apps@1.5.0\n        with:\n          config: fly-preview.toml\n          name: community-platform-pr-${{ github.event.number }}\n          args: --build-arg COMMIT_SHA=${{ github.event.pull_request.head.sha }}\n          secrets: |\n            SUPABASE_API_URL=${{ secrets.SUPABASE_API_URL }}\n            SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}\n            SUPABASE_SERVICE_ROLE_KEY=${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}\n            RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}\n            TENANT_ID=precious-plastic\n"
  },
  {
    "path": ".github/workflows/pr-preview-fly-destroy.yml",
    "content": "name: Destroy Fly PR Preview\n\non:\n  pull_request_target:\n    types:\n      - unlabeled\n      - closed\n\nenv:\n  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n\njobs:\n  label_removed:\n    if: |\n      (github.event.action == 'unlabeled' && github.event.label.name == 'Review allow-preview ✅') ||\n      (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'Review allow-preview ✅'))\n    runs-on: ubuntu-latest\n    continue-on-error: true\n    concurrency:\n      group: pr-${{ github.event.number }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Destroy fly.io preview app\n        id: destroy\n        uses: ./.github/actions/destroy-fly-preview-app\n        with:\n          name: community-platform-pr-${{ github.event.number }}\n"
  },
  {
    "path": ".github/workflows/pr-preview-remove-label.yml",
    "content": "name: Remove PR Preview Label\non:\n  # Run this workflow on every PR event. Existing review apps will be updated when the PR is updated.\n  pull_request_target:\n    # Trigger when labels are changed or more commits added to a PR that contains labels\n    types: [closed]\n\njobs:\n  preview_app:\n    if: contains(github.event.pull_request.labels.*.name, 'Review allow-preview ✅')\n    runs-on: ubuntu-latest\n    continue-on-error: true\n    # Only run one deployment at a time per PR.\n    concurrency:\n      group: pr-${{ github.event.number }}\n\n    steps:\n      - name: Get code\n        uses: actions/checkout@v4\n        with:\n          # pull the repo from the pull request source, not the default local repo\n          ref: ${{ github.event.pull_request.head.sha }}\n\n      - name: Remove preview label\n        uses: actions/github-script@v7\n        with:\n          script: |\n            await github.rest.issues.removeLabel({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: github.event.number,\n              name: 'Review allow-preview ✅',\n            });\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}"
  },
  {
    "path": ".github/workflows/pr-stale.yml",
    "content": "name: 'Close stale issues and PRs'\non:\n  schedule:\n    - cron: '30 1 * * *'\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v8\n        with:\n          stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 5 days.'\n          close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'\n          days-before-pr-stale: 45\n          days-before-pr-close: 5\n          debug-only: true\n"
  },
  {
    "path": ".github/workflows/storybook-deploy.yml",
    "content": "# Build and deploy Storybook to GitHub Pages\nname: Storybook Deploy\non:\n  push:\n    branches:\n      - master\n    # Only run action if changes have been made to the components or themes packages\n    paths:\n      - 'packages/components/**'\n      - 'packages/themes/**'\n      - '.github/workflows/storybook-deploy.yml'\n  # Allow manual trigger\n  workflow_dispatch:\n\n# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\n# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.\n# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.\nconcurrency:\n  group: 'pages'\n  cancel-in-progress: false\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: '22.18.0'\n\n      - name: Install Bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Setup Cache\n        uses: actions/cache@v4\n        id: bun-cache\n        with:\n          path: ~/.bun/install/cache\n          key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}\n          restore-keys: |\n            ${{ runner.os }}-bun-\n\n      - name: Install dependencies\n        run: bun install --frozen-lockfile\n\n      - name: Build themes\n        run: bun run --filter oa-themes build\n\n      - name: Build Storybook\n        run: bun run --filter oa-components build:sb\n\n      - name: Setup Pages\n        uses: actions/configure-pages@v4\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v4\n        with:\n          path: ./packages/components/storybook-static\n\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    needs: build\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/ignore-files/ for more about ignoring files.\n\n# dependencies\nnode_modules\n\n# testing\n/coverage\n/reports\n/junit.xml\n\n# production\n/build\n/lib\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.vscode/*\n!.vscode/launch.json\n*.log\n.vs\n\n# IntelliJ\n/.idea\n\n# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored\n.yarn/*\n!.yarn/patches\n!.yarn/releases\n!.yarn/plugins\n!.yarn/sdks\n!.yarn/versions\n.pnp.*\n\n*.tsbuildinfo\nsupabase/.branches\nsupabase/.temp\nsupabase/.env\n.react-router/\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "  bun run lint-staged\n"
  },
  {
    "path": ".node-version",
    "content": "22.18.0\n"
  },
  {
    "path": ".releaserc.json",
    "content": "{\n  \"branches\": [\"master\"],\n  \"ci\": false,\n  \"plugins\": [\n    [\n      \"@semantic-release/commit-analyzer\",\n      {\n        \"preset\": \"angular\",\n        \"releaseRules\": [\n          { \"type\": \"docs\", \"scope\": \"README\", \"release\": \"patch\" },\n          { \"type\": \"refactor\", \"release\": \"patch\" },\n          { \"type\": \"style\", \"release\": \"patch\" }\n        ],\n        \"parserOpts\": {\n          \"noteKeywords\": [\"MAJOR VERSION\", \"MAJOR VERSIONS\"]\n        }\n      }\n    ],\n    \"@semantic-release/release-notes-generator\",\n    \"@semantic-release/github\",\n    \"@semantic-release/changelog\"\n  ]\n}\n"
  },
  {
    "path": ".snaplet/config.json",
    "content": "{\n  \"adapter\": \"pg\"\n}\n"
  },
  {
    "path": ".snaplet/dataModel.json",
    "content": "{\n  \"models\": {\n    \"_http_response\": {\n      \"id\": \"net._http_response\",\n      \"schemaName\": \"net\",\n      \"tableName\": \"_http_response\",\n      \"fields\": [\n        {\n          \"id\": \"net._http_response.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net._http_response.status_code\",\n          \"name\": \"status_code\",\n          \"columnName\": \"status_code\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net._http_response.content_type\",\n          \"name\": \"content_type\",\n          \"columnName\": \"content_type\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net._http_response.headers\",\n          \"name\": \"headers\",\n          \"columnName\": \"headers\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net._http_response.content\",\n          \"name\": \"content\",\n          \"columnName\": \"content\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net._http_response.timed_out\",\n          \"name\": \"timed_out\",\n          \"columnName\": \"timed_out\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net._http_response.error_msg\",\n          \"name\": \"error_msg\",\n          \"columnName\": \"error_msg\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net._http_response.created\",\n          \"name\": \"created\",\n          \"columnName\": \"created\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"audit_log_entries\": {\n      \"id\": \"auth.audit_log_entries\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"audit_log_entries\",\n      \"fields\": [\n        {\n          \"id\": \"auth.audit_log_entries.instance_id\",\n          \"name\": \"instance_id\",\n          \"columnName\": \"instance_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.audit_log_entries.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.audit_log_entries.payload\",\n          \"name\": \"payload\",\n          \"columnName\": \"payload\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.audit_log_entries.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.audit_log_entries.ip_address\",\n          \"name\": \"ip_address\",\n          \"columnName\": \"ip_address\",\n          \"type\": \"varchar\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": 64\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"banners\": {\n      \"id\": \"public.banners\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"banners\",\n      \"fields\": [\n        {\n          \"id\": \"public.banners.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"banners_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.banners.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.banners.modified_at\",\n          \"name\": \"modified_at\",\n          \"columnName\": \"modified_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.banners.text\",\n          \"name\": \"text\",\n          \"columnName\": \"text\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.banners.url\",\n          \"name\": \"url\",\n          \"columnName\": \"url\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.banners.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"banners_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"buckets\": {\n      \"id\": \"storage.buckets\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"buckets\",\n      \"fields\": [\n        {\n          \"id\": \"storage.buckets.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.owner\",\n          \"name\": \"owner\",\n          \"columnName\": \"owner\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.public\",\n          \"name\": \"public\",\n          \"columnName\": \"public\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.avif_autodetection\",\n          \"name\": \"avif_autodetection\",\n          \"columnName\": \"avif_autodetection\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.file_size_limit\",\n          \"name\": \"file_size_limit\",\n          \"columnName\": \"file_size_limit\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.allowed_mime_types\",\n          \"name\": \"allowed_mime_types\",\n          \"columnName\": \"allowed_mime_types\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.owner_id\",\n          \"name\": \"owner_id\",\n          \"columnName\": \"owner_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets.type\",\n          \"name\": \"type\",\n          \"columnName\": \"type\",\n          \"type\": \"buckettype\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"objects\",\n          \"type\": \"objects\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"objectsTobuckets\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"s3_multipart_uploads\",\n          \"type\": \"s3_multipart_uploads\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"s3_multipart_uploadsTobuckets\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"s3_multipart_uploads_parts\",\n          \"type\": \"s3_multipart_uploads_parts\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"s3_multipart_uploads_partsTobuckets\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"bname\",\n          \"fields\": [\"name\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"buckets_analytics\": {\n      \"id\": \"storage.buckets_analytics\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"buckets_analytics\",\n      \"fields\": [\n        {\n          \"id\": \"storage.buckets_analytics.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets_analytics.type\",\n          \"name\": \"type\",\n          \"columnName\": \"type\",\n          \"type\": \"buckettype\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets_analytics.format\",\n          \"name\": \"format\",\n          \"columnName\": \"format\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets_analytics.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets_analytics.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets_analytics.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets_analytics.deleted_at\",\n          \"name\": \"deleted_at\",\n          \"columnName\": \"deleted_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"iceberg_namespaces\",\n          \"type\": \"iceberg_namespaces\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"iceberg_namespacesTobuckets_analytics\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"iceberg_tables\",\n          \"type\": \"iceberg_tables\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"iceberg_tablesTobuckets_analytics\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"buckets_analytics_unique_name_idx\",\n          \"fields\": [\"name\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"buckets_vectors\": {\n      \"id\": \"storage.buckets_vectors\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"buckets_vectors\",\n      \"fields\": [\n        {\n          \"id\": \"storage.buckets_vectors.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets_vectors.type\",\n          \"name\": \"type\",\n          \"columnName\": \"type\",\n          \"type\": \"buckettype\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets_vectors.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.buckets_vectors.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"vector_indexes\",\n          \"type\": \"vector_indexes\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"vector_indexesTobuckets_vectors\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"categories\": {\n      \"id\": \"public.categories\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"categories\",\n      \"fields\": [\n        {\n          \"id\": \"public.categories.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"categories_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.categories.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.categories.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.categories.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.categories.legacy_id\",\n          \"name\": \"legacy_id\",\n          \"columnName\": \"legacy_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.categories.type\",\n          \"name\": \"type\",\n          \"columnName\": \"type\",\n          \"type\": \"content_types\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"news\",\n          \"type\": \"news\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"newsTocategories\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"projects\",\n          \"type\": \"projects\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"projectsTocategories\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"questions\",\n          \"type\": \"questions\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"questionsTocategories\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"research\",\n          \"type\": \"research\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"researchTocategories\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"categories_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"comments\": {\n      \"id\": \"public.comments\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"comments\",\n      \"fields\": [\n        {\n          \"id\": \"public.comments.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"comments_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.comment\",\n          \"name\": \"comment\",\n          \"columnName\": \"comment\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.source_id\",\n          \"name\": \"source_id\",\n          \"columnName\": \"source_id\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.parent_id\",\n          \"name\": \"parent_id\",\n          \"columnName\": \"parent_id\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.created_by\",\n          \"name\": \"created_by\",\n          \"columnName\": \"created_by\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.source_type\",\n          \"name\": \"source_type\",\n          \"columnName\": \"source_type\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.modified_at\",\n          \"name\": \"modified_at\",\n          \"columnName\": \"modified_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.source_id_legacy\",\n          \"name\": \"source_id_legacy\",\n          \"columnName\": \"source_id_legacy\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.deleted\",\n          \"name\": \"deleted\",\n          \"columnName\": \"deleted\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.comments.legacy_id\",\n          \"name\": \"legacy_id\",\n          \"columnName\": \"legacy_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"commentsToprofiles\",\n          \"relationFromFields\": [\"created_by\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"comments_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"custom_oauth_providers\": {\n      \"id\": \"auth.custom_oauth_providers\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"custom_oauth_providers\",\n      \"fields\": [\n        {\n          \"id\": \"auth.custom_oauth_providers.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.provider_type\",\n          \"name\": \"provider_type\",\n          \"columnName\": \"provider_type\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.identifier\",\n          \"name\": \"identifier\",\n          \"columnName\": \"identifier\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.client_id\",\n          \"name\": \"client_id\",\n          \"columnName\": \"client_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.client_secret\",\n          \"name\": \"client_secret\",\n          \"columnName\": \"client_secret\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.acceptable_client_ids\",\n          \"name\": \"acceptable_client_ids\",\n          \"columnName\": \"acceptable_client_ids\",\n          \"type\": \"text[]\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.scopes\",\n          \"name\": \"scopes\",\n          \"columnName\": \"scopes\",\n          \"type\": \"text[]\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.pkce_enabled\",\n          \"name\": \"pkce_enabled\",\n          \"columnName\": \"pkce_enabled\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.attribute_mapping\",\n          \"name\": \"attribute_mapping\",\n          \"columnName\": \"attribute_mapping\",\n          \"type\": \"jsonb\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.authorization_params\",\n          \"name\": \"authorization_params\",\n          \"columnName\": \"authorization_params\",\n          \"type\": \"jsonb\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.enabled\",\n          \"name\": \"enabled\",\n          \"columnName\": \"enabled\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.email_optional\",\n          \"name\": \"email_optional\",\n          \"columnName\": \"email_optional\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.issuer\",\n          \"name\": \"issuer\",\n          \"columnName\": \"issuer\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.discovery_url\",\n          \"name\": \"discovery_url\",\n          \"columnName\": \"discovery_url\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.skip_nonce_check\",\n          \"name\": \"skip_nonce_check\",\n          \"columnName\": \"skip_nonce_check\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.cached_discovery\",\n          \"name\": \"cached_discovery\",\n          \"columnName\": \"cached_discovery\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.discovery_cached_at\",\n          \"name\": \"discovery_cached_at\",\n          \"columnName\": \"discovery_cached_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.authorization_url\",\n          \"name\": \"authorization_url\",\n          \"columnName\": \"authorization_url\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.token_url\",\n          \"name\": \"token_url\",\n          \"columnName\": \"token_url\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.userinfo_url\",\n          \"name\": \"userinfo_url\",\n          \"columnName\": \"userinfo_url\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.jwks_uri\",\n          \"name\": \"jwks_uri\",\n          \"columnName\": \"jwks_uri\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.custom_oauth_providers.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"custom_oauth_providers_identifier_key\",\n          \"fields\": [\"identifier\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"extensions\": {\n      \"id\": \"_realtime.extensions\",\n      \"schemaName\": \"_realtime\",\n      \"tableName\": \"extensions\",\n      \"fields\": [\n        {\n          \"id\": \"_realtime.extensions.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.extensions.type\",\n          \"name\": \"type\",\n          \"columnName\": \"type\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.extensions.settings\",\n          \"name\": \"settings\",\n          \"columnName\": \"settings\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.extensions.tenant_external_id\",\n          \"name\": \"tenant_external_id\",\n          \"columnName\": \"tenant_external_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.extensions.inserted_at\",\n          \"name\": \"inserted_at\",\n          \"columnName\": \"inserted_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.extensions.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"tenants\",\n          \"type\": \"tenants\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"extensionsTotenants\",\n          \"relationFromFields\": [\"tenant_external_id\"],\n          \"relationToFields\": [\"external_id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"extensions_tenant_external_id_type_index\",\n          \"fields\": [\"tenant_external_id\", \"type\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"flow_state\": {\n      \"id\": \"auth.flow_state\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"flow_state\",\n      \"fields\": [\n        {\n          \"id\": \"auth.flow_state.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.auth_code\",\n          \"name\": \"auth_code\",\n          \"columnName\": \"auth_code\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.code_challenge_method\",\n          \"name\": \"code_challenge_method\",\n          \"columnName\": \"code_challenge_method\",\n          \"type\": \"code_challenge_method\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.code_challenge\",\n          \"name\": \"code_challenge\",\n          \"columnName\": \"code_challenge\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.provider_type\",\n          \"name\": \"provider_type\",\n          \"columnName\": \"provider_type\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.provider_access_token\",\n          \"name\": \"provider_access_token\",\n          \"columnName\": \"provider_access_token\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.provider_refresh_token\",\n          \"name\": \"provider_refresh_token\",\n          \"columnName\": \"provider_refresh_token\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.authentication_method\",\n          \"name\": \"authentication_method\",\n          \"columnName\": \"authentication_method\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.auth_code_issued_at\",\n          \"name\": \"auth_code_issued_at\",\n          \"columnName\": \"auth_code_issued_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.invite_token\",\n          \"name\": \"invite_token\",\n          \"columnName\": \"invite_token\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.referrer\",\n          \"name\": \"referrer\",\n          \"columnName\": \"referrer\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.oauth_client_state_id\",\n          \"name\": \"oauth_client_state_id\",\n          \"columnName\": \"oauth_client_state_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.linking_target_id\",\n          \"name\": \"linking_target_id\",\n          \"columnName\": \"linking_target_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.flow_state.email_optional\",\n          \"name\": \"email_optional\",\n          \"columnName\": \"email_optional\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"saml_relay_states\",\n          \"type\": \"saml_relay_states\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"saml_relay_statesToflow_state\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"hooks\": {\n      \"id\": \"supabase_functions.hooks\",\n      \"schemaName\": \"supabase_functions\",\n      \"tableName\": \"hooks\",\n      \"fields\": [\n        {\n          \"id\": \"supabase_functions.hooks.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"supabase_functions\\\".\\\"hooks_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"supabase_functions.hooks.hook_table_id\",\n          \"name\": \"hook_table_id\",\n          \"columnName\": \"hook_table_id\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"supabase_functions.hooks.hook_name\",\n          \"name\": \"hook_name\",\n          \"columnName\": \"hook_name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"supabase_functions.hooks.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"supabase_functions.hooks.request_id\",\n          \"name\": \"request_id\",\n          \"columnName\": \"request_id\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"hooks_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"http_request_queue\": {\n      \"id\": \"net.http_request_queue\",\n      \"schemaName\": \"net\",\n      \"tableName\": \"http_request_queue\",\n      \"fields\": [\n        {\n          \"id\": \"net.http_request_queue.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"net\\\".\\\"http_request_queue_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net.http_request_queue.method\",\n          \"name\": \"method\",\n          \"columnName\": \"method\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net.http_request_queue.url\",\n          \"name\": \"url\",\n          \"columnName\": \"url\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net.http_request_queue.headers\",\n          \"name\": \"headers\",\n          \"columnName\": \"headers\",\n          \"type\": \"jsonb\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net.http_request_queue.body\",\n          \"name\": \"body\",\n          \"columnName\": \"body\",\n          \"type\": \"bytea\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"net.http_request_queue.timeout_milliseconds\",\n          \"name\": \"timeout_milliseconds\",\n          \"columnName\": \"timeout_milliseconds\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"iceberg_namespaces\": {\n      \"id\": \"storage.iceberg_namespaces\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"iceberg_namespaces\",\n      \"fields\": [\n        {\n          \"id\": \"storage.iceberg_namespaces.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_namespaces.bucket_name\",\n          \"name\": \"bucket_name\",\n          \"columnName\": \"bucket_name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_namespaces.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_namespaces.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_namespaces.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_namespaces.metadata\",\n          \"name\": \"metadata\",\n          \"columnName\": \"metadata\",\n          \"type\": \"jsonb\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_namespaces.catalog_id\",\n          \"name\": \"catalog_id\",\n          \"columnName\": \"catalog_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"buckets_analytics\",\n          \"type\": \"buckets_analytics\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"iceberg_namespacesTobuckets_analytics\",\n          \"relationFromFields\": [\"catalog_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"iceberg_tables\",\n          \"type\": \"iceberg_tables\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"iceberg_tablesToiceberg_namespaces\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"idx_iceberg_namespaces_bucket_id\",\n          \"fields\": [\"catalog_id\", \"name\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"iceberg_tables\": {\n      \"id\": \"storage.iceberg_tables\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"iceberg_tables\",\n      \"fields\": [\n        {\n          \"id\": \"storage.iceberg_tables.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.namespace_id\",\n          \"name\": \"namespace_id\",\n          \"columnName\": \"namespace_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.bucket_name\",\n          \"name\": \"bucket_name\",\n          \"columnName\": \"bucket_name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.location\",\n          \"name\": \"location\",\n          \"columnName\": \"location\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.remote_table_id\",\n          \"name\": \"remote_table_id\",\n          \"columnName\": \"remote_table_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.shard_key\",\n          \"name\": \"shard_key\",\n          \"columnName\": \"shard_key\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.shard_id\",\n          \"name\": \"shard_id\",\n          \"columnName\": \"shard_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.iceberg_tables.catalog_id\",\n          \"name\": \"catalog_id\",\n          \"columnName\": \"catalog_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"buckets_analytics\",\n          \"type\": \"buckets_analytics\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"iceberg_tablesTobuckets_analytics\",\n          \"relationFromFields\": [\"catalog_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"iceberg_namespaces\",\n          \"type\": \"iceberg_namespaces\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"iceberg_tablesToiceberg_namespaces\",\n          \"relationFromFields\": [\"namespace_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"idx_iceberg_tables_location\",\n          \"fields\": [\"location\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"idx_iceberg_tables_namespace_id\",\n          \"fields\": [\"catalog_id\", \"name\", \"namespace_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"identities\": {\n      \"id\": \"auth.identities\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"identities\",\n      \"fields\": [\n        {\n          \"id\": \"auth.identities.provider_id\",\n          \"name\": \"provider_id\",\n          \"columnName\": \"provider_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.identities.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.identities.identity_data\",\n          \"name\": \"identity_data\",\n          \"columnName\": \"identity_data\",\n          \"type\": \"jsonb\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.identities.provider\",\n          \"name\": \"provider\",\n          \"columnName\": \"provider\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.identities.last_sign_in_at\",\n          \"name\": \"last_sign_in_at\",\n          \"columnName\": \"last_sign_in_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.identities.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.identities.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.identities.email\",\n          \"name\": \"email\",\n          \"columnName\": \"email\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": true,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.identities.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"users\",\n          \"type\": \"users\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"identitiesTousers\",\n          \"relationFromFields\": [\"user_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"identities_provider_id_provider_unique\",\n          \"fields\": [\"provider\", \"provider_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"instances\": {\n      \"id\": \"auth.instances\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"instances\",\n      \"fields\": [\n        {\n          \"id\": \"auth.instances.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.instances.uuid\",\n          \"name\": \"uuid\",\n          \"columnName\": \"uuid\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.instances.raw_base_config\",\n          \"name\": \"raw_base_config\",\n          \"columnName\": \"raw_base_config\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.instances.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.instances.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"map_pins\": {\n      \"id\": \"public.map_pins\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"map_pins\",\n      \"fields\": [\n        {\n          \"id\": \"public.map_pins.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"map_pins_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.profile_id\",\n          \"name\": \"profile_id\",\n          \"columnName\": \"profile_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.country\",\n          \"name\": \"country\",\n          \"columnName\": \"country\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.country_code\",\n          \"name\": \"country_code\",\n          \"columnName\": \"country_code\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.administrative\",\n          \"name\": \"administrative\",\n          \"columnName\": \"administrative\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.post_code\",\n          \"name\": \"post_code\",\n          \"columnName\": \"post_code\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.lat\",\n          \"name\": \"lat\",\n          \"columnName\": \"lat\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.lng\",\n          \"name\": \"lng\",\n          \"columnName\": \"lng\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.moderation\",\n          \"name\": \"moderation\",\n          \"columnName\": \"moderation\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.moderation_feedback\",\n          \"name\": \"moderation_feedback\",\n          \"columnName\": \"moderation_feedback\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_pins.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"map_pinsToprofiles\",\n          \"relationFromFields\": [\"profile_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"map_pins_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"map_settings\": {\n      \"id\": \"public.map_settings\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"map_settings\",\n      \"fields\": [\n        {\n          \"id\": \"public.map_settings.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"map_settings_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_settings.default_type_filters\",\n          \"name\": \"default_type_filters\",\n          \"columnName\": \"default_type_filters\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_settings.setting_filters\",\n          \"name\": \"setting_filters\",\n          \"columnName\": \"setting_filters\",\n          \"type\": \"text[]\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.map_settings.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"map_settings_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"public_messages\": {\n      \"id\": \"public.messages\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"messages\",\n      \"fields\": [\n        {\n          \"id\": \"public.messages.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"messages_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.messages.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.messages.message\",\n          \"name\": \"message\",\n          \"columnName\": \"message\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.messages.sender_id\",\n          \"name\": \"sender_id\",\n          \"columnName\": \"sender_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.messages.receiver_id\",\n          \"name\": \"receiver_id\",\n          \"columnName\": \"receiver_id\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.messages.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profiles_public_messages_receiver_idToprofiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"public_messages_receiver_idToprofiles\",\n          \"relationFromFields\": [\"receiver_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profiles_public_messages_sender_idToprofiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"public_messages_sender_idToprofiles\",\n          \"relationFromFields\": [\"sender_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"messages_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"realtime_messages\": {\n      \"id\": \"realtime.messages\",\n      \"schemaName\": \"realtime\",\n      \"tableName\": \"messages\",\n      \"fields\": [\n        {\n          \"id\": \"realtime.messages.topic\",\n          \"name\": \"topic\",\n          \"columnName\": \"topic\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.messages.extension\",\n          \"name\": \"extension\",\n          \"columnName\": \"extension\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.messages.payload\",\n          \"name\": \"payload\",\n          \"columnName\": \"payload\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.messages.event\",\n          \"name\": \"event\",\n          \"columnName\": \"event\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.messages.private\",\n          \"name\": \"private\",\n          \"columnName\": \"private\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.messages.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.messages.inserted_at\",\n          \"name\": \"inserted_at\",\n          \"columnName\": \"inserted_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.messages.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"messages_pkey\",\n          \"fields\": [\"id\", \"inserted_at\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"mfa_amr_claims\": {\n      \"id\": \"auth.mfa_amr_claims\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"mfa_amr_claims\",\n      \"fields\": [\n        {\n          \"id\": \"auth.mfa_amr_claims.session_id\",\n          \"name\": \"session_id\",\n          \"columnName\": \"session_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_amr_claims.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_amr_claims.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_amr_claims.authentication_method\",\n          \"name\": \"authentication_method\",\n          \"columnName\": \"authentication_method\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_amr_claims.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"sessions\",\n          \"type\": \"sessions\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"mfa_amr_claimsTosessions\",\n          \"relationFromFields\": [\"session_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"mfa_amr_claims_session_id_authentication_method_pkey\",\n          \"fields\": [\"authentication_method\", \"session_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"mfa_challenges\": {\n      \"id\": \"auth.mfa_challenges\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"mfa_challenges\",\n      \"fields\": [\n        {\n          \"id\": \"auth.mfa_challenges.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_challenges.factor_id\",\n          \"name\": \"factor_id\",\n          \"columnName\": \"factor_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_challenges.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_challenges.verified_at\",\n          \"name\": \"verified_at\",\n          \"columnName\": \"verified_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_challenges.ip_address\",\n          \"name\": \"ip_address\",\n          \"columnName\": \"ip_address\",\n          \"type\": \"inet\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_challenges.otp_code\",\n          \"name\": \"otp_code\",\n          \"columnName\": \"otp_code\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_challenges.web_authn_session_data\",\n          \"name\": \"web_authn_session_data\",\n          \"columnName\": \"web_authn_session_data\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"mfa_factors\",\n          \"type\": \"mfa_factors\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"mfa_challengesTomfa_factors\",\n          \"relationFromFields\": [\"factor_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"mfa_factors\": {\n      \"id\": \"auth.mfa_factors\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"mfa_factors\",\n      \"fields\": [\n        {\n          \"id\": \"auth.mfa_factors.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.friendly_name\",\n          \"name\": \"friendly_name\",\n          \"columnName\": \"friendly_name\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.factor_type\",\n          \"name\": \"factor_type\",\n          \"columnName\": \"factor_type\",\n          \"type\": \"factor_type\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.status\",\n          \"name\": \"status\",\n          \"columnName\": \"status\",\n          \"type\": \"factor_status\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.secret\",\n          \"name\": \"secret\",\n          \"columnName\": \"secret\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.phone\",\n          \"name\": \"phone\",\n          \"columnName\": \"phone\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.last_challenged_at\",\n          \"name\": \"last_challenged_at\",\n          \"columnName\": \"last_challenged_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.web_authn_credential\",\n          \"name\": \"web_authn_credential\",\n          \"columnName\": \"web_authn_credential\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.web_authn_aaguid\",\n          \"name\": \"web_authn_aaguid\",\n          \"columnName\": \"web_authn_aaguid\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.mfa_factors.last_webauthn_challenge_data\",\n          \"name\": \"last_webauthn_challenge_data\",\n          \"columnName\": \"last_webauthn_challenge_data\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"users\",\n          \"type\": \"users\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"mfa_factorsTousers\",\n          \"relationFromFields\": [\"user_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"mfa_challenges\",\n          \"type\": \"mfa_challenges\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"mfa_challengesTomfa_factors\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"mfa_factors_last_challenged_at_key\",\n          \"fields\": [\"last_challenged_at\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"mfa_factors_user_friendly_name_unique\",\n          \"fields\": [\"friendly_name\", \"user_id\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"unique_phone_factor_per_user\",\n          \"fields\": [\"phone\", \"user_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"storage_migrations\": {\n      \"id\": \"storage.migrations\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"migrations\",\n      \"fields\": [\n        {\n          \"id\": \"storage.migrations.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.migrations.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"varchar\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 100\n        },\n        {\n          \"id\": \"storage.migrations.hash\",\n          \"name\": \"hash\",\n          \"columnName\": \"hash\",\n          \"type\": \"varchar\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 40\n        },\n        {\n          \"id\": \"storage.migrations.executed_at\",\n          \"name\": \"executed_at\",\n          \"columnName\": \"executed_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"migrations_name_key\",\n          \"fields\": [\"name\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"supabase_functions_migrations\": {\n      \"id\": \"supabase_functions.migrations\",\n      \"schemaName\": \"supabase_functions\",\n      \"tableName\": \"migrations\",\n      \"fields\": [\n        {\n          \"id\": \"supabase_functions.migrations.version\",\n          \"name\": \"version\",\n          \"columnName\": \"version\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"supabase_functions.migrations.inserted_at\",\n          \"name\": \"inserted_at\",\n          \"columnName\": \"inserted_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"migrations_pkey\",\n          \"fields\": [\"version\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"news\": {\n      \"id\": \"public.news\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"news\",\n      \"fields\": [\n        {\n          \"id\": \"public.news.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"news_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.created_by\",\n          \"name\": \"created_by\",\n          \"columnName\": \"created_by\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.deleted\",\n          \"name\": \"deleted\",\n          \"columnName\": \"deleted\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.modified_at\",\n          \"name\": \"modified_at\",\n          \"columnName\": \"modified_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.comment_count\",\n          \"name\": \"comment_count\",\n          \"columnName\": \"comment_count\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.body\",\n          \"name\": \"body\",\n          \"columnName\": \"body\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.moderation\",\n          \"name\": \"moderation\",\n          \"columnName\": \"moderation\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.slug\",\n          \"name\": \"slug\",\n          \"columnName\": \"slug\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.previous_slugs\",\n          \"name\": \"previous_slugs\",\n          \"columnName\": \"previous_slugs\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.category\",\n          \"name\": \"category\",\n          \"columnName\": \"category\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.tags\",\n          \"name\": \"tags\",\n          \"columnName\": \"tags\",\n          \"type\": \"int8[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.title\",\n          \"name\": \"title\",\n          \"columnName\": \"title\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.total_views\",\n          \"name\": \"total_views\",\n          \"columnName\": \"total_views\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.hero_image\",\n          \"name\": \"hero_image\",\n          \"columnName\": \"hero_image\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.summary\",\n          \"name\": \"summary\",\n          \"columnName\": \"summary\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.fts\",\n          \"name\": \"fts\",\n          \"columnName\": \"fts\",\n          \"type\": \"tsvector\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": true,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.is_draft\",\n          \"name\": \"is_draft\",\n          \"columnName\": \"is_draft\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.profile_badge\",\n          \"name\": \"profile_badge\",\n          \"columnName\": \"profile_badge\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.news.published_at\",\n          \"name\": \"published_at\",\n          \"columnName\": \"published_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"categories\",\n          \"type\": \"categories\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"newsTocategories\",\n          \"relationFromFields\": [\"category\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profile_badges\",\n          \"type\": \"profile_badges\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"newsToprofile_badges\",\n          \"relationFromFields\": [\"profile_badge\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"newsToprofiles\",\n          \"relationFromFields\": [\"created_by\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"news_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"news_tenant_id_slug_key\",\n          \"fields\": [\"slug\", \"tenant_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"notifications\": {\n      \"id\": \"public.notifications\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"notifications\",\n      \"fields\": [\n        {\n          \"id\": \"public.notifications.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"notifications_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.modified_at\",\n          \"name\": \"modified_at\",\n          \"columnName\": \"modified_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.owned_by_id\",\n          \"name\": \"owned_by_id\",\n          \"columnName\": \"owned_by_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.triggered_by_id\",\n          \"name\": \"triggered_by_id\",\n          \"columnName\": \"triggered_by_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.content_type\",\n          \"name\": \"content_type\",\n          \"columnName\": \"content_type\",\n          \"type\": \"notification_content_types\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.content_id\",\n          \"name\": \"content_id\",\n          \"columnName\": \"content_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.is_read\",\n          \"name\": \"is_read\",\n          \"columnName\": \"is_read\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.action_type\",\n          \"name\": \"action_type\",\n          \"columnName\": \"action_type\",\n          \"type\": \"notification_action_types\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.source_content_type\",\n          \"name\": \"source_content_type\",\n          \"columnName\": \"source_content_type\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.source_content_id\",\n          \"name\": \"source_content_id\",\n          \"columnName\": \"source_content_id\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.parent_comment_id\",\n          \"name\": \"parent_comment_id\",\n          \"columnName\": \"parent_comment_id\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.parent_content_id\",\n          \"name\": \"parent_content_id\",\n          \"columnName\": \"parent_content_id\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.should_email\",\n          \"name\": \"should_email\",\n          \"columnName\": \"should_email\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications.title\",\n          \"name\": \"title\",\n          \"columnName\": \"title\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profiles_notifications_owned_by_idToprofiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"notifications_owned_by_idToprofiles\",\n          \"relationFromFields\": [\"owned_by_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profiles_notifications_triggered_by_idToprofiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"notifications_triggered_by_idToprofiles\",\n          \"relationFromFields\": [\"triggered_by_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"notifications_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"notifications_preferences\": {\n      \"id\": \"public.notifications_preferences\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"notifications_preferences\",\n      \"fields\": [\n        {\n          \"id\": \"public.notifications_preferences.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"notifications_preferences_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications_preferences.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications_preferences.comments\",\n          \"name\": \"comments\",\n          \"columnName\": \"comments\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications_preferences.replies\",\n          \"name\": \"replies\",\n          \"columnName\": \"replies\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications_preferences.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications_preferences.research_updates\",\n          \"name\": \"research_updates\",\n          \"columnName\": \"research_updates\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.notifications_preferences.is_unsubscribed\",\n          \"name\": \"is_unsubscribed\",\n          \"columnName\": \"is_unsubscribed\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"notifications_preferencesToprofiles\",\n          \"relationFromFields\": [\"user_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"notifications_preferences_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"oauth_authorizations\": {\n      \"id\": \"auth.oauth_authorizations\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"oauth_authorizations\",\n      \"fields\": [\n        {\n          \"id\": \"auth.oauth_authorizations.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.authorization_id\",\n          \"name\": \"authorization_id\",\n          \"columnName\": \"authorization_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.client_id\",\n          \"name\": \"client_id\",\n          \"columnName\": \"client_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.redirect_uri\",\n          \"name\": \"redirect_uri\",\n          \"columnName\": \"redirect_uri\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.scope\",\n          \"name\": \"scope\",\n          \"columnName\": \"scope\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.state\",\n          \"name\": \"state\",\n          \"columnName\": \"state\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.resource\",\n          \"name\": \"resource\",\n          \"columnName\": \"resource\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.code_challenge\",\n          \"name\": \"code_challenge\",\n          \"columnName\": \"code_challenge\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.code_challenge_method\",\n          \"name\": \"code_challenge_method\",\n          \"columnName\": \"code_challenge_method\",\n          \"type\": \"code_challenge_method\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.response_type\",\n          \"name\": \"response_type\",\n          \"columnName\": \"response_type\",\n          \"type\": \"oauth_response_type\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.status\",\n          \"name\": \"status\",\n          \"columnName\": \"status\",\n          \"type\": \"oauth_authorization_status\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.authorization_code\",\n          \"name\": \"authorization_code\",\n          \"columnName\": \"authorization_code\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.expires_at\",\n          \"name\": \"expires_at\",\n          \"columnName\": \"expires_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.approved_at\",\n          \"name\": \"approved_at\",\n          \"columnName\": \"approved_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_authorizations.nonce\",\n          \"name\": \"nonce\",\n          \"columnName\": \"nonce\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"oauth_clients\",\n          \"type\": \"oauth_clients\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"oauth_authorizationsTooauth_clients\",\n          \"relationFromFields\": [\"client_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"users\",\n          \"type\": \"users\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"oauth_authorizationsTousers\",\n          \"relationFromFields\": [\"user_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"oauth_authorizations_authorization_code_key\",\n          \"fields\": [\"authorization_code\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"oauth_authorizations_authorization_id_key\",\n          \"fields\": [\"authorization_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"oauth_client_states\": {\n      \"id\": \"auth.oauth_client_states\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"oauth_client_states\",\n      \"fields\": [\n        {\n          \"id\": \"auth.oauth_client_states.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_client_states.provider_type\",\n          \"name\": \"provider_type\",\n          \"columnName\": \"provider_type\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_client_states.code_verifier\",\n          \"name\": \"code_verifier\",\n          \"columnName\": \"code_verifier\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_client_states.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"oauth_clients\": {\n      \"id\": \"auth.oauth_clients\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"oauth_clients\",\n      \"fields\": [\n        {\n          \"id\": \"auth.oauth_clients.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.client_secret_hash\",\n          \"name\": \"client_secret_hash\",\n          \"columnName\": \"client_secret_hash\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.registration_type\",\n          \"name\": \"registration_type\",\n          \"columnName\": \"registration_type\",\n          \"type\": \"oauth_registration_type\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.redirect_uris\",\n          \"name\": \"redirect_uris\",\n          \"columnName\": \"redirect_uris\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.grant_types\",\n          \"name\": \"grant_types\",\n          \"columnName\": \"grant_types\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.client_name\",\n          \"name\": \"client_name\",\n          \"columnName\": \"client_name\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.client_uri\",\n          \"name\": \"client_uri\",\n          \"columnName\": \"client_uri\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.logo_uri\",\n          \"name\": \"logo_uri\",\n          \"columnName\": \"logo_uri\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.deleted_at\",\n          \"name\": \"deleted_at\",\n          \"columnName\": \"deleted_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.client_type\",\n          \"name\": \"client_type\",\n          \"columnName\": \"client_type\",\n          \"type\": \"oauth_client_type\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_clients.token_endpoint_auth_method\",\n          \"name\": \"token_endpoint_auth_method\",\n          \"columnName\": \"token_endpoint_auth_method\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"oauth_authorizations\",\n          \"type\": \"oauth_authorizations\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"oauth_authorizationsTooauth_clients\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"oauth_consents\",\n          \"type\": \"oauth_consents\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"oauth_consentsTooauth_clients\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"sessions\",\n          \"type\": \"sessions\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"sessionsTooauth_clients\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"oauth_consents\": {\n      \"id\": \"auth.oauth_consents\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"oauth_consents\",\n      \"fields\": [\n        {\n          \"id\": \"auth.oauth_consents.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_consents.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_consents.client_id\",\n          \"name\": \"client_id\",\n          \"columnName\": \"client_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_consents.scopes\",\n          \"name\": \"scopes\",\n          \"columnName\": \"scopes\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_consents.granted_at\",\n          \"name\": \"granted_at\",\n          \"columnName\": \"granted_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.oauth_consents.revoked_at\",\n          \"name\": \"revoked_at\",\n          \"columnName\": \"revoked_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"oauth_clients\",\n          \"type\": \"oauth_clients\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"oauth_consentsTooauth_clients\",\n          \"relationFromFields\": [\"client_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"users\",\n          \"type\": \"users\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"oauth_consentsTousers\",\n          \"relationFromFields\": [\"user_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"oauth_consents_user_client_unique\",\n          \"fields\": [\"client_id\", \"user_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"objects\": {\n      \"id\": \"storage.objects\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"objects\",\n      \"fields\": [\n        {\n          \"id\": \"storage.objects.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.bucket_id\",\n          \"name\": \"bucket_id\",\n          \"columnName\": \"bucket_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.owner\",\n          \"name\": \"owner\",\n          \"columnName\": \"owner\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.last_accessed_at\",\n          \"name\": \"last_accessed_at\",\n          \"columnName\": \"last_accessed_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.metadata\",\n          \"name\": \"metadata\",\n          \"columnName\": \"metadata\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.path_tokens\",\n          \"name\": \"path_tokens\",\n          \"columnName\": \"path_tokens\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": true,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.version\",\n          \"name\": \"version\",\n          \"columnName\": \"version\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.owner_id\",\n          \"name\": \"owner_id\",\n          \"columnName\": \"owner_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.objects.user_metadata\",\n          \"name\": \"user_metadata\",\n          \"columnName\": \"user_metadata\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"buckets\",\n          \"type\": \"buckets\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"objectsTobuckets\",\n          \"relationFromFields\": [\"bucket_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"bucketid_objname\",\n          \"fields\": [\"bucket_id\", \"name\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"one_time_tokens\": {\n      \"id\": \"auth.one_time_tokens\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"one_time_tokens\",\n      \"fields\": [\n        {\n          \"id\": \"auth.one_time_tokens.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.one_time_tokens.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.one_time_tokens.token_type\",\n          \"name\": \"token_type\",\n          \"columnName\": \"token_type\",\n          \"type\": \"one_time_token_type\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.one_time_tokens.token_hash\",\n          \"name\": \"token_hash\",\n          \"columnName\": \"token_hash\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.one_time_tokens.relates_to\",\n          \"name\": \"relates_to\",\n          \"columnName\": \"relates_to\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.one_time_tokens.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.one_time_tokens.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"users\",\n          \"type\": \"users\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"one_time_tokensTousers\",\n          \"relationFromFields\": [\"user_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"one_time_tokens_user_id_token_type_key\",\n          \"fields\": [\"token_type\", \"user_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"patreon_settings\": {\n      \"id\": \"public.patreon_settings\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"patreon_settings\",\n      \"fields\": [\n        {\n          \"id\": \"public.patreon_settings.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"patreon_settings_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.patreon_settings.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.patreon_settings.tiers\",\n          \"name\": \"tiers\",\n          \"columnName\": \"tiers\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.patreon_settings.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"patreon_settings_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"profile_badges\": {\n      \"id\": \"public.profile_badges\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"profile_badges\",\n      \"fields\": [\n        {\n          \"id\": \"public.profile_badges.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"profile_badges_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges.display_name\",\n          \"name\": \"display_name\",\n          \"columnName\": \"display_name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges.image_url\",\n          \"name\": \"image_url\",\n          \"columnName\": \"image_url\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges.action_url\",\n          \"name\": \"action_url\",\n          \"columnName\": \"action_url\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges.premium_tier\",\n          \"name\": \"premium_tier\",\n          \"columnName\": \"premium_tier\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"news\",\n          \"type\": \"news\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"newsToprofile_badges\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profile_badges_relations\",\n          \"type\": \"profile_badges_relations\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"profile_badges_relationsToprofile_badges\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"upgrade_badge\",\n          \"type\": \"upgrade_badge\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"upgrade_badgeToprofile_badges\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"profile_badges_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"profile_badges_relations\": {\n      \"id\": \"public.profile_badges_relations\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"profile_badges_relations\",\n      \"fields\": [\n        {\n          \"id\": \"public.profile_badges_relations.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"profile_badges_relations_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges_relations.profile_id\",\n          \"name\": \"profile_id\",\n          \"columnName\": \"profile_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges_relations.profile_badge_id\",\n          \"name\": \"profile_badge_id\",\n          \"columnName\": \"profile_badge_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges_relations.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_badges_relations.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profile_badges\",\n          \"type\": \"profile_badges\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"profile_badges_relationsToprofile_badges\",\n          \"relationFromFields\": [\"profile_badge_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"profile_badges_relationsToprofiles\",\n          \"relationFromFields\": [\"profile_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"profile_badges_relations_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"profile_tags\": {\n      \"id\": \"public.profile_tags\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"profile_tags\",\n      \"fields\": [\n        {\n          \"id\": \"public.profile_tags.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"profile_tags_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_tags.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_tags.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_tags.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_tags.profile_type\",\n          \"name\": \"profile_type\",\n          \"columnName\": \"profile_type\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profile_tags_relations\",\n          \"type\": \"profile_tags_relations\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"profile_tags_relationsToprofile_tags\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"profile_tags_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"profile_tags_relations\": {\n      \"id\": \"public.profile_tags_relations\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"profile_tags_relations\",\n      \"fields\": [\n        {\n          \"id\": \"public.profile_tags_relations.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"profile_tags_relations_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_tags_relations.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_tags_relations.profile_id\",\n          \"name\": \"profile_id\",\n          \"columnName\": \"profile_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_tags_relations.profile_tag_id\",\n          \"name\": \"profile_tag_id\",\n          \"columnName\": \"profile_tag_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_tags_relations.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profile_tags\",\n          \"type\": \"profile_tags\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"profile_tags_relationsToprofile_tags\",\n          \"relationFromFields\": [\"profile_tag_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"profile_tags_relationsToprofiles\",\n          \"relationFromFields\": [\"profile_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"profile_tags_relations_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"profile_types\": {\n      \"id\": \"public.profile_types\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"profile_types\",\n      \"fields\": [\n        {\n          \"id\": \"public.profile_types.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"profile_types_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_types.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_types.display_name\",\n          \"name\": \"display_name\",\n          \"columnName\": \"display_name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_types.order\",\n          \"name\": \"order\",\n          \"columnName\": \"order\",\n          \"type\": \"int2\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_types.image_url\",\n          \"name\": \"image_url\",\n          \"columnName\": \"image_url\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_types.small_image_url\",\n          \"name\": \"small_image_url\",\n          \"columnName\": \"small_image_url\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_types.description\",\n          \"name\": \"description\",\n          \"columnName\": \"description\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_types.map_pin_name\",\n          \"name\": \"map_pin_name\",\n          \"columnName\": \"map_pin_name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_types.is_space\",\n          \"name\": \"is_space\",\n          \"columnName\": \"is_space\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profile_types.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"profilesToprofile_types\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"profile_types_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"profiles\": {\n      \"id\": \"public.profiles\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"profiles\",\n      \"fields\": [\n        {\n          \"id\": \"public.profiles.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"profiles_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.firebase_auth_id\",\n          \"name\": \"firebase_auth_id\",\n          \"columnName\": \"firebase_auth_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.display_name\",\n          \"name\": \"display_name\",\n          \"columnName\": \"display_name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.country\",\n          \"name\": \"country\",\n          \"columnName\": \"country\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.about\",\n          \"name\": \"about\",\n          \"columnName\": \"about\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.username\",\n          \"name\": \"username\",\n          \"columnName\": \"username\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.roles\",\n          \"name\": \"roles\",\n          \"columnName\": \"roles\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.impact\",\n          \"name\": \"impact\",\n          \"columnName\": \"impact\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.is_blocked_from_messaging\",\n          \"name\": \"is_blocked_from_messaging\",\n          \"columnName\": \"is_blocked_from_messaging\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.is_contactable\",\n          \"name\": \"is_contactable\",\n          \"columnName\": \"is_contactable\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.is_supporter\",\n          \"name\": \"is_supporter\",\n          \"columnName\": \"is_supporter\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.patreon\",\n          \"name\": \"patreon\",\n          \"columnName\": \"patreon\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.total_views\",\n          \"name\": \"total_views\",\n          \"columnName\": \"total_views\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.type\",\n          \"name\": \"type\",\n          \"columnName\": \"type\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.auth_id\",\n          \"name\": \"auth_id\",\n          \"columnName\": \"auth_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.legacy_id\",\n          \"name\": \"legacy_id\",\n          \"columnName\": \"legacy_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.cover_images\",\n          \"name\": \"cover_images\",\n          \"columnName\": \"cover_images\",\n          \"type\": \"json[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.last_active\",\n          \"name\": \"last_active\",\n          \"columnName\": \"last_active\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.photo\",\n          \"name\": \"photo\",\n          \"columnName\": \"photo\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.visitor_policy\",\n          \"name\": \"visitor_policy\",\n          \"columnName\": \"visitor_policy\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.website\",\n          \"name\": \"website\",\n          \"columnName\": \"website\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.profile_type\",\n          \"name\": \"profile_type\",\n          \"columnName\": \"profile_type\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.profiles.donations_enabled\",\n          \"name\": \"donations_enabled\",\n          \"columnName\": \"donations_enabled\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"users\",\n          \"type\": \"users\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"profilesTousers\",\n          \"relationFromFields\": [\"auth_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profile_types\",\n          \"type\": \"profile_types\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"profilesToprofile_types\",\n          \"relationFromFields\": [\"profile_type\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"comments\",\n          \"type\": \"comments\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"commentsToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"map_pins\",\n          \"type\": \"map_pins\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"map_pinsToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"public_messages_public_messages_receiver_idToprofiles\",\n          \"type\": \"public_messages\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"public_messages_receiver_idToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"public_messages_public_messages_sender_idToprofiles\",\n          \"type\": \"public_messages\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"public_messages_sender_idToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"news\",\n          \"type\": \"news\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"newsToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"notifications_notifications_owned_by_idToprofiles\",\n          \"type\": \"notifications\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"notifications_owned_by_idToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"notifications_notifications_triggered_by_idToprofiles\",\n          \"type\": \"notifications\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"notifications_triggered_by_idToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"notifications_preferences\",\n          \"type\": \"notifications_preferences\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"notifications_preferencesToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profile_badges_relations\",\n          \"type\": \"profile_badges_relations\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"profile_badges_relationsToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profile_tags_relations\",\n          \"type\": \"profile_tags_relations\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"profile_tags_relationsToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"projects\",\n          \"type\": \"projects\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"projectsToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"questions\",\n          \"type\": \"questions\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"questionsToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"research\",\n          \"type\": \"research\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"researchToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"research_updates\",\n          \"type\": \"research_updates\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"research_updatesToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"subscribers\",\n          \"type\": \"subscribers\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"subscribersToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"useful_votes\",\n          \"type\": \"useful_votes\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"useful_votesToprofiles\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"profiles_auth_id_tenant_id_key\",\n          \"fields\": [\"auth_id\", \"tenant_id\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"profiles_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"profiles_username_tenant_id_key\",\n          \"fields\": [\"tenant_id\", \"username\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"project_steps\": {\n      \"id\": \"public.project_steps\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"project_steps\",\n      \"fields\": [\n        {\n          \"id\": \"public.project_steps.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"project_steps_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.project_steps.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.project_steps.project_id\",\n          \"name\": \"project_id\",\n          \"columnName\": \"project_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.project_steps.title\",\n          \"name\": \"title\",\n          \"columnName\": \"title\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.project_steps.description\",\n          \"name\": \"description\",\n          \"columnName\": \"description\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.project_steps.images\",\n          \"name\": \"images\",\n          \"columnName\": \"images\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.project_steps.video_url\",\n          \"name\": \"video_url\",\n          \"columnName\": \"video_url\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.project_steps.order\",\n          \"name\": \"order\",\n          \"columnName\": \"order\",\n          \"type\": \"int2\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.project_steps.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"projects\",\n          \"type\": \"projects\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"project_stepsToprojects\",\n          \"relationFromFields\": [\"project_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"project_steps_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"projects\": {\n      \"id\": \"public.projects\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"projects\",\n      \"fields\": [\n        {\n          \"id\": \"public.projects.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"projects_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.modified_at\",\n          \"name\": \"modified_at\",\n          \"columnName\": \"modified_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.title\",\n          \"name\": \"title\",\n          \"columnName\": \"title\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.slug\",\n          \"name\": \"slug\",\n          \"columnName\": \"slug\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.previous_slugs\",\n          \"name\": \"previous_slugs\",\n          \"columnName\": \"previous_slugs\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.description\",\n          \"name\": \"description\",\n          \"columnName\": \"description\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.created_by\",\n          \"name\": \"created_by\",\n          \"columnName\": \"created_by\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.deleted\",\n          \"name\": \"deleted\",\n          \"columnName\": \"deleted\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.category\",\n          \"name\": \"category\",\n          \"columnName\": \"category\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.difficulty_level\",\n          \"name\": \"difficulty_level\",\n          \"columnName\": \"difficulty_level\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.cover_image\",\n          \"name\": \"cover_image\",\n          \"columnName\": \"cover_image\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.file_link\",\n          \"name\": \"file_link\",\n          \"columnName\": \"file_link\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.files\",\n          \"name\": \"files\",\n          \"columnName\": \"files\",\n          \"type\": \"json[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.tags\",\n          \"name\": \"tags\",\n          \"columnName\": \"tags\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.is_draft\",\n          \"name\": \"is_draft\",\n          \"columnName\": \"is_draft\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.time\",\n          \"name\": \"time\",\n          \"columnName\": \"time\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.file_download_count\",\n          \"name\": \"file_download_count\",\n          \"columnName\": \"file_download_count\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.moderation\",\n          \"name\": \"moderation\",\n          \"columnName\": \"moderation\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.moderation_feedback\",\n          \"name\": \"moderation_feedback\",\n          \"columnName\": \"moderation_feedback\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.fts\",\n          \"name\": \"fts\",\n          \"columnName\": \"fts\",\n          \"type\": \"tsvector\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.total_views\",\n          \"name\": \"total_views\",\n          \"columnName\": \"total_views\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.comment_count\",\n          \"name\": \"comment_count\",\n          \"columnName\": \"comment_count\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.legacy_id\",\n          \"name\": \"legacy_id\",\n          \"columnName\": \"legacy_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.projects.published_at\",\n          \"name\": \"published_at\",\n          \"columnName\": \"published_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"categories\",\n          \"type\": \"categories\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"projectsTocategories\",\n          \"relationFromFields\": [\"category\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"projectsToprofiles\",\n          \"relationFromFields\": [\"created_by\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"project_steps\",\n          \"type\": \"project_steps\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"project_stepsToprojects\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"projects_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"projects_slug_key\",\n          \"fields\": [\"slug\", \"tenant_id\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"projects_title_key\",\n          \"fields\": [\"tenant_id\", \"title\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"questions\": {\n      \"id\": \"public.questions\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"questions\",\n      \"fields\": [\n        {\n          \"id\": \"public.questions.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"questions_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.created_by\",\n          \"name\": \"created_by\",\n          \"columnName\": \"created_by\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.deleted\",\n          \"name\": \"deleted\",\n          \"columnName\": \"deleted\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.modified_at\",\n          \"name\": \"modified_at\",\n          \"columnName\": \"modified_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.comment_count\",\n          \"name\": \"comment_count\",\n          \"columnName\": \"comment_count\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.description\",\n          \"name\": \"description\",\n          \"columnName\": \"description\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.moderation\",\n          \"name\": \"moderation\",\n          \"columnName\": \"moderation\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.slug\",\n          \"name\": \"slug\",\n          \"columnName\": \"slug\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.previous_slugs\",\n          \"name\": \"previous_slugs\",\n          \"columnName\": \"previous_slugs\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.category\",\n          \"name\": \"category\",\n          \"columnName\": \"category\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.tags\",\n          \"name\": \"tags\",\n          \"columnName\": \"tags\",\n          \"type\": \"int8[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.title\",\n          \"name\": \"title\",\n          \"columnName\": \"title\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.total_views\",\n          \"name\": \"total_views\",\n          \"columnName\": \"total_views\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.fts\",\n          \"name\": \"fts\",\n          \"columnName\": \"fts\",\n          \"type\": \"tsvector\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": true,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.images\",\n          \"name\": \"images\",\n          \"columnName\": \"images\",\n          \"type\": \"json[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.legacy_id\",\n          \"name\": \"legacy_id\",\n          \"columnName\": \"legacy_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.is_draft\",\n          \"name\": \"is_draft\",\n          \"columnName\": \"is_draft\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.questions.published_at\",\n          \"name\": \"published_at\",\n          \"columnName\": \"published_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"categories\",\n          \"type\": \"categories\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"questionsTocategories\",\n          \"relationFromFields\": [\"category\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"questionsToprofiles\",\n          \"relationFromFields\": [\"created_by\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"questions_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"unique_tenant_slug\",\n          \"fields\": [\"slug\", \"tenant_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"refresh_tokens\": {\n      \"id\": \"auth.refresh_tokens\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"refresh_tokens\",\n      \"fields\": [\n        {\n          \"id\": \"auth.refresh_tokens.instance_id\",\n          \"name\": \"instance_id\",\n          \"columnName\": \"instance_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.refresh_tokens.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"auth\\\".\\\"refresh_tokens_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.refresh_tokens.token\",\n          \"name\": \"token\",\n          \"columnName\": \"token\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.refresh_tokens.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.refresh_tokens.revoked\",\n          \"name\": \"revoked\",\n          \"columnName\": \"revoked\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.refresh_tokens.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.refresh_tokens.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.refresh_tokens.parent\",\n          \"name\": \"parent\",\n          \"columnName\": \"parent\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.refresh_tokens.session_id\",\n          \"name\": \"session_id\",\n          \"columnName\": \"session_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"sessions\",\n          \"type\": \"sessions\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"refresh_tokensTosessions\",\n          \"relationFromFields\": [\"session_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"refresh_tokens_token_unique\",\n          \"fields\": [\"token\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"research\": {\n      \"id\": \"public.research\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"research\",\n      \"fields\": [\n        {\n          \"id\": \"public.research.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"research_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.modified_at\",\n          \"name\": \"modified_at\",\n          \"columnName\": \"modified_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.title\",\n          \"name\": \"title\",\n          \"columnName\": \"title\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.slug\",\n          \"name\": \"slug\",\n          \"columnName\": \"slug\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.description\",\n          \"name\": \"description\",\n          \"columnName\": \"description\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.category\",\n          \"name\": \"category\",\n          \"columnName\": \"category\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.created_by\",\n          \"name\": \"created_by\",\n          \"columnName\": \"created_by\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.tags\",\n          \"name\": \"tags\",\n          \"columnName\": \"tags\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.deleted\",\n          \"name\": \"deleted\",\n          \"columnName\": \"deleted\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.total_views\",\n          \"name\": \"total_views\",\n          \"columnName\": \"total_views\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.total_useful\",\n          \"name\": \"total_useful\",\n          \"columnName\": \"total_useful\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.previous_slugs\",\n          \"name\": \"previous_slugs\",\n          \"columnName\": \"previous_slugs\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.status\",\n          \"name\": \"status\",\n          \"columnName\": \"status\",\n          \"type\": \"research_status\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.is_draft\",\n          \"name\": \"is_draft\",\n          \"columnName\": \"is_draft\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.fts\",\n          \"name\": \"fts\",\n          \"columnName\": \"fts\",\n          \"type\": \"tsvector\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.collaborators\",\n          \"name\": \"collaborators\",\n          \"columnName\": \"collaborators\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.image\",\n          \"name\": \"image\",\n          \"columnName\": \"image\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.legacy_id\",\n          \"name\": \"legacy_id\",\n          \"columnName\": \"legacy_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research.published_at\",\n          \"name\": \"published_at\",\n          \"columnName\": \"published_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"categories\",\n          \"type\": \"categories\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"researchTocategories\",\n          \"relationFromFields\": [\"category\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"researchToprofiles\",\n          \"relationFromFields\": [\"created_by\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"research_updates\",\n          \"type\": \"research_updates\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"research_updatesToresearch\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"research_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"research_updates\": {\n      \"id\": \"public.research_updates\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"research_updates\",\n      \"fields\": [\n        {\n          \"id\": \"public.research_updates.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"research_updates_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.research_id\",\n          \"name\": \"research_id\",\n          \"columnName\": \"research_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.title\",\n          \"name\": \"title\",\n          \"columnName\": \"title\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.description\",\n          \"name\": \"description\",\n          \"columnName\": \"description\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.images\",\n          \"name\": \"images\",\n          \"columnName\": \"images\",\n          \"type\": \"json[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.files\",\n          \"name\": \"files\",\n          \"columnName\": \"files\",\n          \"type\": \"json[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.video_url\",\n          \"name\": \"video_url\",\n          \"columnName\": \"video_url\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.is_draft\",\n          \"name\": \"is_draft\",\n          \"columnName\": \"is_draft\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.comment_count\",\n          \"name\": \"comment_count\",\n          \"columnName\": \"comment_count\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.modified_at\",\n          \"name\": \"modified_at\",\n          \"columnName\": \"modified_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.deleted\",\n          \"name\": \"deleted\",\n          \"columnName\": \"deleted\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.file_link\",\n          \"name\": \"file_link\",\n          \"columnName\": \"file_link\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.file_download_count\",\n          \"name\": \"file_download_count\",\n          \"columnName\": \"file_download_count\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.created_by\",\n          \"name\": \"created_by\",\n          \"columnName\": \"created_by\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.legacy_id\",\n          \"name\": \"legacy_id\",\n          \"columnName\": \"legacy_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.research_updates.published_at\",\n          \"name\": \"published_at\",\n          \"columnName\": \"published_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"research_updatesToprofiles\",\n          \"relationFromFields\": [\"created_by\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"research\",\n          \"type\": \"research\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"research_updatesToresearch\",\n          \"relationFromFields\": [\"research_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"research_updates_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"s3_multipart_uploads\": {\n      \"id\": \"storage.s3_multipart_uploads\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"s3_multipart_uploads\",\n      \"fields\": [\n        {\n          \"id\": \"storage.s3_multipart_uploads.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads.in_progress_size\",\n          \"name\": \"in_progress_size\",\n          \"columnName\": \"in_progress_size\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads.upload_signature\",\n          \"name\": \"upload_signature\",\n          \"columnName\": \"upload_signature\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads.bucket_id\",\n          \"name\": \"bucket_id\",\n          \"columnName\": \"bucket_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads.key\",\n          \"name\": \"key\",\n          \"columnName\": \"key\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads.version\",\n          \"name\": \"version\",\n          \"columnName\": \"version\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads.owner_id\",\n          \"name\": \"owner_id\",\n          \"columnName\": \"owner_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads.user_metadata\",\n          \"name\": \"user_metadata\",\n          \"columnName\": \"user_metadata\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"buckets\",\n          \"type\": \"buckets\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"s3_multipart_uploadsTobuckets\",\n          \"relationFromFields\": [\"bucket_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"s3_multipart_uploads_parts\",\n          \"type\": \"s3_multipart_uploads_parts\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"s3_multipart_uploads_partsTos3_multipart_uploads\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"s3_multipart_uploads_parts\": {\n      \"id\": \"storage.s3_multipart_uploads_parts\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"s3_multipart_uploads_parts\",\n      \"fields\": [\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.upload_id\",\n          \"name\": \"upload_id\",\n          \"columnName\": \"upload_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.size\",\n          \"name\": \"size\",\n          \"columnName\": \"size\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.part_number\",\n          \"name\": \"part_number\",\n          \"columnName\": \"part_number\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.bucket_id\",\n          \"name\": \"bucket_id\",\n          \"columnName\": \"bucket_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.key\",\n          \"name\": \"key\",\n          \"columnName\": \"key\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.etag\",\n          \"name\": \"etag\",\n          \"columnName\": \"etag\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.owner_id\",\n          \"name\": \"owner_id\",\n          \"columnName\": \"owner_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.version\",\n          \"name\": \"version\",\n          \"columnName\": \"version\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.s3_multipart_uploads_parts.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"buckets\",\n          \"type\": \"buckets\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"s3_multipart_uploads_partsTobuckets\",\n          \"relationFromFields\": [\"bucket_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"s3_multipart_uploads\",\n          \"type\": \"s3_multipart_uploads\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"s3_multipart_uploads_partsTos3_multipart_uploads\",\n          \"relationFromFields\": [\"upload_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"saml_providers\": {\n      \"id\": \"auth.saml_providers\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"saml_providers\",\n      \"fields\": [\n        {\n          \"id\": \"auth.saml_providers.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_providers.sso_provider_id\",\n          \"name\": \"sso_provider_id\",\n          \"columnName\": \"sso_provider_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_providers.entity_id\",\n          \"name\": \"entity_id\",\n          \"columnName\": \"entity_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_providers.metadata_xml\",\n          \"name\": \"metadata_xml\",\n          \"columnName\": \"metadata_xml\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_providers.metadata_url\",\n          \"name\": \"metadata_url\",\n          \"columnName\": \"metadata_url\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_providers.attribute_mapping\",\n          \"name\": \"attribute_mapping\",\n          \"columnName\": \"attribute_mapping\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_providers.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_providers.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_providers.name_id_format\",\n          \"name\": \"name_id_format\",\n          \"columnName\": \"name_id_format\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"sso_providers\",\n          \"type\": \"sso_providers\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"saml_providersTosso_providers\",\n          \"relationFromFields\": [\"sso_provider_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"saml_providers_entity_id_key\",\n          \"fields\": [\"entity_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"saml_relay_states\": {\n      \"id\": \"auth.saml_relay_states\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"saml_relay_states\",\n      \"fields\": [\n        {\n          \"id\": \"auth.saml_relay_states.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_relay_states.sso_provider_id\",\n          \"name\": \"sso_provider_id\",\n          \"columnName\": \"sso_provider_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_relay_states.request_id\",\n          \"name\": \"request_id\",\n          \"columnName\": \"request_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_relay_states.for_email\",\n          \"name\": \"for_email\",\n          \"columnName\": \"for_email\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_relay_states.redirect_to\",\n          \"name\": \"redirect_to\",\n          \"columnName\": \"redirect_to\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_relay_states.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_relay_states.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.saml_relay_states.flow_state_id\",\n          \"name\": \"flow_state_id\",\n          \"columnName\": \"flow_state_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"flow_state\",\n          \"type\": \"flow_state\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"saml_relay_statesToflow_state\",\n          \"relationFromFields\": [\"flow_state_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"sso_providers\",\n          \"type\": \"sso_providers\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"saml_relay_statesTosso_providers\",\n          \"relationFromFields\": [\"sso_provider_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"_realtime_schema_migrations\": {\n      \"id\": \"_realtime.schema_migrations\",\n      \"schemaName\": \"_realtime\",\n      \"tableName\": \"schema_migrations\",\n      \"fields\": [\n        {\n          \"id\": \"_realtime.schema_migrations.version\",\n          \"name\": \"version\",\n          \"columnName\": \"version\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.schema_migrations.inserted_at\",\n          \"name\": \"inserted_at\",\n          \"columnName\": \"inserted_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"auth_schema_migrations\": {\n      \"id\": \"auth.schema_migrations\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"schema_migrations\",\n      \"fields\": [\n        {\n          \"id\": \"auth.schema_migrations.version\",\n          \"name\": \"version\",\n          \"columnName\": \"version\",\n          \"type\": \"varchar\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": 255\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"realtime_schema_migrations\": {\n      \"id\": \"realtime.schema_migrations\",\n      \"schemaName\": \"realtime\",\n      \"tableName\": \"schema_migrations\",\n      \"fields\": [\n        {\n          \"id\": \"realtime.schema_migrations.version\",\n          \"name\": \"version\",\n          \"columnName\": \"version\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.schema_migrations.inserted_at\",\n          \"name\": \"inserted_at\",\n          \"columnName\": \"inserted_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"supabase_migrations_schema_migrations\": {\n      \"id\": \"supabase_migrations.schema_migrations\",\n      \"schemaName\": \"supabase_migrations\",\n      \"tableName\": \"schema_migrations\",\n      \"fields\": [\n        {\n          \"id\": \"supabase_migrations.schema_migrations.version\",\n          \"name\": \"version\",\n          \"columnName\": \"version\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"supabase_migrations.schema_migrations.statements\",\n          \"name\": \"statements\",\n          \"columnName\": \"statements\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"supabase_migrations.schema_migrations.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"schema_migrations_pkey\",\n          \"fields\": [\"version\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"secrets\": {\n      \"id\": \"vault.secrets\",\n      \"schemaName\": \"vault\",\n      \"tableName\": \"secrets\",\n      \"fields\": [\n        {\n          \"id\": \"vault.secrets.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"vault.secrets.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"vault.secrets.description\",\n          \"name\": \"description\",\n          \"columnName\": \"description\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"vault.secrets.secret\",\n          \"name\": \"secret\",\n          \"columnName\": \"secret\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"vault.secrets.key_id\",\n          \"name\": \"key_id\",\n          \"columnName\": \"key_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"vault.secrets.nonce\",\n          \"name\": \"nonce\",\n          \"columnName\": \"nonce\",\n          \"type\": \"bytea\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"vault.secrets.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"vault.secrets.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"secrets_name_idx\",\n          \"fields\": [\"name\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"seed_files\": {\n      \"id\": \"supabase_migrations.seed_files\",\n      \"schemaName\": \"supabase_migrations\",\n      \"tableName\": \"seed_files\",\n      \"fields\": [\n        {\n          \"id\": \"supabase_migrations.seed_files.path\",\n          \"name\": \"path\",\n          \"columnName\": \"path\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"supabase_migrations.seed_files.hash\",\n          \"name\": \"hash\",\n          \"columnName\": \"hash\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"seed_files_pkey\",\n          \"fields\": [\"path\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"sessions\": {\n      \"id\": \"auth.sessions\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"sessions\",\n      \"fields\": [\n        {\n          \"id\": \"auth.sessions.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.factor_id\",\n          \"name\": \"factor_id\",\n          \"columnName\": \"factor_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.aal\",\n          \"name\": \"aal\",\n          \"columnName\": \"aal\",\n          \"type\": \"aal_level\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.not_after\",\n          \"name\": \"not_after\",\n          \"columnName\": \"not_after\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.refreshed_at\",\n          \"name\": \"refreshed_at\",\n          \"columnName\": \"refreshed_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.user_agent\",\n          \"name\": \"user_agent\",\n          \"columnName\": \"user_agent\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.ip\",\n          \"name\": \"ip\",\n          \"columnName\": \"ip\",\n          \"type\": \"inet\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.tag\",\n          \"name\": \"tag\",\n          \"columnName\": \"tag\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.oauth_client_id\",\n          \"name\": \"oauth_client_id\",\n          \"columnName\": \"oauth_client_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.refresh_token_hmac_key\",\n          \"name\": \"refresh_token_hmac_key\",\n          \"columnName\": \"refresh_token_hmac_key\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.refresh_token_counter\",\n          \"name\": \"refresh_token_counter\",\n          \"columnName\": \"refresh_token_counter\",\n          \"type\": \"int8\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sessions.scopes\",\n          \"name\": \"scopes\",\n          \"columnName\": \"scopes\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"oauth_clients\",\n          \"type\": \"oauth_clients\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"sessionsTooauth_clients\",\n          \"relationFromFields\": [\"oauth_client_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"users\",\n          \"type\": \"users\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"sessionsTousers\",\n          \"relationFromFields\": [\"user_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"mfa_amr_claims\",\n          \"type\": \"mfa_amr_claims\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"mfa_amr_claimsTosessions\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"refresh_tokens\",\n          \"type\": \"refresh_tokens\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"refresh_tokensTosessions\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"sso_domains\": {\n      \"id\": \"auth.sso_domains\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"sso_domains\",\n      \"fields\": [\n        {\n          \"id\": \"auth.sso_domains.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sso_domains.sso_provider_id\",\n          \"name\": \"sso_provider_id\",\n          \"columnName\": \"sso_provider_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sso_domains.domain\",\n          \"name\": \"domain\",\n          \"columnName\": \"domain\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sso_domains.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sso_domains.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"sso_providers\",\n          \"type\": \"sso_providers\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"sso_domainsTosso_providers\",\n          \"relationFromFields\": [\"sso_provider_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"sso_providers\": {\n      \"id\": \"auth.sso_providers\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"sso_providers\",\n      \"fields\": [\n        {\n          \"id\": \"auth.sso_providers.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sso_providers.resource_id\",\n          \"name\": \"resource_id\",\n          \"columnName\": \"resource_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sso_providers.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sso_providers.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.sso_providers.disabled\",\n          \"name\": \"disabled\",\n          \"columnName\": \"disabled\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"saml_providers\",\n          \"type\": \"saml_providers\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"saml_providersTosso_providers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"saml_relay_states\",\n          \"type\": \"saml_relay_states\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"saml_relay_statesTosso_providers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"sso_domains\",\n          \"type\": \"sso_domains\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"sso_domainsTosso_providers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": []\n    },\n    \"subscribers\": {\n      \"id\": \"public.subscribers\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"subscribers\",\n      \"fields\": [\n        {\n          \"id\": \"public.subscribers.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"subscribers_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.subscribers.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.subscribers.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.subscribers.content_id\",\n          \"name\": \"content_id\",\n          \"columnName\": \"content_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.subscribers.content_type\",\n          \"name\": \"content_type\",\n          \"columnName\": \"content_type\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.subscribers.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"subscribersToprofiles\",\n          \"relationFromFields\": [\"user_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"subscribers_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"subscription\": {\n      \"id\": \"realtime.subscription\",\n      \"schemaName\": \"realtime\",\n      \"tableName\": \"subscription\",\n      \"fields\": [\n        {\n          \"id\": \"realtime.subscription.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": true,\n          \"sequence\": {\n            \"identifier\": \"\\\"realtime\\\".\\\"realtime.subscription_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.subscription.subscription_id\",\n          \"name\": \"subscription_id\",\n          \"columnName\": \"subscription_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.subscription.entity\",\n          \"name\": \"entity\",\n          \"columnName\": \"entity\",\n          \"type\": \"regclass\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.subscription.filters\",\n          \"name\": \"filters\",\n          \"columnName\": \"filters\",\n          \"type\": \"user_defined_filter[]\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.subscription.claims\",\n          \"name\": \"claims\",\n          \"columnName\": \"claims\",\n          \"type\": \"jsonb\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.subscription.claims_role\",\n          \"name\": \"claims_role\",\n          \"columnName\": \"claims_role\",\n          \"type\": \"regrole\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": true,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"realtime.subscription.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"subscription_subscription_id_entity_filters_key\",\n          \"fields\": [\"entity\", \"filters\", \"subscription_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"tags\": {\n      \"id\": \"public.tags\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"tags\",\n      \"fields\": [\n        {\n          \"id\": \"public.tags.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"tags_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tags.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tags.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tags.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tags.legacy_id\",\n          \"name\": \"legacy_id\",\n          \"columnName\": \"legacy_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tags.modified_at\",\n          \"name\": \"modified_at\",\n          \"columnName\": \"modified_at\",\n          \"type\": \"date\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"tags_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"tenant_settings\": {\n      \"id\": \"public.tenant_settings\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"tenant_settings\",\n      \"fields\": [\n        {\n          \"id\": \"public.tenant_settings.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"tenant_settings_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.site_name\",\n          \"name\": \"site_name\",\n          \"columnName\": \"site_name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.site_url\",\n          \"name\": \"site_url\",\n          \"columnName\": \"site_url\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.message_sign_off\",\n          \"name\": \"message_sign_off\",\n          \"columnName\": \"message_sign_off\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.email_from\",\n          \"name\": \"email_from\",\n          \"columnName\": \"email_from\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.site_image\",\n          \"name\": \"site_image\",\n          \"columnName\": \"site_image\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.site_favicon\",\n          \"name\": \"site_favicon\",\n          \"columnName\": \"site_favicon\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.donation_settings\",\n          \"name\": \"donation_settings\",\n          \"columnName\": \"donation_settings\",\n          \"type\": \"json\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.academy_resource\",\n          \"name\": \"academy_resource\",\n          \"columnName\": \"academy_resource\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.library_heading\",\n          \"name\": \"library_heading\",\n          \"columnName\": \"library_heading\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.no_messaging\",\n          \"name\": \"no_messaging\",\n          \"columnName\": \"no_messaging\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.patreon_id\",\n          \"name\": \"patreon_id\",\n          \"columnName\": \"patreon_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.profile_guidelines\",\n          \"name\": \"profile_guidelines\",\n          \"columnName\": \"profile_guidelines\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.questions_guidelines\",\n          \"name\": \"questions_guidelines\",\n          \"columnName\": \"questions_guidelines\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.supported_modules\",\n          \"name\": \"supported_modules\",\n          \"columnName\": \"supported_modules\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.site_description\",\n          \"name\": \"site_description\",\n          \"columnName\": \"site_description\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.color_primary\",\n          \"name\": \"color_primary\",\n          \"columnName\": \"color_primary\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.color_primary_hover\",\n          \"name\": \"color_primary_hover\",\n          \"columnName\": \"color_primary_hover\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.color_accent\",\n          \"name\": \"color_accent\",\n          \"columnName\": \"color_accent\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.color_accent_hover\",\n          \"name\": \"color_accent_hover\",\n          \"columnName\": \"color_accent_hover\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.show_impact\",\n          \"name\": \"show_impact\",\n          \"columnName\": \"show_impact\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.create_research_roles\",\n          \"name\": \"create_research_roles\",\n          \"columnName\": \"create_research_roles\",\n          \"type\": \"text[]\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.ga_tracking_id\",\n          \"name\": \"ga_tracking_id\",\n          \"columnName\": \"ga_tracking_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.tenant_settings.pwa_icons\",\n          \"name\": \"pwa_icons\",\n          \"columnName\": \"pwa_icons\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"tenant_settings_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"tenants\": {\n      \"id\": \"_realtime.tenants\",\n      \"schemaName\": \"_realtime\",\n      \"tableName\": \"tenants\",\n      \"fields\": [\n        {\n          \"id\": \"_realtime.tenants.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.external_id\",\n          \"name\": \"external_id\",\n          \"columnName\": \"external_id\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.jwt_secret\",\n          \"name\": \"jwt_secret\",\n          \"columnName\": \"jwt_secret\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.max_concurrent_users\",\n          \"name\": \"max_concurrent_users\",\n          \"columnName\": \"max_concurrent_users\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.inserted_at\",\n          \"name\": \"inserted_at\",\n          \"columnName\": \"inserted_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamp\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.max_events_per_second\",\n          \"name\": \"max_events_per_second\",\n          \"columnName\": \"max_events_per_second\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.postgres_cdc_default\",\n          \"name\": \"postgres_cdc_default\",\n          \"columnName\": \"postgres_cdc_default\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.max_bytes_per_second\",\n          \"name\": \"max_bytes_per_second\",\n          \"columnName\": \"max_bytes_per_second\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.max_channels_per_client\",\n          \"name\": \"max_channels_per_client\",\n          \"columnName\": \"max_channels_per_client\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.max_joins_per_second\",\n          \"name\": \"max_joins_per_second\",\n          \"columnName\": \"max_joins_per_second\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.suspend\",\n          \"name\": \"suspend\",\n          \"columnName\": \"suspend\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.jwt_jwks\",\n          \"name\": \"jwt_jwks\",\n          \"columnName\": \"jwt_jwks\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.notify_private_alpha\",\n          \"name\": \"notify_private_alpha\",\n          \"columnName\": \"notify_private_alpha\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.private_only\",\n          \"name\": \"private_only\",\n          \"columnName\": \"private_only\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.migrations_ran\",\n          \"name\": \"migrations_ran\",\n          \"columnName\": \"migrations_ran\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.broadcast_adapter\",\n          \"name\": \"broadcast_adapter\",\n          \"columnName\": \"broadcast_adapter\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"_realtime.tenants.max_presence_events_per_second\",\n          \"name\": \"max_presence_events_per_second\",\n          \"columnName\": \"max_presence_events_per_second\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"_realtime.tenants.max_payload_size_in_kb\",\n          \"name\": \"max_payload_size_in_kb\",\n          \"columnName\": \"max_payload_size_in_kb\",\n          \"type\": \"int4\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"extensions\",\n          \"type\": \"extensions\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"extensionsTotenants\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"tenants_external_id_index\",\n          \"fields\": [\"external_id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"upgrade_badge\": {\n      \"id\": \"public.upgrade_badge\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"upgrade_badge\",\n      \"fields\": [\n        {\n          \"id\": \"public.upgrade_badge.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"upgrade_badge_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.upgrade_badge.action_label\",\n          \"name\": \"action_label\",\n          \"columnName\": \"action_label\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.upgrade_badge.badge_id\",\n          \"name\": \"badge_id\",\n          \"columnName\": \"badge_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.upgrade_badge.is_space\",\n          \"name\": \"is_space\",\n          \"columnName\": \"is_space\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.upgrade_badge.action_url\",\n          \"name\": \"action_url\",\n          \"columnName\": \"action_url\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.upgrade_badge.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profile_badges\",\n          \"type\": \"profile_badges\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"upgrade_badgeToprofile_badges\",\n          \"relationFromFields\": [\"badge_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"upgrade_badge_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"useful_votes\": {\n      \"id\": \"public.useful_votes\",\n      \"schemaName\": \"public\",\n      \"tableName\": \"useful_votes\",\n      \"fields\": [\n        {\n          \"id\": \"public.useful_votes.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": {\n            \"identifier\": \"\\\"public\\\".\\\"useful_votes_id_seq\\\"\",\n            \"increment\": 1,\n            \"start\": 1\n          },\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.useful_votes.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.useful_votes.content_id\",\n          \"name\": \"content_id\",\n          \"columnName\": \"content_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.useful_votes.content_type\",\n          \"name\": \"content_type\",\n          \"columnName\": \"content_type\",\n          \"type\": \"useful_content_types\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.useful_votes.user_id\",\n          \"name\": \"user_id\",\n          \"columnName\": \"user_id\",\n          \"type\": \"int8\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"public.useful_votes.tenant_id\",\n          \"name\": \"tenant_id\",\n          \"columnName\": \"tenant_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"useful_votesToprofiles\",\n          \"relationFromFields\": [\"user_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"useful_votes_pkey\",\n          \"fields\": [\"id\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"users\": {\n      \"id\": \"auth.users\",\n      \"schemaName\": \"auth\",\n      \"tableName\": \"users\",\n      \"fields\": [\n        {\n          \"id\": \"auth.users.instance_id\",\n          \"name\": \"instance_id\",\n          \"columnName\": \"instance_id\",\n          \"type\": \"uuid\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"uuid\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.aud\",\n          \"name\": \"aud\",\n          \"columnName\": \"aud\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.role\",\n          \"name\": \"role\",\n          \"columnName\": \"role\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.email\",\n          \"name\": \"email\",\n          \"columnName\": \"email\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.encrypted_password\",\n          \"name\": \"encrypted_password\",\n          \"columnName\": \"encrypted_password\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.email_confirmed_at\",\n          \"name\": \"email_confirmed_at\",\n          \"columnName\": \"email_confirmed_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.invited_at\",\n          \"name\": \"invited_at\",\n          \"columnName\": \"invited_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.confirmation_token\",\n          \"name\": \"confirmation_token\",\n          \"columnName\": \"confirmation_token\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.confirmation_sent_at\",\n          \"name\": \"confirmation_sent_at\",\n          \"columnName\": \"confirmation_sent_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.recovery_token\",\n          \"name\": \"recovery_token\",\n          \"columnName\": \"recovery_token\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.recovery_sent_at\",\n          \"name\": \"recovery_sent_at\",\n          \"columnName\": \"recovery_sent_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.email_change_token_new\",\n          \"name\": \"email_change_token_new\",\n          \"columnName\": \"email_change_token_new\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.email_change\",\n          \"name\": \"email_change\",\n          \"columnName\": \"email_change\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.email_change_sent_at\",\n          \"name\": \"email_change_sent_at\",\n          \"columnName\": \"email_change_sent_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.last_sign_in_at\",\n          \"name\": \"last_sign_in_at\",\n          \"columnName\": \"last_sign_in_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.raw_app_meta_data\",\n          \"name\": \"raw_app_meta_data\",\n          \"columnName\": \"raw_app_meta_data\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.raw_user_meta_data\",\n          \"name\": \"raw_user_meta_data\",\n          \"columnName\": \"raw_user_meta_data\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.is_super_admin\",\n          \"name\": \"is_super_admin\",\n          \"columnName\": \"is_super_admin\",\n          \"type\": \"bool\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.phone\",\n          \"name\": \"phone\",\n          \"columnName\": \"phone\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.phone_confirmed_at\",\n          \"name\": \"phone_confirmed_at\",\n          \"columnName\": \"phone_confirmed_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.phone_change\",\n          \"name\": \"phone_change\",\n          \"columnName\": \"phone_change\",\n          \"type\": \"text\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.phone_change_token\",\n          \"name\": \"phone_change_token\",\n          \"columnName\": \"phone_change_token\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.phone_change_sent_at\",\n          \"name\": \"phone_change_sent_at\",\n          \"columnName\": \"phone_change_sent_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.confirmed_at\",\n          \"name\": \"confirmed_at\",\n          \"columnName\": \"confirmed_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": true,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.email_change_token_current\",\n          \"name\": \"email_change_token_current\",\n          \"columnName\": \"email_change_token_current\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.email_change_confirm_status\",\n          \"name\": \"email_change_confirm_status\",\n          \"columnName\": \"email_change_confirm_status\",\n          \"type\": \"int2\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.banned_until\",\n          \"name\": \"banned_until\",\n          \"columnName\": \"banned_until\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.reauthentication_token\",\n          \"name\": \"reauthentication_token\",\n          \"columnName\": \"reauthentication_token\",\n          \"type\": \"varchar\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": 255\n        },\n        {\n          \"id\": \"auth.users.reauthentication_sent_at\",\n          \"name\": \"reauthentication_sent_at\",\n          \"columnName\": \"reauthentication_sent_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.is_sso_user\",\n          \"name\": \"is_sso_user\",\n          \"columnName\": \"is_sso_user\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.deleted_at\",\n          \"name\": \"deleted_at\",\n          \"columnName\": \"deleted_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"auth.users.is_anonymous\",\n          \"name\": \"is_anonymous\",\n          \"columnName\": \"is_anonymous\",\n          \"type\": \"bool\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"identities\",\n          \"type\": \"identities\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"identitiesTousers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"mfa_factors\",\n          \"type\": \"mfa_factors\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"mfa_factorsTousers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"oauth_authorizations\",\n          \"type\": \"oauth_authorizations\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"oauth_authorizationsTousers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"oauth_consents\",\n          \"type\": \"oauth_consents\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"oauth_consentsTousers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"one_time_tokens\",\n          \"type\": \"one_time_tokens\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"one_time_tokensTousers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"sessions\",\n          \"type\": \"sessions\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"sessionsTousers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        },\n        {\n          \"name\": \"profiles\",\n          \"type\": \"profiles\",\n          \"isRequired\": false,\n          \"kind\": \"object\",\n          \"relationName\": \"profilesTousers\",\n          \"relationFromFields\": [],\n          \"relationToFields\": [],\n          \"isList\": true,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"confirmation_token_idx\",\n          \"fields\": [\"confirmation_token\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"email_change_token_current_idx\",\n          \"fields\": [\"email_change_token_current\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"email_change_token_new_idx\",\n          \"fields\": [\"email_change_token_new\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"reauthentication_token_idx\",\n          \"fields\": [\"reauthentication_token\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"recovery_token_idx\",\n          \"fields\": [\"recovery_token\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"users_email_partial_key\",\n          \"fields\": [\"email\"],\n          \"nullNotDistinct\": false\n        },\n        {\n          \"name\": \"users_phone_key\",\n          \"fields\": [\"phone\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    },\n    \"vector_indexes\": {\n      \"id\": \"storage.vector_indexes\",\n      \"schemaName\": \"storage\",\n      \"tableName\": \"vector_indexes\",\n      \"fields\": [\n        {\n          \"id\": \"storage.vector_indexes.id\",\n          \"name\": \"id\",\n          \"columnName\": \"id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": true,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.vector_indexes.name\",\n          \"name\": \"name\",\n          \"columnName\": \"name\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.vector_indexes.bucket_id\",\n          \"name\": \"bucket_id\",\n          \"columnName\": \"bucket_id\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.vector_indexes.data_type\",\n          \"name\": \"data_type\",\n          \"columnName\": \"data_type\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.vector_indexes.dimension\",\n          \"name\": \"dimension\",\n          \"columnName\": \"dimension\",\n          \"type\": \"int4\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.vector_indexes.distance_metric\",\n          \"name\": \"distance_metric\",\n          \"columnName\": \"distance_metric\",\n          \"type\": \"text\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.vector_indexes.metadata_configuration\",\n          \"name\": \"metadata_configuration\",\n          \"columnName\": \"metadata_configuration\",\n          \"type\": \"jsonb\",\n          \"isRequired\": false,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.vector_indexes.created_at\",\n          \"name\": \"created_at\",\n          \"columnName\": \"created_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"id\": \"storage.vector_indexes.updated_at\",\n          \"name\": \"updated_at\",\n          \"columnName\": \"updated_at\",\n          \"type\": \"timestamptz\",\n          \"isRequired\": true,\n          \"kind\": \"scalar\",\n          \"isList\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": true,\n          \"isId\": false,\n          \"maxLength\": null\n        },\n        {\n          \"name\": \"buckets_vectors\",\n          \"type\": \"buckets_vectors\",\n          \"isRequired\": true,\n          \"kind\": \"object\",\n          \"relationName\": \"vector_indexesTobuckets_vectors\",\n          \"relationFromFields\": [\"bucket_id\"],\n          \"relationToFields\": [\"id\"],\n          \"isList\": false,\n          \"isId\": false,\n          \"isGenerated\": false,\n          \"sequence\": false,\n          \"hasDefaultValue\": false\n        }\n      ],\n      \"uniqueConstraints\": [\n        {\n          \"name\": \"vector_indexes_name_bucket_id_idx\",\n          \"fields\": [\"bucket_id\", \"name\"],\n          \"nullNotDistinct\": false\n        }\n      ]\n    }\n  },\n  \"enums\": {\n    \"aal_level\": {\n      \"schemaName\": \"auth\",\n      \"values\": [\n        {\n          \"name\": \"aal1\"\n        },\n        {\n          \"name\": \"aal2\"\n        },\n        {\n          \"name\": \"aal3\"\n        }\n      ]\n    },\n    \"code_challenge_method\": {\n      \"schemaName\": \"auth\",\n      \"values\": [\n        {\n          \"name\": \"plain\"\n        },\n        {\n          \"name\": \"s256\"\n        }\n      ]\n    },\n    \"factor_status\": {\n      \"schemaName\": \"auth\",\n      \"values\": [\n        {\n          \"name\": \"unverified\"\n        },\n        {\n          \"name\": \"verified\"\n        }\n      ]\n    },\n    \"factor_type\": {\n      \"schemaName\": \"auth\",\n      \"values\": [\n        {\n          \"name\": \"phone\"\n        },\n        {\n          \"name\": \"totp\"\n        },\n        {\n          \"name\": \"webauthn\"\n        }\n      ]\n    },\n    \"oauth_authorization_status\": {\n      \"schemaName\": \"auth\",\n      \"values\": [\n        {\n          \"name\": \"approved\"\n        },\n        {\n          \"name\": \"denied\"\n        },\n        {\n          \"name\": \"expired\"\n        },\n        {\n          \"name\": \"pending\"\n        }\n      ]\n    },\n    \"oauth_client_type\": {\n      \"schemaName\": \"auth\",\n      \"values\": [\n        {\n          \"name\": \"confidential\"\n        },\n        {\n          \"name\": \"public\"\n        }\n      ]\n    },\n    \"oauth_registration_type\": {\n      \"schemaName\": \"auth\",\n      \"values\": [\n        {\n          \"name\": \"dynamic\"\n        },\n        {\n          \"name\": \"manual\"\n        }\n      ]\n    },\n    \"oauth_response_type\": {\n      \"schemaName\": \"auth\",\n      \"values\": [\n        {\n          \"name\": \"code\"\n        }\n      ]\n    },\n    \"one_time_token_type\": {\n      \"schemaName\": \"auth\",\n      \"values\": [\n        {\n          \"name\": \"confirmation_token\"\n        },\n        {\n          \"name\": \"email_change_token_current\"\n        },\n        {\n          \"name\": \"email_change_token_new\"\n        },\n        {\n          \"name\": \"phone_change_token\"\n        },\n        {\n          \"name\": \"reauthentication_token\"\n        },\n        {\n          \"name\": \"recovery_token\"\n        }\n      ]\n    },\n    \"request_status\": {\n      \"schemaName\": \"net\",\n      \"values\": [\n        {\n          \"name\": \"ERROR\"\n        },\n        {\n          \"name\": \"PENDING\"\n        },\n        {\n          \"name\": \"SUCCESS\"\n        }\n      ]\n    },\n    \"content_types\": {\n      \"schemaName\": \"public\",\n      \"values\": [\n        {\n          \"name\": \"comments\"\n        },\n        {\n          \"name\": \"news\"\n        },\n        {\n          \"name\": \"projects\"\n        },\n        {\n          \"name\": \"questions\"\n        },\n        {\n          \"name\": \"research\"\n        }\n      ]\n    },\n    \"notification_action_types\": {\n      \"schemaName\": \"public\",\n      \"values\": [\n        {\n          \"name\": \"newComment\"\n        },\n        {\n          \"name\": \"newContent\"\n        },\n        {\n          \"name\": \"newReply\"\n        }\n      ]\n    },\n    \"notification_content_types\": {\n      \"schemaName\": \"public\",\n      \"values\": [\n        {\n          \"name\": \"comment\"\n        },\n        {\n          \"name\": \"comments\"\n        },\n        {\n          \"name\": \"library\"\n        },\n        {\n          \"name\": \"news\"\n        },\n        {\n          \"name\": \"projects\"\n        },\n        {\n          \"name\": \"questions\"\n        },\n        {\n          \"name\": \"reply\"\n        },\n        {\n          \"name\": \"research\"\n        },\n        {\n          \"name\": \"researchUpdate\"\n        },\n        {\n          \"name\": \"research_updates\"\n        }\n      ]\n    },\n    \"notification_source_content_type\": {\n      \"schemaName\": \"public\",\n      \"values\": [\n        {\n          \"name\": \"library\"\n        },\n        {\n          \"name\": \"news\"\n        },\n        {\n          \"name\": \"projects\"\n        },\n        {\n          \"name\": \"questions\"\n        },\n        {\n          \"name\": \"research\"\n        },\n        {\n          \"name\": \"researchUpdate\"\n        },\n        {\n          \"name\": \"research_updates\"\n        }\n      ]\n    },\n    \"research_status\": {\n      \"schemaName\": \"public\",\n      \"values\": [\n        {\n          \"name\": \"archived\"\n        },\n        {\n          \"name\": \"complete\"\n        },\n        {\n          \"name\": \"in-progress\"\n        }\n      ]\n    },\n    \"useful_content_types\": {\n      \"schemaName\": \"public\",\n      \"values\": [\n        {\n          \"name\": \"comments\"\n        },\n        {\n          \"name\": \"news\"\n        },\n        {\n          \"name\": \"projects\"\n        },\n        {\n          \"name\": \"questions\"\n        },\n        {\n          \"name\": \"research\"\n        }\n      ]\n    },\n    \"action\": {\n      \"schemaName\": \"realtime\",\n      \"values\": [\n        {\n          \"name\": \"DELETE\"\n        },\n        {\n          \"name\": \"ERROR\"\n        },\n        {\n          \"name\": \"INSERT\"\n        },\n        {\n          \"name\": \"TRUNCATE\"\n        },\n        {\n          \"name\": \"UPDATE\"\n        }\n      ]\n    },\n    \"equality_op\": {\n      \"schemaName\": \"realtime\",\n      \"values\": [\n        {\n          \"name\": \"eq\"\n        },\n        {\n          \"name\": \"gt\"\n        },\n        {\n          \"name\": \"gte\"\n        },\n        {\n          \"name\": \"in\"\n        },\n        {\n          \"name\": \"lt\"\n        },\n        {\n          \"name\": \"lte\"\n        },\n        {\n          \"name\": \"neq\"\n        }\n      ]\n    },\n    \"buckettype\": {\n      \"schemaName\": \"storage\",\n      \"values\": [\n        {\n          \"name\": \"ANALYTICS\"\n        },\n        {\n          \"name\": \"STANDARD\"\n        },\n        {\n          \"name\": \"VECTOR\"\n        }\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": ".snaplet/library.json",
    "content": "[\n  {\n    \"title\": \"Basic Shredder Machine\",\n    \"description\": \"Learn how to build a basic plastic shredder machine for breaking down plastic waste into small flakes.\",\n    \"difficulty_level\": \"beginner\",\n    \"time\": \"2-3 days\",\n    \"moderation\": \"accepted\",\n    \"file_link\": \"https://example.com/shredder-plans.pdf\",\n    \"comments\": [\n      {\n        \"aldaplaskett48\": \"This is a great starter project! I built one last month and it works perfectly for PET bottles.\"\n      },\n      {\n        \"sampathpini67\": \"The motor specifications are really helpful. Make sure to get the right RPM for safety.\"\n      }\n    ],\n    \"steps\": [\n      {\n        \"title\": \"Gather Materials\",\n        \"description\": \"Collect all the necessary materials including steel frame, motor, and cutting blades.\",\n        \"images\": [\"shredder-materials.jpg\"],\n        \"video_url\": null\n      },\n      {\n        \"title\": \"Build the Frame\",\n        \"description\": \"Construct the main frame structure using welding techniques.\",\n        \"images\": [\"frame-construction.jpg\"],\n        \"video_url\": \"https://example.com/frame-build.mp4\"\n      }\n    ]\n  },\n  {\n    \"title\": \"Injection Molding Machine\",\n    \"description\": \"Build an injection molding machine to create new products from recycled plastic flakes.\",\n    \"difficulty_level\": \"advanced\",\n    \"time\": \"1-2 weeks\",\n    \"moderation\": \"accepted\",\n    \"file_link\": \"https://example.com/injection-molder-plans.pdf\",\n    \"comments\": [\n      {\n        \"galenagiugovaz15\": \"This machine is a game changer! I've been making phone cases and small containers with it.\"\n      },\n      {\n        \"veniaminjewell33\": \"The temperature control is crucial. Don't skip the calibration steps.\"\n      },\n      {\n        \"cortneybrown81\": \"Amazing project! The documentation is very detailed and easy to follow.\"\n      }\n    ],\n    \"steps\": [\n      {\n        \"title\": \"Prepare the Barrel\",\n        \"description\": \"Machine and prepare the injection barrel with proper heating elements.\",\n        \"images\": [\"barrel-prep.jpg\", \"heating-elements.jpg\"],\n        \"video_url\": null\n      },\n      {\n        \"title\": \"Install Control System\",\n        \"description\": \"Set up the temperature and pressure control systems.\",\n        \"images\": [\"control-panel.jpg\"],\n        \"video_url\": \"https://example.com/control-setup.mp4\"\n      },\n      {\n        \"title\": \"Test and Calibrate\",\n        \"description\": \"Run initial tests and calibrate the machine for optimal performance.\",\n        \"images\": [\"testing.jpg\"],\n        \"video_url\": \"https://example.com/calibration.mp4\"\n      }\n    ]\n  },\n  {\n    \"title\": \"Simple Extrusion Machine\",\n    \"description\": \"Create continuous shapes and filaments from recycled plastic using this extrusion machine design.\",\n    \"difficulty_level\": \"intermediate\",\n    \"time\": \"4-5 days\",\n    \"moderation\": \"accepted\",\n    \"file_link\": \"https://example.com/extruder-plans.pdf\",\n    \"comments\": [\n      {\n        \"melisavang56\": \"Perfect for making 3D printing filament! The output quality is surprisingly good.\"\n      },\n      {\n        \"lianabegam24\": \"I modified the design slightly for my needs and it works great for making plastic sheets.\"\n      }\n    ],\n    \"steps\": [\n      {\n        \"title\": \"Build the Screw Mechanism\",\n        \"description\": \"Construct the main screw that will push and heat the plastic material.\",\n        \"images\": [\"screw-assembly.jpg\"],\n        \"video_url\": null\n      },\n      {\n        \"title\": \"Create the Heating Chamber\",\n        \"description\": \"Build the heated chamber where plastic will be melted and formed.\",\n        \"images\": [\"heating-chamber.jpg\"],\n        \"video_url\": \"https://example.com/chamber-build.mp4\"\n      }\n    ]\n  },\n  {\n    \"title\": \"Compression Molding Press\",\n    \"description\": \"Build a compression molding press for creating flat products like plates and sheets.\",\n    \"difficulty_level\": \"beginner\",\n    \"time\": \"1-2 days\",\n    \"moderation\": \"accepted\",\n    \"file_link\": \"https://example.com/compression-press-plans.pdf\",\n    \"comments\": [\n      {\n        \"akromstarkova72\": \"Super simple build! Great for beginners who want to start with something achievable.\"\n      }\n    ],\n    \"steps\": [\n      {\n        \"title\": \"Assemble the Press Frame\",\n        \"description\": \"Build the main frame structure for the compression press.\",\n        \"images\": [\"press-frame.jpg\"],\n        \"video_url\": null\n      },\n      {\n        \"title\": \"Install Hydraulic System\",\n        \"description\": \"Set up the hydraulic system for applying pressure during molding.\",\n        \"images\": [\"hydraulic-setup.jpg\"],\n        \"video_url\": \"https://example.com/hydraulic-install.mp4\"\n      }\n    ]\n  },\n  {\n    \"title\": \"Plastic Waste Sorting Guide\",\n    \"description\": \"A comprehensive guide on how to properly sort and prepare plastic waste for recycling.\",\n    \"difficulty_level\": \"beginner\",\n    \"time\": \"30 minutes\",\n    \"moderation\": \"accepted\",\n    \"file_link\": \"https://example.com/sorting-guide.pdf\",\n    \"comments\": [\n      {\n        \"mirzoblazkova19\": \"This guide saved me so much time! The identification charts are really helpful.\"\n      },\n      {\n        \"jereerickson92\": \"Essential reading before starting any plastic recycling project.\"\n      }\n    ],\n    \"steps\": [\n      {\n        \"title\": \"Learn Plastic Types\",\n        \"description\": \"Understand the different types of plastic and their recycling codes.\",\n        \"images\": [\"plastic-types-chart.jpg\"],\n        \"video_url\": \"https://example.com/plastic-identification.mp4\"\n      },\n      {\n        \"title\": \"Set Up Sorting System\",\n        \"description\": \"Create an efficient system for sorting plastic waste by type and color.\",\n        \"images\": [\"sorting-bins.jpg\"],\n        \"video_url\": null\n      }\n    ]\n  }\n]\n"
  },
  {
    "path": ".snaplet/questions.json",
    "content": "{\n  \"jereerickson92\": [\n    {\n      \"title\": \"What is Precious Plastic?\",\n      \"description\": \"Explain the mission and goals of the Precious Plastic initiative.\",\n      \"comments\": [\n        {\n          \"aldaplaskett48\": \"It's crazy how much plastic we're exposed to every day! Microplastics are literally in our food, water, and even the air. Scientists are still figuring out the long-term effects, but some studies suggest they can mess with hormones and digestion. Makes you think twice about drinking from plastic bottles!\"\n        },\n        {\n          \"sampathpini67\": \"The chemicals in plastic are no joke. BPA, phthalates, all that stuff—these can leach into food and drinks, and they've been linked to hormonal imbalances and fertility issues. And yet, plastic is everywhere! Hard to avoid completely, but switching to glass or stainless steel can help reduce exposure.\"\n        },\n        {\n          \"galenagiugovaz15\": \"People don't realize how bad burning plastic is. The smoke from it releases toxic chemicals that can cause serious lung problems and even cancer. Where I live, some folks still burn their trash, and you can literally feel it in your chest when you breathe. It's awful!\",\n          \"replies\": [\n            {\n              \"aldaplaskett48\": \"I agree!\"\n            }\n          ]\n        },\n        {\n          \"aldaplaskett48\": \"Microplastics are literally everywhere—in our food, water, even the air. Kinda scary when you realize we’re basically consuming plastic every day without even knowing the full health effects.\"\n        }\n      ]\n    },\n    {\n      \"title\": \"How does Precious Plastic help fight plastic pollution?\",\n      \"description\": \"Describe the ways in which Precious Plastic contributes to reducing plastic waste.\",\n      \"comments\": [\n        {\n          \"aldaplaskett48\": \"outro\"\n        }\n      ]\n    },\n    {\n      \"title\": \"What types of machines does Precious Plastic offer?\",\n      \"description\": \"List and describe the different machines available for recycling plastic.\"\n    },\n    {\n      \"title\": \"How can individuals get involved with Precious Plastic?\",\n      \"description\": \"Provide information on how people can participate in the Precious Plastic movement.\"\n    },\n    {\n      \"title\": \"What materials can be processed using Precious Plastic machines?\",\n      \"description\": \"Explain which types of plastic can be recycled using Precious Plastic machines.\"\n    },\n    {\n      \"title\": \"How does the Precious Plastic community collaborate globally?\",\n      \"description\": \"Describe the role of the global community in the Precious Plastic movement and how people connect.\"\n    },\n    {\n      \"title\": \"What products can be made from recycled plastic?\",\n      \"description\": \"Give examples of items that can be created using Precious Plastic's recycling process.\"\n    },\n    {\n      \"title\": \"How can businesses benefit from Precious Plastic?\",\n      \"description\": \"Explain how entrepreneurs and small businesses can use Precious Plastic's technology for commercial purposes.\"\n    },\n    {\n      \"title\": \"What are the main challenges of plastic recycling?\",\n      \"description\": \"Discuss the biggest obstacles faced in plastic recycling and how Precious Plastic addresses them.\"\n    },\n    {\n      \"title\": \"Where can I find Precious Plastic workspaces near me?\",\n      \"description\": \"Guide users on how to locate Precious Plastic workspaces in their region.\"\n    }\n  ],\n  \"aldaplaskett48\": [\n    {\n      \"title\": \"How does plastic pollution impact marine life?\",\n      \"description\": \"Explain the effects of plastic waste on ocean ecosystems and marine animals.\"\n    },\n    {\n      \"title\": \"What are microplastics, and why are they a problem?\",\n      \"description\": \"Describe what microplastics are, how they form, and their environmental and health impacts.\"\n    },\n    {\n      \"title\": \"What are some sustainable alternatives to plastic?\",\n      \"description\": \"List eco-friendly materials that can replace plastic in everyday products.\"\n    },\n    {\n      \"title\": \"How does plastic contribute to climate change?\",\n      \"description\": \"Discuss the relationship between plastic production, fossil fuels, and greenhouse gas emissions.\"\n    },\n    {\n      \"title\": \"What policies and regulations exist to reduce plastic waste?\",\n      \"description\": \"Provide an overview of international laws and local initiatives aimed at limiting plastic pollution.\"\n    }\n  ],\n  \"sampathpini67\": [\n    {\n      \"title\": \"How long does plastic take to decompose in nature?\",\n      \"description\": \"Explain the decomposition process of different types of plastic and their impact on ecosystems.\"\n    },\n    {\n      \"title\": \"What role does recycling play in reducing plastic waste?\",\n      \"description\": \"Discuss the importance of recycling in managing plastic waste and its effectiveness.\"\n    },\n    {\n      \"title\": \"How does plastic pollution affect human health?\",\n      \"description\": \"Describe the ways in which plastic exposure and pollution can impact human well-being.\"\n    },\n    {\n      \"title\": \"What actions can individuals take to reduce plastic waste?\",\n      \"description\": \"Provide practical steps for people to minimize their plastic consumption and waste.\"\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  // Use IntelliSense to learn about possible attributes.\n  // Hover to view descriptions of existing attributes.\n  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n  \"version\": \"0.2.0\",\n\n  \"configurations\": [\n    {\n      \"name\": \"Community Platform\",\n      \"command\": \"bun run start\",\n      \"request\": \"launch\",\n      \"type\": \"node-terminal\",\n      \"cwd\": \"${workspaceFolder}\"\n    },\n    {\n      \"name\": \"Community Platform Unit Tests\",\n      \"command\": \"bun run test:unit\",\n      \"request\": \"launch\",\n      \"type\": \"node-terminal\",\n      \"cwd\": \"${workspaceFolder}\"\n    },\n    {\n      \"name\": \"Attach by Process ID\",\n      \"processId\": \"${command:PickProcess}\",\n      \"request\": \"attach\",\n      \"skipFiles\": [\"<node_internals>/**\"],\n      \"type\": \"node\"\n    }\n  ]\n}\n"
  },
  {
    "path": ".yarnrc.yml",
    "content": "checksumBehavior: update\n\ncompressionLevel: mixed\n\nenableGlobalCache: false\n\nenableScripts: false\n\nlogFilters:\n  - code: YN0060\n    level: discard\n  - code: YN0002\n    level: discard\n\nnmMode: hardlinks-local\n\nnodeLinker: node-modules\n\nyarnPath: .yarn/releases/yarn-4.12.0.cjs\n"
  },
  {
    "path": "CNAME",
    "content": "storybook.onearmy.earth"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as\ncontributors and maintainers pledge to making participation in our project and\nour community a harassment-free experience for everyone, regardless of age, body\nsize, disability, ethnicity, sex characteristics, gender identity and expression,\nlevel of experience, education, socioeconomic status, nationality, personal\nappearance, race, religion, or sexual identity and orientation.\n\n## Our Standards\n\nExamples of behaviour that contributes to creating a positive environment\ninclude:\n\n- Using welcoming and inclusive language\n- Being respectful of differing viewpoints and experiences\n- Gracefully accepting constructive criticism\n- Focusing on what is best for the community\n- Showing empathy towards other community members\n\nExamples of unacceptable behaviour by participants include:\n\n- The use of sexualized language or imagery and unwelcome sexual attention or\n  advances\n- Trolling, insulting/derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or electronic\n  address, without explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Our Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable\nbehaviour and are expected to take appropriate and fair corrective action in\nresponse to any instances of unacceptable behaviour.\n\nProject maintainers have the right and responsibility to remove, edit, or\nreject comments, commits, code, wiki edits, issues, and other contributions\nthat are not aligned to this Code of Conduct, or to ban temporarily or\npermanently any contributor for other behaviours that they deem inappropriate,\nthreatening, offensive, or harmful.\n\n## Scope\n\nThis Code of Conduct applies both within project spaces and in public spaces\nwhen an individual is representing the project or its community. Examples of\nrepresenting a project or community include using an official project e-mail\naddress, posting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event. Representation of a project may be\nfurther defined and clarified by project maintainers.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behaviour may be\nreported by contacting the project team at [platform@onearmy.earth](mailto:platform@onearmy.earth?subject=contact%20from%20github%20conduct). All\ncomplaints will be reviewed and investigated and will result in a response that\nis deemed necessary and appropriate to the circumstances. The project team is\nobligated to maintain confidentiality with regard to the reporter of an incident.\nFurther details of specific enforcement policies may be posted separately.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good\nfaith may face temporary or permanent repercussions as determined by other\nmembers of the project's leadership.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,\navailable at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see\nhttps://www.contributor-covenant.org/faq\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contribution Guidelines\n\nThanks for being here already! You'll find all the information you need to start contributing to the project. Make sure to read them before submitting your contribution.\n\nIf you think something is missing, consider sending us a PR.\n\n## 🍽 Summary\n\n- [Code of conduct](https://github.com/ONEARMY/community-platform/blob/master/CONTRIBUTING.md#-code-of-conduct)\n- [Getting started](https://github.com/ONEARMY/community-platform/blob/master/CONTRIBUTING.md#-getting-started)\n- [Project structure](https://github.com/ONEARMY/community-platform/blob/master/CONTRIBUTING.md#-project-structure)\n- [Branching](https://github.com/ONEARMY/community-platform/blob/master/CONTRIBUTING.md#-branching)\n- [Style guide](https://github.com/ONEARMY/community-platform/blob/master/CONTRIBUTING.md#-style-guide)\n- [Testing](https://github.com/ONEARMY/community-platform/blob/master/CONTRIBUTING.md#-testing)\n- [Joining the team](https://github.com/ONEARMY/community-platform/blob/master/CONTRIBUTING.md#-joining-the-team)\n- [Contributing with UX/UI Design](https://github.com/ONEARMY/community-platform/blob/master/CONTRIBUTING.md#-contributing-with-uxui-design)\n\n## 👐 Code of Conduct\n\nThis project and everyone participating in it is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behaviour to [platform@onearmy.earth](mailto:platform@onearmy.earth).\n\nAlso check our [Team Principles](./docs/team-principles.md), which guide our work.\n\n## 📟 Getting started\n\n### Prerequisites\n\n- [Bun 1.3.10+](https://bun.sh/docs/installation)\n\n### One time setup\n\n1. Fork the repository.\n\n2. Clone the project from the fork you have created previously at first step:\n\n   ```\n   git clone https://github.com/<your-github-username>/community-platform.git\n   ```\n\n3. Install dependencies\n   ```\n   bun install\n   ```\n4. [Create a local supabase instance](./docs/supabase.md)\n\n5. Run the app\n   ```\n   bun start\n   ```\n\n## ⚙️ Technology\n\nOur main technologies are [React Router 7](./docs/react-router-7.md) and [Supabase](./docs/supabase.md)\nWe try to document some important [Technical Decisions](./docs/technical-decisions.md).\n\n## 🏠 Project Structure\n\n- **`src`**\n  - **`routes`** : app routes, including api routes.\n  - **`pages`** : Main components for each page are located here. Each folder should correspond to a **feature** of the platform.\n  - **`services`** : client-side services to interact with our api and server-side services to interact with supabase or other external service like patreon.\n  - **`assets`** : contains assets such as icons/images.\n  - **`utils`** : contains utility functions.\n- **`packages/components/`**: - general stateless components that compose the app.\n- **`packages/themes/`**: - theme definitions for presentation inherited by components\n- **`packages/cypress/`** : contains the test automation of End-to-end tests.\n- **`shared`** : contains mainly type definitions\n\n## 🌳 Branching\n\nWe have a single `master` branch which is linked to production.\n\nQA sites will be created from Pull Request branches upon adding the `Review allow-preview ✅` label (Mainteiners only).\n\nTo contribute, you should fork our `master` branch and create a branch from your own fork. When your changes are ready, submit a PR from your fork branch, into our `master` branch.\n\nIt is recommended to update your `master` fork regularly to avoid conflicts.\n\n## 🤓 Style guide\n\nWe use `Biome` please install the extension on your IDE https://biomejs.dev/guides/editors/first-party-extensions/.\n\nAdd to your `.vscode/settings.json`:\n\n```\n   \"editor.codeActionsOnSave\": {\n      \"source.organizeImports.biome\": \"explicit\",\n      \"source.fixAll.biome\": \"explicit\"\n   }\n```\n\nAlso make sure you set your vscode default formatter to Biome (CTRL + SHIFT + P > Format Document With... -> Configure Default Formatter -> Biome).\n\nRunning `bun run format` from the project root prior to committing will ensure the code you're adding is formatted to align with the standards of this project.\n\nWe expect code to follow standard practices:\n\n- Simple, clear and self-documenting code, with meaningful names.\n- avoid deep nesting and prop-drilling and avoid unnecessary abstractions.\n- Create additional functions if it would aid code readability.\n- Extend existing functions over creating new ones.\n- Don't add code comments.\n\n## ✅ Testing\n\nWriting tests is crucial for maintaining a robust and reliable codebase. Tests provide a safety net that helps catch errors and unintended behaviour changes before they reach production. Keep them simple and as easy to read as the other code.\n\nTest Writing Guidelines:\n\n- Unit Tests are useful for code that has logic, it could be a frontend component or a server-side function.\n- E2E tests are useful for full feature tests, which require database interaction.\n- Focus on testing significant aspects and edge cases, not for the sake of coverage.\n- Adhere to the testing conventions and styles established in the project.\n- If your changes affect existing functionality, update the corresponding tests to reflect the new behaviour.\n\n## Security\n\nWe have evolved our security practices with the introduction of React-Router 7, Fly.io and Supabase in 2024/2025.\nA few practices we ensure:\n\n- Database connection is done server-side only\n- Secrets are managed server-side on fly.io\n- We use `helmet` for CSP and other security checks\n- Reduce and keep packages up-to-date to reduce potential vulnerabilities\n\n## First time contributing?\n\nEarly on, and especially when contributing for the first time, please only submit and work on a single issue at a time. It's likely there's lots of little changes we'd like you to make while you get up to speed with how we work and what the code already does.\n\n## 🤝 Joining the team\n\nWe are always open to have more people involved. If you would like to contribute more often and become a maintainer, we would love to welcome you to the team. [Join us on Discord](https://discord.gg/gJ7Yyk4) and checkout the [development](https://discord.com/channels/586676777334865928/938781727017558018) channel. Feel free to introduce yourself and outline:\n\n1. How much time you feel you can dedicate to the project\n2. Any relevant experience working with web technologies\n\nWe ask this so that we can better understand how you might fit in with the rest of the team, and maximise your contributions.\n\nHere are more details about what to expect from becoming a [Maintainer](./docs/maintainers.md)\n\n## 🎨 Contributing with UX/UI Design\n\nWe always welcome UX/UI design contributions in various shapes and forms. Whether it's designing new features, giving design feedback or optimising the existing flows.\n\nThe best way to start would be to look at [open design issues](https://github.com/ONEARMY/community-platform/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22Task%3A%20Design%22) here in our GitHub Repository to see if anything sparks your interest. Another way is to get in touch with us [through Discord](https://discord.gg/p4hWHYeG), the introduce-yourself channel is a good place for that. :) Then we can chat about what would be interesting for you to help out with, as well as what we are currently working on.\n\nIn the meantime you can check out our [UI Component library in Storybook](https://storybook.onearmy.earth).\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax = docker/dockerfile:1\n\nFROM oven/bun:1.3.10 AS base\n\nLABEL fly_launch_runtime=\"React Router\"\n\n# App lives here\nWORKDIR /app\n\n# Set production environment\nENV NODE_ENV=\"production\"\n\n# Add CircleCI context variables as ARGs\nARG VITE_BRANCH\nARG VITE_SENTRY_DSN\n\n# Throw-away build stage to reduce size of final image\nFROM base AS build\n\n# Install packages needed to build node modules\nRUN apt-get update -qq && \\\n    apt-get install --no-install-recommends -y build-essential pkg-config python-is-python3\n\n# Copy source code\nADD . .\n\n# Install packages\nRUN bun install\n\nRUN --mount=type=secret,id=VITE_BRANCH \\\n    --mount=type=secret,id=VITE_SENTRY_DSN \\\n    VITE_BRANCH=\"$(cat /run/secrets/VITE_BRANCH)\" && \\\n    VITE_SENTRY_DSN=\"$(cat /run/secrets/VITE_SENTRY_DSN)\" && \\\n    echo \"VITE_BRANCH=\\\"${VITE_BRANCH}\\\"\" >> .env && \\\n    echo \"VITE_SENTRY_DSN=\\\"${VITE_SENTRY_DSN}\\\"\" >> .env\n\n# Build application (skip tsc type checking - already done in CI)\nRUN bun run build:shared && bun run build:themes && bun run build:components && bun run build:vite\n\n# Final stage for app image\nFROM base\n\n# Copy built application\nCOPY --from=build /app /app\n\n# Start the server by default, this can be overwritten at runtime\nENV PORT=3000\nEXPOSE 3000\nCMD [ \"bun\", \"./server.js\" ]\n\n"
  },
  {
    "path": "Dockerfile.preview",
    "content": "# syntax = docker/dockerfile:1\n\nFROM oven/bun:1.3.10 AS base\n\nLABEL fly_launch_runtime=\"React Router\"\n\n# App lives here\nWORKDIR /app\n\n# Set production environment\nENV NODE_ENV=\"production\"\n\n# Throw-away build stage to reduce size of final image\nFROM base AS build\n\n# Cache-busting arg - changes on each commit\nARG COMMIT_SHA\n\n# Install packages needed to build node modules\nRUN apt-get update -qq && \\\n    apt-get install --no-install-recommends -y build-essential pkg-config python-is-python3\n\n# Copy source code\nCOPY . .\n\n# Install packages\nRUN bun install\n\nRUN echo \"VITE_SITE_VARIANT=\\\"preview\\\"\" >> .env\n\n# Build application (skip tsc type checking - already done in CI)\nRUN bun run build:shared && bun run build:themes && bun run build:components && bun run build:vite\n\n# Final stage for app image\nFROM base\n\n# Copy built application\nCOPY --from=build /app /app\n\n# Start the server by default, this can be overwritten at runtime\nENV PORT=3000\nEXPOSE 3000\nCMD [ \"bun\", \"./server.js\" ]\n\n"
  },
  {
    "path": "FUNDING.yml",
    "content": "# These are supported funding model platforms\n\npatreon: one_army\nopen_collective: onearmy\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 OneArmyWorld\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/from-referrer/)\n[![Build Status](https://circleci.com/gh/ONEARMY/community-platform/tree/master.svg?style=shield)](https://app.circleci.com/pipelines/github/ONEARMY/community-platform?branch=master)\n[![GitHub license](https://badgen.net/github/license/ONEARMY/community-platform)](https://github.com/ONEARMY/community-platform/blob/master/LICENSE)\n[![GitHub license](https://badgen.net/github/tag/ONEARMY/community-platform)](https://github.com/ONEARMY/community-platform/blob/master/LICENSE)\n[![GitHub contributors](https://img.shields.io/github/contributors/ONEARMY/community-platform)](https://github.com/ONEARMY/community-platform/graphs/contributors/)\n[![discord](https://badges.aleen42.com/src/discord.svg)](https://discord.gg/gJ7Yyk4)\n\n# 🔗 &nbsp; tl;dr Quick Links\n\n- [Our Project Website](https://www.onearmy.earth/community-platform)\n- [Precious Plastic Community (live site)](http://community.preciousplastic.com/)\n- [Project Kamp Community (live site)](http://community.projectkamp.com/)\n- [Developer documentation](/CONTRIBUTING.md)\n\n# 🌍 &nbsp; Community Platform\n\nWelcome to our Community Platform!\nAt [One Army](https://www.onearmy.earth) we are building this platform to help unite people and contribute to social & environmental projects, such as [Precious Plastic](https://preciousplastic.com), [Project Kamp](https://projectkamp.com/) and [Fixing Fashion](https://fixing.fashion). A platform to connect, educate and empower our global community (65K) to solve society's greatest challenges. Together.\n\n## 👀 &nbsp; Why?\n\nFor the past 5+ years we’ve worked together with thousands of people from all over the world on open hardware projects to tackle some of the most pressing environmental issues, building machines and tools to fix the mess. The more we worked on these projects, the more we realised that there are two main hurdles to the success of a project:\n\n- A project's success is closely linked to its community, and for a new project starting up, finding and creating their own strong community is often a time-consuming activity that can take lots of resources.\n- While working on a project we often find ourselves having to use a multitude of digital tools that are often incomplete, disconnected, privately owned and not open source.\n\nThis platform aims to tackle these problems by creating a strong unified community for the different projects under its umbrella and offering the necessary tools to collaborate and connect in one single place. Free and open-source.\n\n## ⚡️ &nbsp; What is this platform?\n\nOur platform helps communities to grow and makes it easier to collaborate on environmental projects in one single place. A place where people can meet, help each other, ask and answer questions, share their innovative ways of fixing problems, discover people around them, connect locally and more. It aims to provide the tools to connect both online and offline. Amongst other features we have a library of projects, research, questions, news updates and a map.\n\n### Have a look on our [website](https://www.onearmy.earth/community-platform) to have a clear overview\n\n## 👐 Open Source\n\nSociety and the environment are kind of screwed 💩 in many ways. We think free knowledge and open source are the fastest and most efficient ways to bring about innovation to tackle some of the most pressing humanity’s fuck ups. Simple.\n\n## 🤝 &nbsp; Contributions\n\nContributions, issues and feature requests are very welcome.\nPlease make sure to read the [Contributing Guide](/CONTRIBUTING.md) before making a pull request.\n\nIt also covers lots of handy additional information such as setting up a local server, or finding [good first issues](https://github.com/ONEARMY/community-platform/issues?q=is%3Aissue+is%3Aopen+label%3A%22Good+first+issue%22) to work on.\n\nTo startup the project locally use `bun start`, but before that, follow [Getting Started](/packages/documentation/docs/supabase.md)\n\nIf needed you can [drop us a line here](mailto:platform@onearmy.earth?subject=contact%20from%20github) 👋\nOr join our [Discord channel](https://discord.gg/gJ7Yyk4)\n\n## Contributors ✨\n\nThanks go to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):\n\n<!--- spell-checker: disable --->\n<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->\n<!-- prettier-ignore-start -->\n<!-- markdownlint-disable -->\n<table>\n  <tbody>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://davehakkens.nl\"><img src=\"https://avatars.githubusercontent.com/u/13672737?v=4?s=60\" width=\"60px;\" alt=\"Dave Hakkens\"/><br /><sub><b>Dave Hakkens</b></sub></a><br /><a href=\"#design-davehakkens\" title=\"Design\">🎨</a> <a href=\"#ideas-davehakkens\" title=\"Ideas, Planning, & Feedback\">🤔</a> <a href=\"#projectManagement-davehakkens\" title=\"Project Management\">📆</a> <a href=\"[💪](\"Maintainer\"),\" title=\"Maintainer\">💪</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://c2dev.co.uk/\"><img src=\"https://avatars.githubusercontent.com/u/10515065?v=4?s=60\" width=\"60px;\" alt=\"Chris Clarke\"/><br /><sub><b>Chris Clarke</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=chrismclarke\" title=\"Code\">💻</a> <a href=\"[💪](\"Maintainer\"),\" title=\"Maintainer\">💪</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://thisis.la/\"><img src=\"https://avatars.githubusercontent.com/u/472589?v=4?s=60\" width=\"60px;\" alt=\"Luke Watts\"/><br /><sub><b>Luke Watts</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=thisislawatts\" title=\"Code\">💻</a> <a href=\"[💪](\"Maintainer\"),\" title=\"Maintainer\">💪</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/amuroBosetti\"><img src=\"https://avatars.githubusercontent.com/u/46928545?v=4?s=60\" width=\"60px;\" alt=\"Mauro Bosetti\"/><br /><sub><b>Mauro Bosetti</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=amuroBosetti\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/patrycjapraczyk\"><img src=\"https://avatars.githubusercontent.com/u/35103888?v=4?s=60\" width=\"60px;\" alt=\"patrycjapraczyk\"/><br /><sub><b>patrycjapraczyk</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=patrycjapraczyk\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://tedspare.com\"><img src=\"https://avatars.githubusercontent.com/u/36117635?v=4?s=60\" width=\"60px;\" alt=\"Ted Spare\"/><br /><sub><b>Ted Spare</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=tedspare\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.linkedin.com/in/eliasvelardez\"><img src=\"https://avatars.githubusercontent.com/u/40184787?v=4?s=60\" width=\"60px;\" alt=\"Elias Velardez\"/><br /><sub><b>Elias Velardez</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=eliasvelardezft\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/AlfonsoGhislieri\"><img src=\"https://avatars.githubusercontent.com/u/652368?v=4?s=60\" width=\"60px;\" alt=\"Alfonso\"/><br /><sub><b>Alfonso</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=AlfonsoGhislieri\" title=\"Code\">💻</a> <a href=\"[💪](\"Maintainer\"),\" title=\"Maintainer\">💪</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Xyli0\"><img src=\"https://avatars.githubusercontent.com/u/10441748?v=4?s=60\" width=\"60px;\" alt=\"Xyli0\"/><br /><sub><b>Xyli0</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Xyli0\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.linkedin.com/in/laianbraum\"><img src=\"https://avatars.githubusercontent.com/u/61033391?v=4?s=60\" width=\"60px;\" alt=\"Laian Braum\"/><br /><sub><b>Laian Braum</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=laianbraum\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/osouthwell-scottlogic\"><img src=\"https://avatars.githubusercontent.com/u/98388720?v=4?s=60\" width=\"60px;\" alt=\"osouthwell-scottlogic\"/><br /><sub><b>osouthwell-scottlogic</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=osouthwell-scottlogic\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://asheerrizvi.com\"><img src=\"https://avatars.githubusercontent.com/u/17976252?v=4?s=60\" width=\"60px;\" alt=\"Asheer Rizvi\"/><br /><sub><b>Asheer Rizvi</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=asheerrizvi\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://franknoirot.co\"><img src=\"https://avatars.githubusercontent.com/u/23481541?v=4?s=60\" width=\"60px;\" alt=\"Frank Noirot\"/><br /><sub><b>Frank Noirot</b></sub></a><br /><a href=\"#design-franknoirot\" title=\"Design\">🎨</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/LucasGabrielBecker\"><img src=\"https://avatars.githubusercontent.com/u/48301172?v=4?s=60\" width=\"60px;\" alt=\"Lucas Becker \"/><br /><sub><b>Lucas Becker </b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=LucasGabrielBecker\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/cschilbe\"><img src=\"https://avatars.githubusercontent.com/u/485557?v=4?s=60\" width=\"60px;\" alt=\"Conrad Schilbe\"/><br /><sub><b>Conrad Schilbe</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=cschilbe\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/ThakurKarthik\"><img src=\"https://avatars.githubusercontent.com/u/26309938?v=4?s=60\" width=\"60px;\" alt=\"Thakur Karthik\"/><br /><sub><b>Thakur Karthik</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=ThakurKarthik\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.linkedin.com/in/danitrod/\"><img src=\"https://avatars.githubusercontent.com/u/45438149?v=4?s=60\" width=\"60px;\" alt=\"Daniel T. Rodrigues\"/><br /><sub><b>Daniel T. Rodrigues</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=danitrod\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/adrianduke\"><img src=\"https://avatars.githubusercontent.com/u/711058?v=4?s=60\" width=\"60px;\" alt=\"Adrian Duke\"/><br /><sub><b>Adrian Duke</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=adrianduke\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/missalyss\"><img src=\"https://avatars.githubusercontent.com/u/19866110?v=4?s=60\" width=\"60px;\" alt=\"Alyssa Helgason\"/><br /><sub><b>Alyssa Helgason</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=missalyss\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Kiebert\"><img src=\"https://avatars.githubusercontent.com/u/3414938?v=4?s=60\" width=\"60px;\" alt=\"Kieb\"/><br /><sub><b>Kieb</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Kiebert\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Sc4ramouche\"><img src=\"https://avatars.githubusercontent.com/u/25829136?v=4?s=60\" width=\"60px;\" alt=\"Kovechenkov Vladislav\"/><br /><sub><b>Kovechenkov Vladislav</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Sc4ramouche\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://devtato.com\"><img src=\"https://avatars.githubusercontent.com/u/4504330?v=4?s=60\" width=\"60px;\" alt=\"Devtato\"/><br /><sub><b>Devtato</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=cerkiewny\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/NoHara42\"><img src=\"https://avatars.githubusercontent.com/u/43496778?v=4?s=60\" width=\"60px;\" alt=\"Ned O'Hara\"/><br /><sub><b>Ned O'Hara</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=NoHara42\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/SophXN\"><img src=\"https://avatars.githubusercontent.com/u/80185757?v=4?s=60\" width=\"60px;\" alt=\"Sophia Nguyen\"/><br /><sub><b>Sophia Nguyen</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=SophXN\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.evakillenberg.com\"><img src=\"https://avatars.githubusercontent.com/u/37253846?v=4?s=60\" width=\"60px;\" alt=\"Eva Killenberg\"/><br /><sub><b>Eva Killenberg</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=evakill\" title=\"Code\">💻</a> <a href=\"[💪](\"Maintainer\"),\" title=\"Maintainer\">💪</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://speckledbanana.com\"><img src=\"https://avatars.githubusercontent.com/u/80723794?v=4?s=60\" width=\"60px;\" alt=\"Sean Thompson\"/><br /><sub><b>Sean Thompson</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=iSCJT\" title=\"Code\">💻</a> <a href=\"[💪](\"Maintainer\"),\" title=\"Maintainer\">💪</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/NguyenVanDo51\"><img src=\"https://avatars.githubusercontent.com/u/30190734?v=4?s=60\" width=\"60px;\" alt=\"Nguyễn Văn Đỏ\"/><br /><sub><b>Nguyễn Văn Đỏ</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=NguyenVanDo51\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://kungraseri.dev\"><img src=\"https://avatars.githubusercontent.com/u/1054240?v=4?s=60\" width=\"60px;\" alt=\"KungRaseri\"/><br /><sub><b>KungRaseri</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=KungRaseri\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/BaltacMihai\"><img src=\"https://avatars.githubusercontent.com/u/72079422?v=4?s=60\" width=\"60px;\" alt=\"Mihai-Cristian Bâltac\"/><br /><sub><b>Mihai-Cristian Bâltac</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=BaltacMihai\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/CDeighton\"><img src=\"https://avatars.githubusercontent.com/u/13475443?v=4?s=60\" width=\"60px;\" alt=\"Cullum Deighton\"/><br /><sub><b>Cullum Deighton</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=CDeighton\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/d-skowronski\"><img src=\"https://avatars.githubusercontent.com/u/98740166?v=4?s=60\" width=\"60px;\" alt=\"Dawid Skowroński\"/><br /><sub><b>Dawid Skowroński</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=d-skowronski\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://jonboiser.com\"><img src=\"https://avatars.githubusercontent.com/u/10248067?v=4?s=60\" width=\"60px;\" alt=\"Jonathan Boiser\"/><br /><sub><b>Jonathan Boiser</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=jonboiser\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/benfurber\"><img src=\"https://avatars.githubusercontent.com/u/16688508?v=4?s=60\" width=\"60px;\" alt=\"benfurber\"/><br /><sub><b>benfurber</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=benfurber\" title=\"Code\">💻</a> <a href=\"[💪](\"Maintainer\"),\" title=\"Maintainer\">💪</a> <a href=\"https://github.com/ONEARMY/community-platform/commits?author=benfurber\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/AlimurtuzaCodes\"><img src=\"https://avatars.githubusercontent.com/u/88965204?v=4?s=60\" width=\"60px;\" alt=\"Alimurtuza\"/><br /><sub><b>Alimurtuza</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=AlimurtuzaCodes\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/AliAbuSalam\"><img src=\"https://avatars.githubusercontent.com/u/17426615?v=4?s=60\" width=\"60px;\" alt=\"Askell\"/><br /><sub><b>Askell</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=AliAbuSalam\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://linkedin.com/in/manacesneto\"><img src=\"https://avatars.githubusercontent.com/u/8915867?v=4?s=60\" width=\"60px;\" alt=\"Manacés Pereira\"/><br /><sub><b>Manacés Pereira</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=manacespereira\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/5niperspider\"><img src=\"https://avatars.githubusercontent.com/u/62932392?v=4?s=60\" width=\"60px;\" alt=\"Georg Karl\"/><br /><sub><b>Georg Karl</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=5niperspider\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.linkedin.com/in/fletcher-larue/\"><img src=\"https://avatars.githubusercontent.com/u/42685363?v=4?s=60\" width=\"60px;\" alt=\"asdFletcher\"/><br /><sub><b>asdFletcher</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=asdFletcher\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/bagiyal\"><img src=\"https://avatars.githubusercontent.com/u/63339447?v=4?s=60\" width=\"60px;\" alt=\"Abhishek Bagiyal\"/><br /><sub><b>Abhishek Bagiyal</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=bagiyal\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/CrowsVeldt\"><img src=\"https://avatars.githubusercontent.com/u/8883408?v=4?s=60\" width=\"60px;\" alt=\"Zack\"/><br /><sub><b>Zack</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=CrowsVeldt\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://careers.bol.com/\"><img src=\"https://avatars.githubusercontent.com/u/1822855?v=4?s=60\" width=\"60px;\" alt=\"Bart Enkelaar\"/><br /><sub><b>Bart Enkelaar</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=benkelaar\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/laviodias\"><img src=\"https://avatars.githubusercontent.com/u/44332001?v=4?s=60\" width=\"60px;\" alt=\"Lávio Vale\"/><br /><sub><b>Lávio Vale</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=laviodias\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/manuelrurda\"><img src=\"https://avatars.githubusercontent.com/u/62727899?v=4?s=60\" width=\"60px;\" alt=\"Manuel Rodriguez Urdapilelta\"/><br /><sub><b>Manuel Rodriguez Urdapilelta</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=manuelrurda\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/LiptonB\"><img src=\"https://avatars.githubusercontent.com/u/467965?v=4?s=60\" width=\"60px;\" alt=\"Ben Lipton\"/><br /><sub><b>Ben Lipton</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=LiptonB\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/ayachish\"><img src=\"https://avatars.githubusercontent.com/u/102033230?v=4?s=60\" width=\"60px;\" alt=\"Ayachi Sharma\"/><br /><sub><b>Ayachi Sharma</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=ayachish\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://tyukayev.com\"><img src=\"https://avatars.githubusercontent.com/u/9029936?v=4?s=60\" width=\"60px;\" alt=\"Arthur Tyukayev\"/><br /><sub><b>Arthur Tyukayev</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=arthurtyukayev\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/jgable\"><img src=\"https://avatars.githubusercontent.com/u/164497?v=4?s=60\" width=\"60px;\" alt=\"Jacob Gable\"/><br /><sub><b>Jacob Gable</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=jgable\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://beemargarida.github.io\"><img src=\"https://avatars.githubusercontent.com/u/25725586?v=4?s=60\" width=\"60px;\" alt=\"Ana Margarida Silva\"/><br /><sub><b>Ana Margarida Silva</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=BeeMargarida\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/cjh1212\"><img src=\"https://avatars.githubusercontent.com/u/45911291?v=4?s=60\" width=\"60px;\" alt=\"cjh1212\"/><br /><sub><b>cjh1212</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=cjh1212\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://pizzaisdavid.medium.com/\"><img src=\"https://avatars.githubusercontent.com/u/4391884?v=4?s=60\" width=\"60px;\" alt=\"David Germain\"/><br /><sub><b>David Germain</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=pizzaisdavid\" title=\"Documentation\">📖</a> <a href=\"https://github.com/ONEARMY/community-platform/commits?author=pizzaisdavid\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://ajotka.com\"><img src=\"https://avatars.githubusercontent.com/u/15144546?v=4?s=60\" width=\"60px;\" alt=\"AJOTKA\"/><br /><sub><b>AJOTKA</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=ajotka\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://rayliu.me\"><img src=\"https://avatars.githubusercontent.com/u/17478640?v=4?s=60\" width=\"60px;\" alt=\"Ray Liu\"/><br /><sub><b>Ray Liu</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=CheRayLiu\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://erkanerkisi.github.io\"><img src=\"https://avatars.githubusercontent.com/u/22741824?v=4?s=60\" width=\"60px;\" alt=\"Erkan Erkişi\"/><br /><sub><b>Erkan Erkişi</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Erkanerkisi\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/denyilm\"><img src=\"https://avatars.githubusercontent.com/u/65300462?v=4?s=60\" width=\"60px;\" alt=\"denyilm\"/><br /><sub><b>denyilm</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=denyilm\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/zweertsk\"><img src=\"https://avatars.githubusercontent.com/u/131855633?v=4?s=60\" width=\"60px;\" alt=\"Koen\"/><br /><sub><b>Koen</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=zweertsk\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/goratt12\"><img src=\"https://avatars.githubusercontent.com/u/23094928?v=4?s=60\" width=\"60px;\" alt=\"Guy Ribak\"/><br /><sub><b>Guy Ribak</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=goratt12\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/onim-at\"><img src=\"https://avatars.githubusercontent.com/u/45094836?v=4?s=60\" width=\"60px;\" alt=\"Cosimo Chetta\"/><br /><sub><b>Cosimo Chetta</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=onim-at\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://uk.linkedin.com/in/rogerchick\"><img src=\"https://avatars.githubusercontent.com/u/555883?v=4?s=60\" width=\"60px;\" alt=\"Roger Chick\"/><br /><sub><b>Roger Chick</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=rchick\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://ignasplace.com\"><img src=\"https://avatars.githubusercontent.com/u/76262712?v=4?s=60\" width=\"60px;\" alt=\"Ignas\"/><br /><sub><b>Ignas</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=IgnasPlace\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/mariojsnunes\"><img src=\"https://avatars.githubusercontent.com/u/8073622?v=4?s=60\" width=\"60px;\" alt=\"Mário Nunes\"/><br /><sub><b>Mário Nunes</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=mariojsnunes\" title=\"Code\">💻</a> <a href=\"[💪](\"Maintainer\"),\" title=\"Maintainer\">💪</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/oktomus\"><img src=\"https://avatars.githubusercontent.com/u/4656466?v=4?s=60\" width=\"60px;\" alt=\"Kevin Masson\"/><br /><sub><b>Kevin Masson</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=oktomus\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.darigovresearch.com/\"><img src=\"https://avatars.githubusercontent.com/u/30328618?v=4?s=60\" width=\"60px;\" alt=\"Darigov Research\"/><br /><sub><b>Darigov Research</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=darigovresearch\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://cs.mcgill.ca/~zdouce\"><img src=\"https://avatars.githubusercontent.com/u/21955868?v=4?s=60\" width=\"60px;\" alt=\"Zachary Doucet\"/><br /><sub><b>Zachary Doucet</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Shamauk\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/viracoding\"><img src=\"https://avatars.githubusercontent.com/u/20618068?v=4?s=60\" width=\"60px;\" alt=\"viracoding\"/><br /><sub><b>viracoding</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=viracoding\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Gashmoh\"><img src=\"https://avatars.githubusercontent.com/u/24207256?v=4?s=60\" width=\"60px;\" alt=\"Gashmoh\"/><br /><sub><b>Gashmoh</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Gashmoh\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/dariusmihut\"><img src=\"https://avatars.githubusercontent.com/u/7417010?v=4?s=60\" width=\"60px;\" alt=\"dariusmihut\"/><br /><sub><b>dariusmihut</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=dariusmihut\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/dcustodio\"><img src=\"https://avatars.githubusercontent.com/u/2907004?v=4?s=60\" width=\"60px;\" alt=\"David Custódio\"/><br /><sub><b>David Custódio</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=dcustodio\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.eliasjorgensen.se\"><img src=\"https://avatars.githubusercontent.com/u/63782477?v=4?s=60\" width=\"60px;\" alt=\"Elias Jörgensen\"/><br /><sub><b>Elias Jörgensen</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=saile515\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.jagan-chary.com\"><img src=\"https://avatars.githubusercontent.com/u/26999371?v=4?s=60\" width=\"60px;\" alt=\"devChary\"/><br /><sub><b>devChary</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=devChary\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/paposeco\"><img src=\"https://avatars.githubusercontent.com/u/13892562?v=4?s=60\" width=\"60px;\" alt=\"Fabi\"/><br /><sub><b>Fabi</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=paposeco\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Robert-LC\"><img src=\"https://avatars.githubusercontent.com/u/72999492?v=4?s=60\" width=\"60px;\" alt=\"Robert\"/><br /><sub><b>Robert</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Robert-LC\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/phlapjack\"><img src=\"https://avatars.githubusercontent.com/u/1590042?v=4?s=60\" width=\"60px;\" alt=\"Phillip Atkinson\"/><br /><sub><b>Phillip Atkinson</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=phlapjack\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://agw.lv/\"><img src=\"https://avatars.githubusercontent.com/u/6299387?v=4?s=60\" width=\"60px;\" alt=\"Andris\"/><br /><sub><b>Andris</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=exabyssus\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/EdwardAndress\"><img src=\"https://avatars.githubusercontent.com/u/7963978?v=4?s=60\" width=\"60px;\" alt=\"Edward Andress\"/><br /><sub><b>Edward Andress</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=EdwardAndress\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/tuliobluz\"><img src=\"https://avatars.githubusercontent.com/u/21323883?v=4?s=60\" width=\"60px;\" alt=\"Túlio Luz\"/><br /><sub><b>Túlio Luz</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=tuliobluz\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/codisart\"><img src=\"https://avatars.githubusercontent.com/u/1767237?v=4?s=60\" width=\"60px;\" alt=\"Louis\"/><br /><sub><b>Louis</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=codisart\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://venugsportfolio.netlify.app/\"><img src=\"https://avatars.githubusercontent.com/u/52736045?v=4?s=60\" width=\"60px;\" alt=\"Venu G Soganadgi\"/><br /><sub><b>Venu G Soganadgi</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=V24039\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Augustindou\"><img src=\"https://avatars.githubusercontent.com/u/44368825?v=4?s=60\" width=\"60px;\" alt=\"Augustindou\"/><br /><sub><b>Augustindou</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Augustindou\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://prashikgamre.vercel.app/\"><img src=\"https://avatars.githubusercontent.com/u/88095936?v=4?s=60\" width=\"60px;\" alt=\"Prashik Gamre\"/><br /><sub><b>Prashik Gamre</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=prashik0202\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/simontree\"><img src=\"https://avatars.githubusercontent.com/u/59532700?v=4?s=60\" width=\"60px;\" alt=\"Robert\"/><br /><sub><b>Robert</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=simontree\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/kimskovhusandersen\"><img src=\"https://avatars.githubusercontent.com/u/5513342?v=4?s=60\" width=\"60px;\" alt=\"Kim Skovhus Andersen\"/><br /><sub><b>Kim Skovhus Andersen</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=kimskovhusandersen\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/leoasimon\"><img src=\"https://avatars.githubusercontent.com/u/89898967?v=4?s=60\" width=\"60px;\" alt=\"leoasimon\"/><br /><sub><b>leoasimon</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=leoasimon\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://lucaasrojas-portfolio.vercel.app/\"><img src=\"https://avatars.githubusercontent.com/u/26610409?v=4?s=60\" width=\"60px;\" alt=\"Lucas Rojas\"/><br /><sub><b>Lucas Rojas</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=lucaasrojas\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://luismgsantos.github.io\"><img src=\"https://avatars.githubusercontent.com/u/13033016?v=4?s=60\" width=\"60px;\" alt=\"Luís Santos\"/><br /><sub><b>Luís Santos</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=luismgsantos\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/koeppel\"><img src=\"https://avatars.githubusercontent.com/u/12177323?v=4?s=60\" width=\"60px;\" alt=\"Janik Köppel\"/><br /><sub><b>Janik Köppel</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=koeppel\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Shalankwa\"><img src=\"https://avatars.githubusercontent.com/u/31330598?v=4?s=60\" width=\"60px;\" alt=\"Jonathan Goodman\"/><br /><sub><b>Jonathan Goodman</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Shalankwa\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/LahuenGR\"><img src=\"https://avatars.githubusercontent.com/u/101137877?v=4?s=60\" width=\"60px;\" alt=\"LahuenGR\"/><br /><sub><b>LahuenGR</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=LahuenGR\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/JoseAConcepcion\"><img src=\"https://avatars.githubusercontent.com/u/99701565?v=4?s=60\" width=\"60px;\" alt=\"José Antonio Concepción\"/><br /><sub><b>José Antonio Concepción</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=JoseAConcepcion\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/jaykayudo\"><img src=\"https://avatars.githubusercontent.com/u/58009744?v=4?s=60\" width=\"60px;\" alt=\"Joshua Kelechi\"/><br /><sub><b>Joshua Kelechi</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=jaykayudo\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/mchen10\"><img src=\"https://avatars.githubusercontent.com/u/16161485?v=4?s=60\" width=\"60px;\" alt=\"Michael Chen\"/><br /><sub><b>Michael Chen</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=mchen10\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/motuz0001\"><img src=\"https://avatars.githubusercontent.com/u/61076969?v=4?s=60\" width=\"60px;\" alt=\"Matúš Motyka\"/><br /><sub><b>Matúš Motyka</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=motuz0001\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/cypherpepe\"><img src=\"https://avatars.githubusercontent.com/u/125112044?v=4?s=60\" width=\"60px;\" alt=\"Cypher Pepe\"/><br /><sub><b>Cypher Pepe</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=cypherpepe\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.behance.net/dalibormrska\"><img src=\"https://avatars.githubusercontent.com/u/35503298?v=4?s=60\" width=\"60px;\" alt=\"Dalibor Mrška\"/><br /><sub><b>Dalibor Mrška</b></sub></a><br /><a href=\"#design-dalibormrska\" title=\"Design\">🎨</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/sky-coderay\"><img src=\"https://avatars.githubusercontent.com/u/137945430?v=4?s=60\" width=\"60px;\" alt=\"Skylar Ray\"/><br /><sub><b>Skylar Ray</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=sky-coderay\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://johannesross.de\"><img src=\"https://avatars.githubusercontent.com/u/74828657?v=4?s=60\" width=\"60px;\" alt=\"Johannes Roß\"/><br /><sub><b>Johannes Roß</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=johannes-ross\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/detrina\"><img src=\"https://avatars.githubusercontent.com/u/155117116?v=4?s=60\" width=\"60px;\" alt=\"Devkuni\"/><br /><sub><b>Devkuni</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=detrina\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Bilogweb3\"><img src=\"https://avatars.githubusercontent.com/u/155262265?v=4?s=60\" width=\"60px;\" alt=\"Bilog WEB3\"/><br /><sub><b>Bilog WEB3</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Bilogweb3\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/ebradbury\"><img src=\"https://avatars.githubusercontent.com/u/253679?v=4?s=60\" width=\"60px;\" alt=\"Elliot Bradbury\"/><br /><sub><b>Elliot Bradbury</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=ebradbury\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.linkedin.com/in/joseh29\"><img src=\"https://avatars.githubusercontent.com/u/70706814?v=4?s=60\" width=\"60px;\" alt=\"José\"/><br /><sub><b>José</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=joseh29\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/maximevtush\"><img src=\"https://avatars.githubusercontent.com/u/154841002?v=4?s=60\" width=\"60px;\" alt=\"Maxim Evtush\"/><br /><sub><b>Maxim Evtush</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=maximevtush\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/kilavvy\"><img src=\"https://avatars.githubusercontent.com/u/140459108?v=4?s=60\" width=\"60px;\" alt=\"kilavvy\"/><br /><sub><b>kilavvy</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=kilavvy\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/mohitsharma23\"><img src=\"https://avatars.githubusercontent.com/u/32203733?v=4?s=60\" width=\"60px;\" alt=\"Mohit Sharma\"/><br /><sub><b>Mohit Sharma</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=mohitsharma23\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/paulaFenner\"><img src=\"https://avatars.githubusercontent.com/u/18422622?v=4?s=60\" width=\"60px;\" alt=\"Paula Fenner\"/><br /><sub><b>Paula Fenner</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=paulaFenner\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://ismaelabujadur.github.io\"><img src=\"https://avatars.githubusercontent.com/u/112013216?v=4?s=60\" width=\"60px;\" alt=\"Ismael Abu-jadur Garcia\"/><br /><sub><b>Ismael Abu-jadur Garcia</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=ismaelabujadur\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://nickthewilder.com\"><img src=\"https://avatars.githubusercontent.com/u/38483425?v=4?s=60\" width=\"60px;\" alt=\"Nick Wilder\"/><br /><sub><b>Nick Wilder</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=NickTheWilder\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/dizer-ti\"><img src=\"https://avatars.githubusercontent.com/u/155266991?v=4?s=60\" width=\"60px;\" alt=\"James Niken\"/><br /><sub><b>James Niken</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=dizer-ti\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/omahs\"><img src=\"https://avatars.githubusercontent.com/u/73983677?v=4?s=60\" width=\"60px;\" alt=\"omahs\"/><br /><sub><b>omahs</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=omahs\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/GTaf\"><img src=\"https://avatars.githubusercontent.com/u/3484782?v=4?s=60\" width=\"60px;\" alt=\"GTaf\"/><br /><sub><b>GTaf</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=GTaf\" title=\"Code\">💻</a> <a href=\"https://github.com/ONEARMY/community-platform/commits?author=GTaf\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/cajohn2757\"><img src=\"https://avatars.githubusercontent.com/u/71300075?v=4?s=60\" width=\"60px;\" alt=\"Corey Johnson\"/><br /><sub><b>Corey Johnson</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=cajohn2757\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/devianweb\"><img src=\"https://avatars.githubusercontent.com/u/87659522?v=4?s=60\" width=\"60px;\" alt=\"Ian Webster\"/><br /><sub><b>Ian Webster</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=devianweb\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/JCSergent\"><img src=\"https://avatars.githubusercontent.com/u/34434102?v=4?s=60\" width=\"60px;\" alt=\"JC Sergent\"/><br /><sub><b>JC Sergent</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=JCSergent\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/fel4-dev\"><img src=\"https://avatars.githubusercontent.com/u/94372355?v=4?s=60\" width=\"60px;\" alt=\"Fel4\"/><br /><sub><b>Fel4</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=fel4-dev\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://matteo.codes\"><img src=\"https://avatars.githubusercontent.com/u/62759388?v=4?s=60\" width=\"60px;\" alt=\"Matteo Bucciol\"/><br /><sub><b>Matteo Bucciol</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=matteobu\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/ernestorbemx\"><img src=\"https://avatars.githubusercontent.com/u/204041962?v=4?s=60\" width=\"60px;\" alt=\"ernestorbemx\"/><br /><sub><b>ernestorbemx</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=ernestorbemx\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/jproberson\"><img src=\"https://avatars.githubusercontent.com/u/50461518?v=4?s=60\" width=\"60px;\" alt=\"jproberson\"/><br /><sub><b>jproberson</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=jproberson\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://abhishekdotsol.vercel.app/\"><img src=\"https://avatars.githubusercontent.com/u/177325053?v=4?s=60\" width=\"60px;\" alt=\"Abhishek Singh\"/><br /><sub><b>Abhishek Singh</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=Abhishek-singh88\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/chris-staunton\"><img src=\"https://avatars.githubusercontent.com/u/60261694?v=4?s=60\" width=\"60px;\" alt=\"Chris Staunton\"/><br /><sub><b>Chris Staunton</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=chris-staunton\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/rejected-l\"><img src=\"https://avatars.githubusercontent.com/u/99460023?v=4?s=60\" width=\"60px;\" alt=\"Rej Ect\"/><br /><sub><b>Rej Ect</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=rejected-l\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/othman-shamla\"><img src=\"https://avatars.githubusercontent.com/u/16326221?v=4?s=60\" width=\"60px;\" alt=\"othman-shamla\"/><br /><sub><b>othman-shamla</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=othman-shamla\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/rsgb\"><img src=\"https://avatars.githubusercontent.com/u/96707736?v=4?s=60\" width=\"60px;\" alt=\"RSGB\"/><br /><sub><b>RSGB</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=rsgb\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/GMetaxakis\"><img src=\"https://avatars.githubusercontent.com/u/4234419?v=4?s=60\" width=\"60px;\" alt=\"Georgios Metaxakis\"/><br /><sub><b>Georgios Metaxakis</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=GMetaxakis\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://trev.in\"><img src=\"https://avatars.githubusercontent.com/u/517103?v=4?s=60\" width=\"60px;\" alt=\"Trevin Chow\"/><br /><sub><b>Trevin Chow</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=tmchow\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://botho.cc\"><img src=\"https://avatars.githubusercontent.com/u/1258870?v=4?s=60\" width=\"60px;\" alt=\"Botho\"/><br /><sub><b>Botho</b></sub></a><br /><a href=\"https://github.com/ONEARMY/community-platform/commits?author=elbotho\" title=\"Code\">💻</a></td>\n    </tr>\n  </tbody>\n</table>\n\n<!-- markdownlint-restore -->\n<!-- prettier-ignore-end -->\n\n<!-- ALL-CONTRIBUTORS-LIST:END -->\n<!--- spell-checker: enable --->\n\nThis project adopted the [all-contributors](https://allcontributors.org) specification in June 2022.\nContributions of any kind are welcome!\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n# Reporting Security Issues\n\nIf you believe you have found a security vulnerability on our platform, we encourage you to let us know right away. We will investigate all legitimate reports and do our best to quickly fix the problem.\n\nSubmit your report to platform@onearmy.earth (one issue per report) and respond to the report with any updates.\n\n### Reporting a Vulnerability\n\nWe ask that:\n\n- You give us reasonable time to investigate and mitigate an issue you report before making public any information about the report or sharing such information with others.\n- You do not interact with an individual account (which includes modifying or accessing data from the account) if the account owner has not consented to such actions.\n- You make a good faith effort to avoid privacy violations and disruptions to others, including (but not limited to) destruction of data and interruption or degradation of our services.\n- You do not exploit a security issue you discover for any reason. (This includes demonstrating additional risk, such as attempted compromise of sensitive company data or probing for additional issues).\n- You do not violate any other applicable laws or regulations.\n"
  },
  {
    "path": "biome.json",
    "content": "{\n  \"$schema\": \"https://biomejs.dev/schemas/2.4.4/schema.json\",\n  \"vcs\": { \"enabled\": true, \"clientKind\": \"git\", \"useIgnoreFile\": true },\n  \"files\": {\n    \"ignoreUnknown\": false,\n    \"includes\": [\n      \"**\",\n      \"!**/build\",\n      \"!**/storybook-static\",\n      \"!**/dist\",\n      \"!**/shared/lib\",\n      \"!**/cypress\",\n      \"!**/.react-router\",\n      \"!**/*.md\",\n      \"!**/*.spec.*\",\n      \"!**/*.stories.*\",\n      \"!**/*.test.*\",\n      \"!**/styles/*\",\n      \"!**/supabase\"\n    ]\n  },\n  \"formatter\": {\n    \"enabled\": true,\n    \"formatWithErrors\": false,\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2,\n    \"lineEnding\": \"lf\",\n    \"lineWidth\": 100,\n    \"attributePosition\": \"auto\",\n    \"bracketSameLine\": false,\n    \"bracketSpacing\": true,\n    \"expand\": \"auto\",\n    \"useEditorconfig\": true,\n    \"includes\": [\n      \"**\",\n      \"!**/build\",\n      \"!**/storybook-static\",\n      \"!**/dist\",\n      \"!shared/lib\",\n      \"!**/.react-router\",\n      \"!**/*.md\"\n    ]\n  },\n  \"linter\": {\n    \"enabled\": true,\n    \"rules\": {\n      \"recommended\": false,\n      \"complexity\": {\n        \"noAdjacentSpacesInRegex\": \"error\",\n        \"noExtraBooleanCast\": \"error\",\n        \"noUselessCatch\": \"error\",\n        \"noUselessEscapeInRegex\": \"error\",\n        \"noUselessTypeConstraint\": \"error\"\n      },\n      \"correctness\": {\n        \"noChildrenProp\": \"error\",\n        \"noConstAssign\": \"error\",\n        \"noConstantCondition\": \"error\",\n        \"noEmptyCharacterClassInRegex\": \"error\",\n        \"noEmptyPattern\": \"error\",\n        \"noGlobalObjectCalls\": \"error\",\n        \"noInnerDeclarations\": \"error\",\n        \"noInvalidConstructorSuper\": \"error\",\n        \"noInvalidUseBeforeDeclaration\": \"off\",\n        \"noNonoctalDecimalEscape\": \"error\",\n        \"noPrecisionLoss\": \"error\",\n        \"noSelfAssign\": \"error\",\n        \"noSetterReturn\": \"error\",\n        \"noSwitchDeclarations\": \"error\",\n        \"noUndeclaredVariables\": \"error\",\n        \"noUnreachable\": \"error\",\n        \"noUnreachableSuper\": \"error\",\n        \"noUnsafeFinally\": \"error\",\n        \"noUnsafeOptionalChaining\": \"error\",\n        \"noUnusedImports\": \"warn\",\n        \"noUnusedLabels\": \"error\",\n        \"noUnusedVariables\": \"error\",\n        \"useIsNan\": \"error\",\n        \"useJsxKeyInIterable\": \"error\",\n        \"useValidForDirection\": \"error\",\n        \"useValidTypeof\": \"error\",\n        \"useYield\": \"error\"\n      },\n      \"security\": { \"noDangerouslySetInnerHtmlWithChildren\": \"error\" },\n      \"style\": {\n        \"noCommonJs\": \"off\",\n        \"noInferrableTypes\": \"off\",\n        \"noNamespace\": \"off\",\n        \"noNonNullAssertion\": \"off\",\n        \"noRestrictedImports\": \"error\",\n        \"noRestrictedTypes\": \"error\",\n        \"useArrayLiterals\": \"error\",\n        \"useAsConstAssertion\": \"error\",\n        \"useBlockStatements\": \"off\"\n      },\n      \"suspicious\": {\n        \"noAlert\": \"error\",\n        \"noAsyncPromiseExecutor\": \"error\",\n        \"noCatchAssign\": \"error\",\n        \"noClassAssign\": \"error\",\n        \"noCommentText\": \"error\",\n        \"noCompareNegZero\": \"error\",\n        \"noConsole\": \"off\",\n        \"noControlCharactersInRegex\": \"error\",\n        \"noDebugger\": \"error\",\n        \"noDuplicateCase\": \"error\",\n        \"noDuplicateClassMembers\": \"error\",\n        \"noDuplicateElseIf\": \"error\",\n        \"noDuplicateJsxProps\": \"error\",\n        \"noDuplicateObjectKeys\": \"error\",\n        \"noDuplicateParameters\": \"error\",\n        \"noEmptyBlockStatements\": \"off\",\n        \"noExplicitAny\": \"off\",\n        \"noExtraNonNullAssertion\": \"error\",\n        \"noFallthroughSwitchClause\": \"error\",\n        \"noFunctionAssign\": \"error\",\n        \"noGlobalAssign\": \"error\",\n        \"noImportAssign\": \"error\",\n        \"noIrregularWhitespace\": \"error\",\n        \"noMisleadingCharacterClass\": \"error\",\n        \"noMisleadingInstantiator\": \"error\",\n        \"noNonNullAssertedOptionalChain\": \"error\",\n        \"noPrototypeBuiltins\": \"error\",\n        \"noRedeclare\": \"error\",\n        \"noShadowRestrictedNames\": \"error\",\n        \"noSparseArray\": \"error\",\n        \"noUnsafeDeclarationMerging\": \"error\",\n        \"noUnsafeNegation\": \"error\",\n        \"noUselessRegexBackrefs\": \"error\",\n        \"noWith\": \"error\",\n        \"useGetterReturn\": \"error\",\n        \"useNamespaceKeyword\": \"error\"\n      }\n    },\n    \"includes\": [\n      \"**\",\n      \"!**/node_modules\",\n      \"!**/functions\",\n      \"!**/build\",\n      \"!**/lib\",\n      \"!**/storybook-static\",\n      \"!**/dist\",\n      \"!shared/lib\"\n    ]\n  },\n  \"javascript\": {\n    \"globals\": [\"Bun\"],\n    \"formatter\": {\n      \"jsxQuoteStyle\": \"double\",\n      \"quoteProperties\": \"asNeeded\",\n      \"trailingCommas\": \"all\",\n      \"semicolons\": \"always\",\n      \"arrowParentheses\": \"always\",\n      \"bracketSameLine\": false,\n      \"quoteStyle\": \"single\",\n      \"attributePosition\": \"auto\",\n      \"bracketSpacing\": true\n    }\n  },\n  \"html\": {\n    \"formatter\": {\n      \"indentScriptAndStyle\": false,\n      \"selfCloseVoidElements\": \"always\"\n    }\n  },\n  \"assist\": {\n    \"enabled\": true,\n    \"actions\": { \"source\": { \"organizeImports\": \"on\" } }\n  }\n}\n"
  },
  {
    "path": "codecov.yml",
    "content": "ignore:\n  - \"packages/documentation\"\ncoverage: \n  status: \n    project:\n      default:\n        target: auto\n        threshold: 1%\n    patch: off\ncomment:\n  require_changes: true"
  },
  {
    "path": "docs/circle-ci.md",
    "content": "# Deployment via CircleCI\n\nWe use CircleCI to handle automated build-test-deploy cycles when PRs and releases are created from the GitHub Repository\n\n## Environment Variables\n\nEnvironment variables should be set via [CircleCI Contexts](https://circleci.com/docs/2.0/contexts/)\n"
  },
  {
    "path": "docs/db-seeding.md",
    "content": "# Supabase seeding\n\nThis file describes how supabase seeding works.\n\n## Snaplet\n\nSnaplet is a tool used to seed mostly PostgresSQL databases. Read through their documentation to understand better how it works here: https://snaplet-seed.netlify.app/seed/getting-started/quick-start\n\nTo initialize snaplet:\n\n```sh\nnpx @snaplet/seed init\n```\n\nTo sync snaplet (essentially generate snaplet models and docs from `seed.config.ts`):\n\n> Note: every time the config file is changed, this command has to be ran.\n\n```sh\nbun run db:reset\n```\n\nOr if you need to run the commands separately:\n\n```sh\nnpx supabase db reset\nnpx @snaplet/seed sync\nnpx tsx seed.ts > seed.sql\nbun run format\n```\n"
  },
  {
    "path": "docs/maintainers.md",
    "content": "### ⚡️ Things that Maintainers do:\n\n#### Review incoming code from Contributors\n\n1. Validate code quality and give feedback as needed.\n2. Help resolve problems.\n3. Ask to add tests as needed.\n4. Add new contributors to our [contributors list](#recognising-contributors).\n\n### 🔧 Things that Core Maintainers do:\n\n#### Release changes\n\n- The PR is merged into the `master` branch.\n- `master` branch triggers an automated CircleCI build.\n- Production deployment requires approval by a maintainer on CircleCI.\n- After approval, an automated build deploys to production sites.\n\n#### Recognising Contributors\n\nWe have adopted [all contributors](https://allcontributors.org/) and their tooling for managing the contributors listing on the [project README.md](https://github.com/ONEARMY/community-platform/blob/master/README.md).\n\nAfter merging a new contributors PR:\n\n1. Add a comment to the merged PR mentioning the bot, contributor and their contribution [type](https://allcontributors.org/docs/en/emoji-key), for example: `@all-contributors add @username for code`.\n2. A PR will be automatically raised, [example](https://github.com/ONEARMY/community-platform/pull/1952).\n3. The PR raised by the `All Contributors` bot will need to be merged with admin privileges as the required CI skips are deliberately skipped.\n\n### Payment\n\nThere is a hourly pay for Maintainers. Aimed at developers who help more consistently. If you're interested to become a maintainer feel free to reach out on [Discord](https://discord.gg/gJ7Yyk4).\n"
  },
  {
    "path": "docs/pwa-setup.md",
    "content": "# PWA Setup Documentation\n\n## Overview\n\nThis project has Progressive Web App (PWA) capabilities configured for React Router 7 with **SSR mode**.\n\n**Important:** React Router 7 SSR doesn't generate `index.html` (server renders HTML dynamically), so the PWA uses a `NetworkFirst` caching strategy for navigation instead of precaching static HTML.\n\n## Configuration\n\n1. **vite.config.ts** - PWA plugin configuration\n2. **src/root.tsx** - Manifest links added\n3. **src/entry.client.tsx** - Service worker registration\n\n## React Router 7 SSR Considerations\n\nWith SSR mode (`ssr: true` in react-router.config.ts), there is **no static `index.html` file** generated. The PWA configuration uses:\n\n- `navigateFallback: null` - Disables static navigation fallback\n- `NetworkFirst` strategy for navigation requests - Server handles HTML rendering, cached for offline fallback\n- Asset precaching still works for JS/CSS/images\n\nThis is different from SPA mode where a static `index.html` would be precached.\n\n## Icons and webmanifest\n\nWe need to serve icons and webmanifest dynamically as we are a multi-tenant app.\n\n`/manifest.webmanifest` route generates the manifest from `tenant_settings`\n`tenant_settings` now has a `pwa_icons` column with the 5 icon sizes required.\n\n## Development\n\nPWA features are **disabled in development** mode because React Router 7's dev server conflicts with the service worker registration.\nTo test it locally, run `bun run build` and then `bun cross-env NODE_ENV=production bun ./server.js`.\nDon't forget to build it again on every change.\n"
  },
  {
    "path": "docs/react-router-7.md",
    "content": "### What is React Router 7?\nA React Fullstack Framework that provides server-side rendering and an API Layer.\nhttps://reactrouter.com/start/modes#framework\n\n## Routing\nEach route is a normal React component file that should include a [loader](https://reactrouter.com/start/framework/data-loading#server-data-loading) function, that function runs exclusively on the server (or clientLoader for browser only).\n\nParts of the component might be rendered client side, for that we can use React.lazy or wrap them with `<ClientOnly>` component from `remix-utils`.\n\nAdditionally, routes could also export [Links](https://reactrouter.com/api/components/Links#links) and [Meta](https://reactrouter.com/api/components/Meta#meta) functions that will be added to the html head.\n\nFor the API, we can use [action/loader](https://reactrouter.com/start/framework/actions#server-actions) routes.\n"
  },
  {
    "path": "docs/supabase.md",
    "content": "# What is Supabase?\n\nSupabase is an open source Firebase alternative, based on Postgres.\n\n## Getting Started\n\n### Local supabase instance\n\nTo install the supabase locally, follow https://supabase.com/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=windows\nInstalling as a dev dependency doesn't always work well, so it is recommended to install for your OS.\n\nInstall Docker Desktop.\nMake sure you have the docker app open.\n\nRun `supabase start` (Ensure you run it on the project folder root.)\nRun `supabase status`\nCreate a .env.local file at the project root (same level as .env) and fill in the keys with values from the command above:\n\n```\nSUPABASE_API_URL=<API URL>\nSUPABASE_KEY=<anon key>\nSUPABASE_SERVICE_ROLE_KEY=<service_role key>\n```\n\nRun `bun run db:seed` to run the DB migration scripts and update your local database schema. You will have to run this again whenever there are DB schema changes. For more in-depth seeding, please check [Seeding](./db-seeding.md).\n\nNow you can start the project with `bun start`.\nTo sign-up locally, you can get the email confirmation link at http://localhost:54324/monitor\n\n### Updating local supabase\n\nhttps://supabase.com/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=macos#updating-the-supabase-cli\n\n### Using online Supabase free version\n\n#### Create your Supabase instance\n\nYou can also use the online Supabase free version.\nFor this, create your Supabase account at https://supabase.com and create a new project.\nInstall the Supabase CLI : https://supabase.com/docs/guides/local-development/cli/getting-started\n\n#### Link your Supabase instance and push the db schema\n\nFirst, connect to the Supabase CLI using `supabase login`.\nGet your project-id from your Supabase project, you can find it in the project settings general section.\nNow from the project root, run `supabase link --project-ref your-project-id`.\nFinally, push your schema using `supabase db push`.\n\nTo finish you should fill the .env.local file with the values from the \"Data API\" section of your project settings:\n\n```\nSUPABASE_API_URL=<API URL>\nSUPABASE_KEY=<anon key>\nSUPABASE_SERVICE_ROLE_KEY=<service_role key>\n```\n\n## Migrations\n\nWe use Supabase Declarative Schemas feature.\nSchema changes must be made in the /supabase/schemas folder, following the current pattern (1 sql file per feature).\nAfter making the schema changes, a migration file needs to be generated, use this command:\n`supabase db diff --file [migration_name]`\n\n## Running Cypress Tests\n\nRunning cypress tests locally will use the local database, while running on CI will use the QA database.\nFor each test run, a new tenant_id is generated, which has a few benefits:\n\n- ensures no conflicts between parallel test runs\n- easier to cleanup\n- if the data isn't cleaned for some reason, it won't affect other runs\n  For each test file, there should be a `before` and `after` block to, respectively, seed and clean the database.\n\nCreate a .env.local file at the packages/cypress folder\nSUPABASE_API_URL=your_api_key (probably http://127.0.0.1:54321)\nSUPABASE_KEY=your_key\nSUPABASE_SERVICE_ROLE_KEY=your service key\n\nAll done! Tests will use your local database. More info about how it works below.\n\n## Supabase Edge Functions\n\nCurrently used for customizing Auth Emails.\nSupabase Auth Hooks have a timeout of 5 seconds which can easily be exceeded. To reduce the risk, the `resend` call is not awaited.\nIf it is exceeded, the user gets an error on the UI, but still receives the email and can continue his flow.\nHaven't managed to run the functions locally yet (contributions welcome!).\n"
  },
  {
    "path": "docs/team-principles.md",
    "content": "---\nid: team-principles\ntitle: Team Principles\n---\n\nThe principles we hold close to guide the work we prioritise, the ways we work and the product we aim for.\n\n## Team working values\n\n- Sustainability\n- Quality over quantity\n- Empower Contributors\n- Empowering good approaches (rather than restricting the bad)\n- Only invent new wheels when we absolutely have to\n\n## Product values\n\n- Features for all projects\n- Should feel natural and easy to navigate projects' whole web presence\n- Our tech feels human - is playful and organic\n\n## Foundational values\n\n- Stability and longevity\n- Accessible to users\n- Privacy\n- Legal Compliance\n- Separation from Big Tech\n"
  },
  {
    "path": "docs/technical-decisions.md",
    "content": "# Technical Decisions\n\n## Multi-tenant\n\nMulti-tenancy is a requirement because:\n\n- Single login for all websites.\n- Easier maintenance and migrations.\n\nWith supabase there are a few ways we can do multi-tenancy:\n\n1. Have Multiple projects\n2. Have a Single project with multiple schemas\n3. Have a Single project, with 1 common schema, using RLS (Row Level Security) to ensure data separation\n\nDecision: 3. Why?\n\n- A single project is easier to manage and deploy\n- Same for a single common schema... and multiple schemas wouldn't give any security benefits\n- With RLS, we can ensure, based on an Environment Variable, only the respective rows of that tenant are queried.\n\nHow?\n\n- Each table has a tenant_id column\n- On each request, to supabase (via its sdk) we pass a header 'x-tenant-id' with the process.env.TENANT_ID variable, which is set for each app, via Fly.io secret.\n\n## Comment Counts\n\nCurrently we can sort questions/research/library by the number of comments.\nWith supabase there are a few ways we can do this:\n\n1. A comment count view\n2. A comment count materialized view\n3. Triggers, where the main table has a comment_count column which is updated when a comment is inserted/deleted\n\nDecision: 3. Why?\n\n- A simple view isn't performant, would be querying for the total on each query.\n- A materialized view keeps state and is simple enough to update it, but doesn't support RLS. (Would be a better contender if it supported RLS)\n\nHow?\n\n- Whenever a comment is created or deleted, it triggers the update_comment_count function.\n- The function checks the Operation kind (Insert/Delete), the source_type and source_id.\n- From the source_type it will update the according content total (library, research, questions) that matches the source_id\n\n## Research Search\n\nResearch is composed by:\n\n1. research main item\n2. research updates\n\nWhen searching for `research`, we want to search both the main item and its updates.\nTo search using postgres textSearch, all info needs to be stored in a vector column.\nIf updates were stored in the main research item as json, it would be straightforward to index.\nHaving updates being stored in their own table is beneficial:\n\n- easier CRUD\n- enforcing a schema ensures data integrity and structure compared to json\n- easier to extract metrics\n\nThe solution is to have a `tsvector` column that also contains update data which comes from the `research_updates` table.\nTo build the column data, a `combined_research_search_fields(research_id)` is used to combine all data (main + updates).\nTo automate indexing from `research_update` an INSERT/UPDATE trigger is needed.\n\n## Notifications\n\nNotifications are sent in batch to avoid hitting resend limits.\nBatches of 100 emails, wait 1 second per batch.\n\nNotifications are sent to users who subscribed a specific kind of content, when said content is created.\nThere is a `subscriptions` table to keep track of that.\n\nCurrent notification types:\n\n- new research upgrade\n- new comment (for each of these content types - news, research_updates, questions, projects)\n- new reply (to a comment)\n\nAs such, there are 3 action types: `newContent`, `newComment`, `newReply`.\n\nWhy not merge `newComment` and `newReply`?\n\n- They were the same at first, but the logic is different enough to warrant the separation. For instance, in a `newReply` the \"parent\" is a comment, and to obtain extra info (content title), need to \"go up a level\" twice.\n- Due to this complexity, a decision was made to cut showing the `newReply` respective content title (can be added again later if necessary).\n- This also made the logic of `newComment` more straighforward.\n\nNotification links are redirects: `/redirect?id={content_id}&ct={content_type}`\nwhere the `content_type` could be comments, questions, research_updates, news, questions, projects\nBy using the redirect, we no longer need to generate the direct link in the \"send notifications\" step, which reduces complexity significantly.\n\n## Subscription Store\n\n### Architecture\n\n```\nProfileStoreProvider (existing)\n  └── SubscriptionStoreProvider (new)\n        └── App Components\n```\n\n### Pros\n\n- ✅ Clear separation of concerns\n- ✅ Independent lifecycle management\n- ✅ Easy to test in isolation\n- ✅ No impact on existing ProfileStore\n- ✅ Can be used anywhere in the component tree\n- ✅ Follows existing patterns in your codebase\n\n### Cons\n\n- ⚠️ Adds one more context provider layer\n- ⚠️ Need to nest providers in layout\n\n### Implementation Details\n\n**Store Structure:**\n\n```typescript\nclass SubscriptionStore {\n  // Cache: Map<\"contentType-itemId\", subscriptionState>\n  subscriptions: Map<string, boolean>\n\n  // Track loading states to prevent duplicate calls\n  loadingStates: Map<string, boolean>\n\n  Methods:\n  - checkAndCacheSubscription(contentType, itemId): Promise<boolean>\n  - subscribe(contentType, itemId): Promise<void>\n  - unsubscribe(contentType, itemId): Promise<void>\n  - isSubscribed(contentType, itemId): boolean | undefined\n  - clearCache(): void\n}\n```\n\n**Provider Setup:**\nPlace in `src/routes/_.tsx` inside `ProfileStoreProvider`:\n\n```tsx\n<ProfileStoreProvider>\n  <SubscriptionStoreProvider>{/* existing app */}</SubscriptionStoreProvider>\n</ProfileStoreProvider>\n```\n\n**Hook Usage:**\n\n```tsx\nconst { isSubscribed, subscribe, unsubscribe } = useSubscriptionStore();\nconst subscribed = isSubscribed('comments', 123);\n```\n"
  },
  {
    "path": "fly-ff.toml",
    "content": "app = 'community-platform-ff'\nprimary_region = 'ams'\n\n[build]\n\n[http_service]\ninternal_port = 3000\nforce_https = true\nauto_stop_machines = 'suspend'\nmin_machines_running = 1\nprocesses = ['app']\n\n[env]\nVITE_BRANCH = \"production\"\nNODE_ENV = \"production\"\n\n[[vm]]\nmemory = '2gb'\ncpu_kind = 'shared'\ncpus = 2\n"
  },
  {
    "path": "fly-pk.toml",
    "content": "app = 'community-platform-pk'\nprimary_region = 'ams'\n\n[build]\n\n[http_service]\ninternal_port = 3000\nforce_https = true\nauto_stop_machines = 'off'\nprocesses = ['app']\n\n[env]\nVITE_BRANCH = \"production\"\nNODE_ENV = \"production\"\n\n[[vm]]\nmemory = '2gb'\ncpu_kind = 'shared'\ncpus = 2\n"
  },
  {
    "path": "fly-pp.toml",
    "content": "app = 'community-platform-pp'\nprimary_region = 'ams'\n\n[build]\n\n[http_service]\ninternal_port = 3000\nforce_https = true\nauto_stop_machines = 'off'\nprocesses = ['app']\n\n[env]\nVITE_BRANCH = \"production\"\nNODE_ENV = \"production\"\n\n[[vm]]\nmemory = '2gb'\ncpu_kind = 'shared'\ncpus = 2\n"
  },
  {
    "path": "fly-preview.toml",
    "content": "primary_region = 'ams'\n\n[build]\ndockerfile = \"Dockerfile.preview\"\n\n[http_service]\ninternal_port = 3000\nforce_https = true\nauto_stop_machines = 'stop'\nprocesses = ['app']\n\n[env]\nVITE_BRANCH = \"preview\"\n\n[[vm]]\nmemory = '1gb'\ncpu_kind = 'shared'\ncpus = 2\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, initial-scale=1, shrink-to-fit=no\"\n    />\n    <link rel=\"preconnect\" href=\"https://storage.googleapis.com\" crossorigin />\n    <noscript><link rel=\"stylesheet\" href=\"path/to/stylesheet.css\" /></noscript>\n    <meta name=\"theme-color\" content=\"#000000\" />\n    <meta property=\"og:title\" content=\"Community Platform\" />\n    <meta\n      property=\"og:description\"\n      content=\"A series of tools for the Precious Plastic community to collaborate around the world. Connect, share and meet each other to tackle plastic waste.\"\n    />\n    <meta property=\"og:image\" content=\"./social-image.jpg\" />\n    <!-- <meta property=\"og:url\" content=\"https://community.preciousplastic.com\" /> -->\n    <meta name=\"twitter:title\" content=\"Community Platform\" />\n    <meta\n      name=\"twitter:description\"\n      content=\"A series of tools for the Precious Plastic community to collaborate around the world. Connect, share and meet each other to tackle plastic waste.\"\n    />\n    <meta name=\"twitter:image\" content=\"./social-image.jpg\" />\n    <meta name=\"twitter:card\" content=\"summary_large_image\" />\n    <!--\n      manifest.json provides metadata used when your web app is added to the\n      homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/\n    -->\n    <link rel=\"manifest\" href=\"./manifest.json\" />\n    <link id=\"favicon\" rel=\"shortcut icon\" href=\"./favicon.ico\" />\n    <!--\n      Notice the use of ./public in the tags above.\n      It will be replaced with the URL of the `public` folder during the build.\n      Only files inside the `public` folder can be referenced from the HTML.\n      Unlike \"/favicon.ico\" or \"favicon.ico\", \"./favicon.ico\" will\n      work correctly both with client-side routing and a non-root public URL.\n      Learn how to configure a non-root public URL by running `npm run build`.\n    -->\n    <title>Precious Plastic Community</title>\n    <meta\n      name=\"description\"\n      content=\"A series of tools for the Precious Plastic community to collaborate around the world. Connect, share and meet each other to tackle plastic waste.\"\n    />\n  </head>\n\n  <body style=\"overflow-x: hidden\">\n    <noscript> You need to enable JavaScript to run this app. </noscript>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/index.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"one-army-community-platform\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/ONEARMY/community-platform.git\"\n  },\n  \"workspaces\": [\"shared\", \"packages/*\"],\n  \"private\": true,\n  \"main\": \"lib/index.js\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"start\": \"bun ./server.js\",\n    \"start-ci\": \"bun run build:shared && cross-env NODE_ENV=production bun ./server.js\",\n    \"start:themes\": \"bun run --filter oa-themes dev\",\n    \"start:components\": \"bun run --filter oa-components dev\",\n    \"start:shared\": \"bun run --filter oa-shared dev\",\n    \"start:prod\": \"bun run build:shared && bun run build:themes && bun run build:components && cross-env NODE_ENV=production && bun ./server.js\",\n    \"start:platform\": \"bun ./server.js\",\n    \"start:platform-ci\": \"cross-env NODE_ENV=production bun ./server.js\",\n    \"build:themes\": \"bun run --filter oa-themes build\",\n    \"build:components\": \"bun run --filter oa-components build\",\n    \"build:vite\": \"react-router build\",\n    \"build:shared\": \"bun run --filter oa-shared build\",\n    \"build\": \"bun run build:shared && bun run build:themes && bun run build:components && bun run build:vite && tsc\",\n    \"lint\": \"biome lint --write\",\n    \"format\": \"biome check --write\",\n    \"serve\": \"npx serve -s build\",\n    \"test\": \"bun run --filter oa-cypress start\",\n    \"test:components\": \"bun run build:shared && bun run build:themes && bun run build:components && bun run --filter oa-components test\",\n    \"test:unit\": \"bun run build:themes && bun run build:components && vitest\",\n    \"test:madge\": \"npx madge --circular --extensions ts,tsx ./ --exclude src/stores\",\n    \"storybook\": \"bun run --filter oa-components start\",\n    \"storybook:build\": \"bun run build:shared && bun run build:themes && bun run --filter oa-components build:sb\",\n    \"docs\": \"bun run --filter oa-docs start\",\n    \"db:reset\": \"npx supabase db reset && bun run db:seed\",\n    \"db:seed\": \"npx @snaplet/seed sync && npx tsx seed.ts > seed.sql\",\n    \"commit\": \"git-cz\",\n    \"prepare\": \"husky\"\n  },\n  \"lint-staged\": {\n    \"*.{js,ts,tsx,json}\": [\"biome check --write --no-errors-on-unmatched\"]\n  },\n  \"browserslist\": {\n    \"production\": [\">0.2%\", \"not dead\", \"not op_mini all\", \"iOS >= 15\", \"safari >= 15\"],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\",\n      \"iOS >= 15\"\n    ]\n  },\n  \"resolutions\": {\n    \"__note1__\": \"Pin react version consistently in all child workspaces\",\n    \"react\": \"19.2.1\",\n    \"react-dom\": \"19.2.1\",\n    \"@types/react\": \"19.2.7\"\n  },\n  \"dependencies\": {\n    \"@emotion/cache\": \"^11.14.0\",\n    \"@emotion/react\": \"^11.14.0\",\n    \"@emotion/server\": \"^11.11.0\",\n    \"@emotion/styled\": \"^11.14.1\",\n    \"@mui/base\": \"next\",\n    \"@react-email/components\": \"0.3.3\",\n    \"@react-router/fs-routes\": \"^7.13.1\",\n    \"@react-spring/web\": \"^10.0.3\",\n    \"@sentry/react\": \"10.40.0\",\n    \"@sentry/react-router\": \"^10.40.0\",\n    \"@supabase/ssr\": \"^0.7.0\",\n    \"@supabase/storage-js\": \"^2.76.1\",\n    \"@supabase/supabase-js\": \"^2.76.1\",\n    \"@theme-ui/components\": \"^0.17.4\",\n    \"@theme-ui/core\": \"^0.17.4\",\n    \"canvas-confetti\": \"^1.9.3\",\n    \"countries-list\": \"^3.2.2\",\n    \"country-to-iso\": \"^1.6.1\",\n    \"date-fns\": \"^4.1.0\",\n    \"debounce\": \"^3.0.0\",\n    \"final-form\": \"4.20.2\",\n    \"final-form-arrays\": \"^3.0.2\",\n    \"hono\": \"^4.12.7\",\n    \"isbot\": \"^5.1.13\",\n    \"jsonwebtoken\": \"^9.0.3\",\n    \"keyv\": \"^5.1.2\",\n    \"leaflet\": \"^1.9.4\",\n    \"leaflet.markercluster\": \"^1.5.3\",\n    \"lodash\": \"^4.17.23\",\n    \"marked\": \"^17.0.1\",\n    \"mobx\": \"6.15.0\",\n    \"mobx-react\": \"9.2.1\",\n    \"oa-components\": \"workspace:*\",\n    \"oa-shared\": \"workspace:*\",\n    \"oa-themes\": \"workspace:*\",\n    \"react\": \"19.2.1\",\n    \"react-country-flag\": \"^3.1.0\",\n    \"react-dom\": \"19.2.1\",\n    \"react-final-form\": \"6.5.3\",\n    \"react-final-form-arrays\": \"^3.1.3\",\n    \"react-ga4\": \"^2.1.0\",\n    \"react-highlight-words\": \"^0.21.0\",\n    \"react-leaflet\": \"^5.0.0\",\n    \"react-leaflet-markercluster\": \"^5.0.0-rc.0\",\n    \"react-router\": \"^7.13.1\",\n    \"react-spring\": \"^10.0.3\",\n    \"react-tooltip\": \"^5.30.0\",\n    \"remix-utils\": \"^9.0.1\",\n    \"resend\": \"6.5.2\",\n    \"sharp\": \"^0.34.5\",\n    \"sonner\": \"^2.0.7\",\n    \"styled-system\": \"^5.1.5\",\n    \"theme-ui\": \"^0.17.2\",\n    \"tslog\": \"^4.10.2\",\n    \"use-debounce\": \"^10.1.0\",\n    \"yup\": \"^1.7.1\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"2.4.4\",\n    \"@faker-js/faker\": \"^10.1.0\",\n    \"@hono/node-server\": \"^1.19.11\",\n    \"@react-router/dev\": \"^7.13.1\",\n    \"@semantic-release/changelog\": \"^6.0.3\",\n    \"@semantic-release/git\": \"^10.0.1\",\n    \"@snaplet/copycat\": \"^6.0.0\",\n    \"@snaplet/seed\": \"0.98.0\",\n    \"@testing-library/jest-dom\": \"^6.9.1\",\n    \"@testing-library/react\": \"^16.3.0\",\n    \"@testing-library/user-event\": \"^14.6.1\",\n    \"@types/bun\": \"^1.3.9\",\n    \"@types/canvas-confetti\": \"^1\",\n    \"@types/jsonwebtoken\": \"^9\",\n    \"@types/leaflet\": \"^1.9.21\",\n    \"@types/node\": \"^22.18.0\",\n    \"@types/pg\": \"^8.11.11\",\n    \"@types/react\": \"19.2.7\",\n    \"@types/react-dom\": \"19.2.3\",\n    \"@types/react-leaflet\": \"^1.1.6\",\n    \"@types/react-leaflet-markercluster\": \"^2.0.0\",\n    \"@types/styled-system\": \"^5.1.25\",\n    \"@vitejs/plugin-react\": \"^5.1.1\",\n    \"all-contributors-cli\": \"^6.20.0\",\n    \"cross-env\": \"^7.0.3\",\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.7\",\n    \"pg\": \"^8.13.3\",\n    \"supabase\": \"2.53.6\",\n    \"tsx\": \"^4.20.3\",\n    \"typescript\": \"^5.7.2\",\n    \"vite\": \"^7.1.12\",\n    \"vite-plugin-env-compatible\": \"^2.0.1\",\n    \"vite-plugin-pwa\": \"^1.2.0\",\n    \"vite-plugin-svgr\": \"^4.5.0\",\n    \"vite-tsconfig-paths\": \"^5.1.4\",\n    \"vitest\": \"^4.0.5\",\n    \"workbox-window\": \"^7.4.0\"\n  },\n  \"dependenciesMeta\": {\n    \"cypress\": {\n      \"built\": true\n    }\n  },\n  \"engines\": {\n    \"bun\": \">=1.3.10\"\n  }\n}\n"
  },
  {
    "path": "packages/components/.gitignore",
    "content": "node_modules\nstorybook-static\ndist\ncoverage\nreports"
  },
  {
    "path": "packages/components/.storybook/main.ts",
    "content": "import type { StorybookConfig } from '@storybook/react-vite';\nimport { createRequire } from 'module';\nimport { dirname, join } from 'path';\nimport { mergeConfig } from 'vite';\n\nconst require = createRequire(import.meta.url);\n\nconst Config: StorybookConfig = {\n  stories: ['../src/**/*.stories.tsx', '../src/*.mdx'],\n\n  addons: [getAbsolutePath('@storybook/addon-links'), getAbsolutePath('@storybook/addon-docs')],\n\n  framework: {\n    name: getAbsolutePath('@storybook/react-vite'),\n    options: {},\n  },\n\n  async viteFinal(config) {\n    return mergeConfig(config, {\n      publicDir: '../public',\n    });\n  },\n};\nexport default Config;\n\nfunction getAbsolutePath(value: string): any {\n  return dirname(require.resolve(join(value, 'package.json')));\n}\n"
  },
  {
    "path": "packages/components/.storybook/manager.js",
    "content": "import { addons } from 'storybook/manager-api';\nimport { create } from 'storybook/theming';\n\naddons.setConfig({\n  isFullscreen: false,\n  showNav: true,\n  showPanel: true,\n  panelPosition: 'bottom',\n  enableShortcuts: true,\n  isToolshown: true,\n  theme: create({\n    base: 'light',\n    brandTitle: 'Platform Components',\n    brandUrl: 'https://onearmy.world',\n  }),\n  selectedPanel: undefined,\n  initialActive: 'sidebar',\n  sidebar: {\n    showRoots: false,\n    collapsedRoots: ['other'],\n  },\n});\n\n// automatically import all files ending in *.stories.js\n// configure(require.context('../src', true, /\\.stories\\.(js|jsx|mdx)$/), module)\n"
  },
  {
    "path": "packages/components/.storybook/preview.tsx",
    "content": "import { Global } from '@emotion/react';\nimport type { Preview } from '@storybook/react-vite';\nimport { ThemeProvider } from '@theme-ui/core';\nimport { theme } from 'oa-themes';\nimport { useEffect } from 'react';\nimport { createRoutesStub } from 'react-router';\nimport { GlobalStyles } from '../src/GlobalStyles/GlobalStyles';\n\nexport const themeColors = {\n  'precious-plastic': {\n    primary: '#fee77b',\n    primaryHover: '#ffde45',\n    accent: '#fee77b',\n    accentHover: '#ffde45',\n  },\n  'project-kamp': {\n    primary: '#8ab57f',\n    primaryHover: 'hsl(108, 25%, 68%)',\n    accent: '#8ab57f',\n    accentHover: 'hsl(108, 25%, 68%)',\n  },\n  'fixing-fashion': {\n    primary: '#f82f03',\n    primaryHover: 'hsl(14, 81%, 63%)',\n    accent: '#f82f03',\n    accentHover: 'hsl(14, 81%, 63%)',\n  },\n} as const;\n\nexport type ThemeName = keyof typeof themeColors;\n\nexport function getThemeCSSVariables(themeName: ThemeName): string {\n  const colors = themeColors[themeName];\n  return `\n    --color-primary: ${colors.primary};\n    --color-primary-hover: ${colors.primaryHover};\n    --color-accent: ${colors.accent};\n    --color-accent-hover: ${colors.accentHover};\n  `.trim();\n}\n\nconst themeMap: Record<string, ThemeName> = {\n  pp: 'precious-plastic',\n  pk: 'project-kamp',\n  ff: 'fixing-fashion',\n};\n\n// Component to inject CSS variables dynamically\nfunction ThemeVariables({ themeName }: { themeName: ThemeName }) {\n  useEffect(() => {\n    const cssVars = getThemeCSSVariables(themeName);\n    const styleId = 'theme-variables';\n\n    let styleTag = document.getElementById(styleId) as HTMLStyleElement;\n    if (!styleTag) {\n      styleTag = document.createElement('style');\n      styleTag.id = styleId;\n      document.head.appendChild(styleTag);\n    }\n\n    styleTag.textContent = `:root { ${cssVars} }`;\n  }, [themeName]);\n\n  return null;\n}\n\nconst preview: Preview = {\n  parameters: {\n    actions: { argTypesRegex: '^on[A-Z].*' },\n    options: {\n      storySort: {\n        order: ['Welcome'],\n      },\n    },\n    controls: {\n      matchers: {\n        color: /(background|color)$/i,\n        date: /Date$/,\n      },\n    },\n  },\n  globalTypes: {\n    theme: {\n      name: 'Theme',\n      description: 'Platform Theme',\n      defaultValue: 'pp',\n      toolbar: {\n        icon: 'paintbrush',\n        items: [\n          { value: 'pp', title: 'Precious Plastic' },\n          { value: 'pk', title: 'Project Kamp' },\n          { value: 'ff', title: 'Fixing Fashion' },\n        ],\n      },\n    },\n  },\n  decorators: [\n    (Story, context) => {\n      const themeName = themeMap[context.globals.theme] || 'precious-plastic';\n\n      const RouterStub = createRoutesStub([\n        {\n          path: '/',\n          Component: () => (\n            <>\n              <ThemeVariables themeName={themeName} />\n              <Global styles={GlobalStyles} />\n              <ThemeProvider theme={theme}>\n                <Story />\n              </ThemeProvider>\n            </>\n          ),\n        },\n      ]);\n\n      return <RouterStub />;\n    },\n  ],\n};\n\nexport default preview;\n"
  },
  {
    "path": "packages/components/README.md",
    "content": "# Platform Components\n\nA collection of react components for reuse across the platform. Built with [Theme UI](https://theme-ui.com/) for styling.\n\nThese components are stored within the [Community Platform monorepo](https://github.com/ONEARMY/community-platform) and configured as a standalone package using [Bun workspaces](https://bun.sh/docs/install/workspaces).  \nThe aim of packaging these components separately is to:\n\n1. Encourage separation between presentation layer and business logic\n2. Reduce the overhead for contributors looking to work **only** on the component layer without needing to spin up the entire application locally.\n\nWe are using [Storybook](https://storybook.js.org/) to provide a browser accessible interface for our components.\n\n> Storybook is a tool for UI development. It makes development faster and easier by isolating components. This allows you to work on one component at a time. You can develop entire UIs without needing to start up a complex dev stack, force certain data into your database, or navigate around your application.\n\n(Optional) For anyone unfamiliar with Storybook looking to better understand the tool, we recommend reading their guide on [What's a Story](https://storybook.js.org/docs/react/get-started/whats-a-story).\n\n## Getting started\n\nAfter [cloning the repo](https://github.com/ONEARMY/community-platform), you can start the Storybook instance, which will make the application available in your browser at [http://localhost:6006](http://localhost:6006/).\n\n```\ncd ./packages/components\nbun install\nbun start\n```\n\n## Creating a new Component\n\nYou can quickly create a new component using the command `bun run new-component MyNewComponentName`, which\nwill generate the following items:\n\n```\nsrc/\n  MyNewComponentName/\n    MyNewComponentName.tsx # Component\n    MyNewComponentName.test.tsx # Storybook documentation\n    MyNewComponentName.stories.tsx # Storybook documentation\n```\n"
  },
  {
    "path": "packages/components/package.json",
    "content": "{\n  \"name\": \"oa-components\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": {\n      \"bun\": \"./src/index.ts\",\n      \"import\": \"./dist/index.js\",\n      \"default\": \"./dist/index.js\"\n    },\n    \"./*\": {\n      \"bun\": \"./src/*.ts\",\n      \"import\": \"./dist/*.js\",\n      \"default\": \"./dist/*.js\"\n    }\n  },\n  \"main\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./src/index.ts\", \"./src/*\"]\n    }\n  },\n  \"scripts\": {\n    \"storybook\": \"bun run start\",\n    \"start\": \"storybook dev -p 6006 --loglevel verbose\",\n    \"format\": \"biome check --write\",\n    \"build:sb\": \"bun run build --build && storybook build\",\n    \"build\": \"tsc --build --verbose\",\n    \"dev\": \"tsc --watch\",\n    \"lint\": \"biome lint --write\",\n    \"test\": \"vitest\",\n    \"test-ci\": \"vitest --coverage --reporter=junit --outputFile.junit=./reports/output.xml\"\n  },\n  \"dependencies\": {\n    \"@emotion/react\": \"^11.14.0\",\n    \"@emotion/styled\": \"^11.14.1\",\n    \"@faker-js/faker\": \"^10.1.0\",\n    \"@mdxeditor/editor\": \"^3.52.0\",\n    \"@mui/base\": \"next\",\n    \"@react-spring/web\": \"^10.0.3\",\n    \"@theme-ui/core\": \"^0.17.4\",\n    \"country-to-iso\": \"^1.6.1\",\n    \"date-fns\": \"^4.1.0\",\n    \"linkify-plugin-mention\": \"^4.3.2\",\n    \"linkify-react\": \"^4.3.2\",\n    \"linkifyjs\": \"^4.3.2\",\n    \"marked\": \"^17.0.1\",\n    \"oa-themes\": \"workspace:^\",\n    \"photoswipe\": \"^5.4.4\",\n    \"react-country-flag\": \"^3.1.0\",\n    \"react-horizontal-scrolling-menu\": \"^8.2.0\",\n    \"react-icons\": \"^5.3.0\",\n    \"react-player\": \"3.0.0-canary.4\",\n    \"react-router\": \"^7.13.1\",\n    \"react-select\": \"^5.8.1\",\n    \"react-spring\": \"^10.0.3\",\n    \"react-tooltip\": \"^5.30.0\",\n    \"storybook\": \"10.1.4\",\n    \"styled-system\": \"^5.1.5\",\n    \"theme-ui\": \"^0.17.2\",\n    \"use-debounce\": \"^10.1.0\",\n    \"yup\": \"^1.7.1\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"19.2.1\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"2.3.14\",\n    \"@react-router/dev\": \"^7.13.1\",\n    \"@storybook/addon-docs\": \"10.1.4\",\n    \"@storybook/addon-links\": \"^10.1.4\",\n    \"@storybook/react-vite\": \"10.1.4\",\n    \"@storybook/react\": \"10.1.4\",\n    \"@testing-library/dom\": \"^10.4.1\",\n    \"@testing-library/jest-dom\": \"^6.9.1\",\n    \"@testing-library/react\": \"^16.3.0\",\n    \"@types/react\": \"^19.2.7\",\n    \"@vitejs/plugin-react\": \"^5.1.1\",\n    \"eslint\": \"^8.50.0\",\n    \"eslint-plugin-import\": \"^2.28.1\",\n    \"eslint-plugin-storybook\": \"^9.0.15\",\n    \"eslint-plugin-vitest\": \"^0.5.4\",\n    \"jsdom\": \"^21.1.1\",\n    \"react\": \"^19.2.1\",\n    \"react-dom\": \"^19.2.1\",\n    \"typescript\": \"^5.7.2\",\n    \"vitest\": \"^4.0.5\"\n  }\n}\n"
  },
  {
    "path": "packages/components/src/Accordion/Accordion.stories.tsx",
    "content": "import { Text } from 'theme-ui';\n\nimport { Accordion } from './Accordion';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/Accordion',\n  component: Accordion,\n} as Meta<typeof Accordion>;\n\nexport const Default: StoryFn<typeof Accordion> = () => (\n  <Accordion title=\"Accordion Title\">\n    <Text>Now you see me!</Text>\n  </Accordion>\n);\n"
  },
  {
    "path": "packages/components/src/Accordion/Accordion.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { act, screen } from '@testing-library/react';\nimport { Text } from 'theme-ui';\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { Accordion } from './Accordion';\n\ndescribe('Accordion', () => {\n  it('displays the accordion body on click', () => {\n    const { getByText } = render(\n      <Accordion title=\"Accordion Title\">\n        <Text>Now you see me!</Text>\n      </Accordion>,\n    );\n    const accordionTitle = getByText('Accordion Title');\n    expect(screen.queryByText('Now you see me!')).not.toBeInTheDocument();\n\n    act(() => {\n      accordionTitle.click();\n    });\n\n    expect(getByText('Now you see me!')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/Accordion/Accordion.tsx",
    "content": "import { useState } from 'react';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Flex, Heading, Text } from 'theme-ui';\nimport { Icon } from '../Icon/Icon';\n\nexport interface IProps {\n  children: React.ReactNode;\n  sx?: ThemeUIStyleObject | undefined;\n  title: string;\n  subtitle?: string;\n}\n\nexport const Accordion = (props: IProps) => {\n  const [isExpanded, setIsExpanded] = useState<boolean>(false);\n  const { children, sx, title, subtitle } = props;\n\n  return (\n    <Flex\n      data-cy=\"accordionContainer\"\n      sx={{ flexDirection: 'column', gap: 2, cursor: 'pointer', ...sx }}\n      onClick={() => {\n        if (!isExpanded) {\n          setIsExpanded(true);\n        }\n      }}\n    >\n      <Flex\n        sx={{\n          flexDirection: 'row',\n          justifyContent: 'space-between',\n          alignItems: 'center',\n        }}\n        onClick={() => setIsExpanded(!isExpanded)}\n      >\n        <Heading as=\"h3\" variant=\"h3\">\n          {title}\n        </Heading>\n        <Icon glyph={isExpanded ? 'arrow-full-up' : 'arrow-full-down'} />\n      </Flex>\n\n      {subtitle != undefined && <Text sx={{ fontSize: 1, color: 'gray' }}>{subtitle}</Text>}\n      {isExpanded && children}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ActionSet/ActionSet.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { useEffect, useRef, useState } from 'react';\nimport { Card, Flex } from 'theme-ui';\nimport { Button } from '../Button/Button';\n\ninterface IProps {\n  children: ReactNode[];\n  itemType: 'ReplyItem' | 'CommentItem';\n}\n\nexport const ActionSet = ({ children, itemType }: IProps) => {\n  const [show, setShow] = useState<boolean>(false);\n  const cardRef = useRef<HTMLDivElement>(null);\n\n  const toDisplay = children.filter((child) => !!child);\n  if (!children || toDisplay.length === 0) {\n    return null;\n  }\n\n  const onClick = () => setShow((show) => !show);\n\n  useEffect(() => {\n    const handleClickOutsideDropdownCard = (event: MouseEvent) => {\n      if (cardRef.current && !cardRef.current.contains(event.target as Node)) {\n        setShow((prev) => !prev);\n      }\n    };\n\n    if (show) document.addEventListener('mousedown', handleClickOutsideDropdownCard);\n\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutsideDropdownCard);\n    };\n  }, [show]);\n\n  return (\n    <Flex\n      ref={cardRef}\n      sx={{\n        display: 'inline-block',\n        position: 'relative',\n        gap: 2,\n      }}\n    >\n      <Button\n        data-cy={`${itemType}: ActionSetButton`}\n        icon=\"more-vert\"\n        onClick={onClick}\n        variant=\"subtle\"\n        small={true}\n        showIconOnly\n      >\n        Show Actions\n      </Button>\n\n      {show && (\n        <Card\n          sx={{\n            position: 'absolute',\n            right: 0,\n            zIndex: 10,\n            gap: 1,\n            minWidth: '200px',\n          }}\n        >\n          <Flex\n            onClick={() => setShow(false)}\n            sx={{\n              alignItems: 'stretch',\n              justifyItems: 'stretch',\n              flexDirection: 'column',\n            }}\n          >\n            {...children}\n          </Flex>\n        </Card>\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Alert/Alert.stories.tsx",
    "content": "import { Alert } from 'theme-ui';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/Alert',\n  component: Alert,\n} as Meta<typeof Alert>;\n\nexport const Success: StoryFn<typeof Alert> = () => (\n  <Alert variant=\"success\">A successful message</Alert>\n);\n\nexport const Failure: StoryFn<typeof Alert> = () => (\n  <Alert variant=\"failure\">An error message</Alert>\n);\n\nexport const Information: StoryFn<typeof Alert> = () => (\n  <Alert variant=\"info\">An information message</Alert>\n);\n\nexport const FailureLong: StoryFn<typeof Alert> = () => (\n  <Alert variant=\"failure\">\n    An error message: Veniam explicabo dolor ipsam impedit. Eum eos ut et consequatur eos eaque\n    explicabo et inventore. Aperiam aut consequatur sit ut. Iusto consequatur enim placeat enim quia\n    voluptas pariatur. Culpa quaerat placeat magni et autem earum placeat deserunt eum. A autem enim\n    dolorum. Quo sint nisi vel. Voluptate voluptates alias repudiandae doloribus nemo. Quia aperiam\n    nihil magnam quos ut id. Pariatur itaque sint. Id vel aliquid ullam delectus animi quis.{' '}\n  </Alert>\n);\n"
  },
  {
    "path": "packages/components/src/ArrowIcon/ArrowIcon.stories.tsx",
    "content": "import { Arrow } from './ArrowIcon';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  /* 👇 The title prop is optional.\n   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading\n   * to learn how to generate automatic titles\n   */\n  title: 'Components/Arrow icon',\n  component: Arrow,\n} as Meta<typeof Arrow>;\n\nexport const Left: StoryFn<typeof Arrow> = () => <Arrow direction=\"left\" />;\nexport const Right: StoryFn<typeof Arrow> = () => <Arrow direction=\"right\" />;\n"
  },
  {
    "path": "packages/components/src/ArrowIcon/ArrowIcon.tsx",
    "content": "import type { ThemeUIStyleObject } from 'theme-ui';\nimport { Flex } from 'theme-ui';\nimport { Icon } from '../Icon/Icon';\nimport type { availableGlyphs } from '../Icon/types';\n\nimport './styles.css';\n\ninterface IProps {\n  disabled?: boolean;\n  direction: 'left' | 'right';\n  sx?: ThemeUIStyleObject;\n  onClick?: () => void;\n}\n\nexport const Arrow = ({ disabled, direction, onClick, sx }: IProps) => {\n  const glyph: availableGlyphs = direction === 'left' ? 'chevron-left' : 'chevron-right';\n\n  return (\n    <Flex\n      sx={{\n        overflow: 'hidden',\n        alignItems: 'center',\n        ...sx,\n      }}\n    >\n      {disabled ? null : (\n        <Flex\n          sx={{\n            width: ['35px', '35px', '45px'],\n            height: ['35px', '35px', '45px'],\n            border: '3px solid #000',\n            borderRadius: 3,\n            alignItems: 'center',\n            justifyContent: 'center',\n            backgroundColor: 'white',\n          }}\n        >\n          <Icon\n            sx={{\n              position: 'relative',\n              // Properly center the icon for arrows as they can look offset\n              left: direction === 'right' ? '1px' : '-1px',\n            }}\n            glyph={glyph}\n            size={35}\n            onClick={onClick}\n            className=\"arrow-\"\n          />\n        </Flex>\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ArrowIcon/styles.css",
    "content": ".react-horizontal-scrolling-menu--inner-wrapper {\n  position: relative;\n}\n\n.react-horizontal-scrolling-menu--arrow-left {\n  position: absolute;\n  left: 0;\n  z-index: 1;\n  height: 100%;\n}\n\n.react-horizontal-scrolling-menu--arrow-right {\n  position: absolute;\n  right: 0;\n  z-index: 1;\n  height: 100%;\n}\n"
  },
  {
    "path": "packages/components/src/ArticleCallToActionSupabase/ArticleCallToActionSupabase.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\n\nimport { Button } from '../Button/Button';\nimport { UsefulStatsButton } from '../UsefulStatsButton/UsefulStatsButton';\nimport { ArticleCallToActionSupabase } from './ArticleCallToActionSupabase';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { Author } from 'oa-shared';\n\nexport default {\n  title: 'Layout/ArticleCallToActionSupabase',\n  component: ArticleCallToActionSupabase,\n} as Meta<typeof ArticleCallToActionSupabase>;\n\nexport const ArticleCallToActionSupabaseCommentAndUseful: StoryFn<\n  typeof ArticleCallToActionSupabase\n> = () => (\n  <ArticleCallToActionSupabase author={makeFakeUser()}>\n    <Button sx={{ fontSize: 2 }}>Leave a comment</Button>\n    <UsefulStatsButton\n      isLoggedIn={false}\n      hasUserVotedUseful={false}\n      onUsefulClick={() => Promise.resolve()}\n    />\n  </ArticleCallToActionSupabase>\n);\n\nexport const ArticleCallToActionSupabaseUseful: StoryFn<\n  typeof ArticleCallToActionSupabase\n> = () => (\n  <ArticleCallToActionSupabase author={makeFakeUser()}>\n    <UsefulStatsButton\n      isLoggedIn={false}\n      hasUserVotedUseful={false}\n      onUsefulClick={() => Promise.resolve()}\n    />\n  </ArticleCallToActionSupabase>\n);\n\nexport const ArticleCallToActionSupabaseSingleContributor: StoryFn<\n  typeof ArticleCallToActionSupabase\n> = () => (\n  <ArticleCallToActionSupabase\n    author={makeFakeUser()}\n    contributors={[\n      {\n        id: faker.number.int(),\n        country: faker.location.countryCode(),\n        displayName: faker.person.firstName(),\n        badges: [\n          {\n            id: 1,\n            name: 'pro',\n            displayName: 'PRO',\n            imageUrl: faker.image.avatar(),\n          },\n          {\n            id: 2,\n            name: 'supporter',\n            displayName: 'Supporter',\n            actionUrl: faker.internet.url(),\n            imageUrl: faker.image.avatar(),\n          },\n        ],\n        photo: {\n          id: faker.string.uuid(),\n          publicUrl: faker.image.avatar(),\n        },\n        username: faker.internet.username(),\n      },\n    ]}\n  >\n    <Button>Action</Button>\n  </ArticleCallToActionSupabase>\n);\n\nconst makeFakeUser = (): Author => ({\n  id: faker.number.int(),\n  country: faker.location.countryCode(),\n  displayName: faker.person.firstName(),\n  badges: [\n    {\n      id: 1,\n      name: 'pro',\n      displayName: 'PRO',\n      imageUrl: faker.image.avatar(),\n    },\n    {\n      id: 2,\n      name: 'supporter',\n      displayName: 'Supporter',\n      actionUrl: faker.internet.url(),\n      imageUrl: faker.image.avatar(),\n    },\n  ],\n  photo: {\n    id: faker.string.uuid(),\n    publicUrl: faker.image.avatar(),\n  },\n  username: faker.internet.username(),\n});\n\nexport const ArticleCallToActionSupabaseMultipleContributors: StoryFn<\n  typeof ArticleCallToActionSupabase\n> = () => (\n  <ArticleCallToActionSupabase\n    author={makeFakeUser()}\n    contributors={faker.helpers.uniqueArray(makeFakeUser, Math.floor(Math.random() * 10))}\n  >\n    <Button>Action</Button>\n  </ArticleCallToActionSupabase>\n);\n"
  },
  {
    "path": "packages/components/src/ArticleCallToActionSupabase/ArticleCallToActionSupabase.tsx",
    "content": "import type { Author } from 'oa-shared';\nimport { Flex, Heading, Text } from 'theme-ui';\nimport { Username } from '../Username/Username';\n\nexport interface IProps {\n  author: Author;\n  children: React.ReactNode;\n  contributors?: Author[];\n}\n\nexport const ArticleCallToActionSupabase = (props: IProps) => {\n  const { author, children, contributors } = props;\n\n  return (\n    <Flex\n      sx={{\n        flexDirection: 'column',\n        alignItems: 'center',\n        alignContent: 'center',\n      }}\n    >\n      <Flex>\n        <Text variant=\"body\" sx={{ fontSize: 2, alignContent: 'center' }}>\n          Made by\n        </Text>\n        <Username user={author} sx={{ ml: 1 }} />\n      </Flex>\n      {contributors && contributors.length ? (\n        <Text\n          variant=\"quiet\"\n          sx={{\n            display: 'block',\n            marginTop: 2,\n            textAlign: 'center',\n            fontSize: 2,\n            gap: 1,\n            alignItems: 'center',\n          }}\n        >\n          With contributions from:{' '}\n          {contributors.map((contributor, key) => (\n            <Username key={key} user={contributor} />\n          ))}\n        </Text>\n      ) : null}\n      <Heading sx={{ my: 4 }}>Like what you see? 👇</Heading>\n      <Flex\n        sx={{\n          gap: 2,\n          flexDirection: ['column', 'row'],\n        }}\n      >\n        {children}\n      </Flex>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/AuthorDisplay/AuthorDisplay.tsx",
    "content": "import type { Author } from 'oa-shared';\nimport { Avatar, Flex } from 'theme-ui';\nimport { Username } from '../Username/Username';\n\ninterface IProps {\n  author: Author | null;\n}\n\nexport const AuthorDisplay = ({ author }: IProps) => {\n  if (!author) {\n    return null;\n  }\n\n  return (\n    <Flex sx={{ gap: 2 }}>\n      {author.photo && (\n        <Avatar\n          data-cy=\"authorAvatar\"\n          src={author.photo.publicUrl}\n          sx={{\n            objectFit: 'cover',\n            width: '40px',\n            height: '40px',\n          }}\n          alt={author.displayName}\n          loading=\"lazy\"\n        />\n      )}\n\n      <Username user={author} sx={{ alignSelf: 'center' }} />\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Banner/Banner.stories.tsx",
    "content": "import { Banner } from './Banner';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/Banner',\n  component: Banner,\n} as Meta<typeof Banner>;\n\nexport const Default: StoryFn<typeof Banner> = () => (\n  <Banner>Defaults to a failure banner when no varient defined</Banner>\n);\n\nexport const AccentWithOnclick: StoryFn<typeof Banner> = () => (\n  <Banner variant=\"accent\" onClick={() => null}>\n    This is an accent with onClick\n  </Banner>\n);\n\nexport const InfoWithCustomStylings: StoryFn<typeof Banner> = () => (\n  <Banner variant=\"info\" sx={{ height: '200px', border: '4px solid #333' }}>\n    Info with custom stylings\n  </Banner>\n);\n\nexport const Success: StoryFn<typeof Banner> = () => (\n  <Banner variant=\"success\" onClick={() => null}>\n    Success Banner\n  </Banner>\n);\n"
  },
  {
    "path": "packages/components/src/Banner/Banner.test.tsx",
    "content": "import { fireEvent } from '@testing-library/react';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { Banner } from './Banner';\n\ndescribe('Banner', () => {\n  it('sets the default variant if none is provided', () => {\n    const onClick = vi.fn();\n    const { getByText } = render(<Banner onClick={onClick}>Some words</Banner>);\n\n    fireEvent.click(getByText('Some words'));\n\n    expect(onClick).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/Banner/Banner.tsx",
    "content": "import type { ThemeUIStyleObject } from 'theme-ui';\nimport { Alert } from 'theme-ui';\n\n// Types of alert currently specified in the theme\ntype AlertVariants = 'accent' | 'failure' | 'info' | 'success';\n\nexport interface IProps {\n  children: React.ReactNode;\n  onClick?: () => void;\n  sx?: ThemeUIStyleObject | undefined;\n  variant?: AlertVariants;\n}\n\nexport const Banner = (props: IProps) => {\n  const { children, onClick, sx, variant } = props;\n\n  return (\n    <Alert\n      data-cy=\"Banner\"\n      onClick={onClick}\n      variant={variant || 'failure'}\n      sx={{\n        borderRadius: 2,\n        alignItems: 'center',\n        flex: '1',\n        justifyContent: 'center',\n        cursor: onClick ? 'pointer' : 'default',\n        fontSize: 2,\n        ':hover': { textDecoration: onClick ? 'underline' : 'none' },\n        ...sx,\n      }}\n    >\n      {children}\n    </Alert>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/BlockedRoute/BlockedRoute.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\n\nimport { BlockedRoute } from './BlockedRoute';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/BlockedRoute',\n  component: BlockedRoute,\n} as Meta<typeof BlockedRoute>;\n\nexport const Default: StoryFn<typeof BlockedRoute> = () => (\n  <BlockedRoute>{faker.lorem.sentences(2)}</BlockedRoute>\n);\n\nexport const OverrideButton: StoryFn<typeof BlockedRoute> = () => (\n  <BlockedRoute redirectLabel=\"A custom call to action\" redirectUrl=\"/another-url\">\n    {faker.lorem.sentences(2)}\n  </BlockedRoute>\n);\n"
  },
  {
    "path": "packages/components/src/BlockedRoute/BlockedRoute.tsx",
    "content": "import { Box, Flex, Text } from 'theme-ui';\n\nimport { Button } from '../Button/Button';\nimport { InternalLink } from '../InternalLink/InternalLink';\n\nexport interface BlockedRouteProps {\n  children: React.ReactNode;\n  redirectUrl?: string;\n  redirectLabel?: string;\n}\n\nexport const BlockedRoute = (props: BlockedRouteProps) => {\n  const redirectLabel = props.redirectLabel || 'Back to home';\n  const redirectUrl = props.redirectUrl || '/';\n  return (\n    <Flex sx={{ justifyContent: 'center', flexDirection: 'column', mt: 8 }} data-cy=\"BlockedRoute\">\n      <Text sx={{ width: '100%', textAlign: 'center' }}>{props.children}</Text>\n      <Box sx={{ textAlign: 'center', mt: 2 }}>\n        <InternalLink to={redirectUrl}>\n          <Button type=\"button\" variant=\"subtle\" small>\n            {redirectLabel}\n          </Button>\n        </InternalLink>\n      </Box>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Breadcrumbs/Breadcrumbs.stories.tsx",
    "content": "import { Breadcrumbs } from './Breadcrumbs';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/Breadcrumbs',\n  component: Breadcrumbs,\n} as Meta<typeof Breadcrumbs>;\n\nexport const Default: StoryFn<typeof Breadcrumbs> = () => (\n  <Breadcrumbs\n    steps={[\n      {\n        text: 'Question',\n        link: '/questions',\n      },\n      {\n        text: 'Category',\n        link: '/questions?category=Category',\n      },\n      {\n        text: 'Are we real?',\n      },\n    ]}\n  />\n);\n\nexport const NoCategory: StoryFn<typeof Breadcrumbs> = () => (\n  <Breadcrumbs\n    steps={[\n      {\n        text: 'Question',\n        link: '/questions',\n      },\n      {\n        text: 'Are we real?',\n      },\n    ]}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/Breadcrumbs/Breadcrumbs.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { Breadcrumbs } from './Breadcrumbs';\n\ndescribe('Breadcrumbs', () => {\n  it('validate full breadcrumbs', () => {\n    const { getByText, getAllByTestId } = render(\n      <Breadcrumbs\n        steps={[\n          {\n            text: 'Question',\n            link: '/questions',\n          },\n          {\n            text: 'Category',\n            link: '/questions?category=Category',\n          },\n          {\n            text: 'Are we real?',\n          },\n        ]}\n      />,\n    );\n\n    expect(getByText('Question')).toBeInTheDocument();\n    expect(getByText('Category')).toBeInTheDocument();\n    expect(getByText('Are we real?')).toBeInTheDocument();\n    const chevrons = getAllByTestId('breadcrumbsChevron');\n    expect(chevrons).toHaveLength(2);\n  });\n\n  it('validate no category breadcrumbs', () => {\n    const { getByText, getAllByTestId } = render(\n      <Breadcrumbs\n        steps={[\n          {\n            text: 'Question',\n            link: '/questions',\n          },\n          {\n            text: 'Are we real?',\n          },\n        ]}\n      />,\n    );\n\n    expect(getByText('Question')).toBeInTheDocument();\n    expect(getByText('Are we real?')).toBeInTheDocument();\n    const chevrons = getAllByTestId('breadcrumbsChevron');\n    expect(chevrons).toHaveLength(1);\n  });\n});\n"
  },
  {
    "path": "packages/components/src/Breadcrumbs/Breadcrumbs.tsx",
    "content": "import { Flex } from 'theme-ui';\n\nimport { Icon } from '../Icon/Icon';\nimport { BreadcrumbItem } from './BreadcrumbsItem';\n\ntype Step = { text: string; link?: string };\n\nexport interface BreadcrumbsProps {\n  steps: Step[];\n}\n\nexport const Breadcrumbs = ({ steps }: BreadcrumbsProps) => {\n  return (\n    <Flex\n      sx={{\n        alignItems: 'center',\n        width: '100%',\n        overflowX: 'auto',\n        scrollbarWidth: 'none',\n        '&::-webkit-scrollbar': {\n          display: 'none',\n        },\n      }}\n    >\n      {steps.map((step, index) => {\n        const isLast = index === steps.length - 1;\n        return (\n          <Flex\n            key={index}\n            sx={{\n              alignItems: 'center',\n              flexShrink: isLast ? 1 : 0,\n              ...(isLast && { flex: '1' }),\n            }}\n          >\n            <BreadcrumbItem text={step.text} link={step.link} isLast={isLast} />\n            {!isLast && (\n              <Icon glyph=\"chevron-right\" color=\"black\" data-testid=\"breadcrumbsChevron\" />\n            )}\n          </Flex>\n        );\n      })}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Breadcrumbs/BreadcrumbsItem.tsx",
    "content": "import { Link } from 'react-router';\nimport { Box, Text } from 'theme-ui';\n\nimport { Button } from '../Button/Button';\n\ninterface BreadcrumbButtonProps {\n  text: string;\n  link?: string;\n}\n\ninterface BreadcrumbItemProps {\n  text: string;\n  link?: string;\n  isLast: boolean;\n}\n\nconst BreadcrumbButton = ({ text, link }: BreadcrumbButtonProps) => {\n  return link ? (\n    <Link to={link}>\n      <Button type=\"button\" variant=\"breadcrumb\">\n        {text}\n      </Button>\n    </Link>\n  ) : (\n    <Button type=\"button\" variant=\"breadcrumb\">\n      {text}\n    </Button>\n  );\n};\n\nexport const BreadcrumbItem = ({ text, link, isLast }: BreadcrumbItemProps) => (\n  <Box\n    style={{\n      display: 'inline-flex',\n      ...(isLast && {\n        flex: '1',\n        maxWidth: '100%',\n      }),\n    }}\n    data-testid=\"breadcrumbsItem\"\n    data-cy=\"breadcrumbsItem\"\n  >\n    {!isLast ? (\n      <BreadcrumbButton link={link} text={text} />\n    ) : (\n      <Text\n        sx={{\n          display: 'block',\n          color: 'black',\n          fontSize: [2, 3],\n          textOverflow: 'ellipsis',\n          whiteSpace: 'nowrap',\n          overflow: 'hidden',\n          width: '100%',\n          padding: 1,\n          paddingX: 3,\n        }}\n      >\n        {text}\n      </Text>\n    )}\n  </Box>\n);\n"
  },
  {
    "path": "packages/components/src/Button/Button.stories.tsx",
    "content": "import { glyphs } from '../Icon/Icon';\nimport { Button } from './Button';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  /* 👇 The title prop is optional.\n   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading\n   * to learn how to generate automatic titles\n   */\n  title: 'Components/Button',\n  component: Button,\n} as Meta<typeof Button>;\n\nconst sizeOptions = [\n  {\n    small: true,\n    label: 'Small',\n  },\n  {\n    label: 'Default',\n  },\n  {\n    large: true,\n    label: 'Large',\n  },\n];\n\nexport const Basic: StoryFn<typeof Button> = () => <Button>Button Text</Button>;\n\nexport const Disabled: StoryFn<typeof Button> = () => (\n  <>\n    <Button disabled>Disabled</Button>\n    <Button icon=\"delete\" disabled>\n      Disabled\n    </Button>\n  </>\n);\n\nexport const Primary: StoryFn<typeof Button> = () => (\n  <>\n    <Button variant={'primary'}>Primary</Button>\n    <Button icon=\"delete\" variant={'primary'}>\n      Primary\n    </Button>\n    {sizeOptions.map((v, k) => (\n      <Button key={k} variant={'primary'} {...v}>\n        {v.label}\n      </Button>\n    ))}\n  </>\n);\n\nexport const Secondary: StoryFn<typeof Button> = () => (\n  <>\n    <Button variant={'secondary'}>Secondary</Button>\n    <Button icon=\"delete\" variant={'secondary'}>\n      Secondary\n    </Button>\n    {sizeOptions.map((v, k) => (\n      <Button key={k} variant={'secondary'} {...v}>\n        {v.label}\n      </Button>\n    ))}\n  </>\n);\n\nexport const Destructive: StoryFn<typeof Button> = () => (\n  <>\n    <Button variant={'destructive'}>Destructive</Button>\n    <Button icon=\"delete\" variant={'destructive'}>\n      Destructive\n    </Button>\n    {sizeOptions.map((v, k) => (\n      <Button key={k} variant={'destructive'} {...v}>\n        {v.label}\n      </Button>\n    ))}\n  </>\n);\n\nexport const Success: StoryFn<typeof Button> = () => (\n  <>\n    <Button variant=\"success\">Success</Button>\n    <Button icon=\"delete\" variant=\"success\">\n      Success\n    </Button>\n    {sizeOptions.map((v, k) => (\n      <Button key={k} variant=\"success\" {...v}>\n        {v.label}\n      </Button>\n    ))}\n  </>\n);\n\nexport const Subtle: StoryFn<typeof Button> = () => (\n  <>\n    <Button variant={'subtle'}>Subtle</Button>\n    <Button variant={'subtle'} icon=\"account-circle\">\n      Subtle\n    </Button>\n    {sizeOptions.map((v, k) => (\n      <Button key={k} variant={'subtle'} {...v}>\n        {v.label}\n      </Button>\n    ))}\n  </>\n);\n\nexport const Outline: StoryFn<typeof Button> = () => (\n  <>\n    <Button variant={'outline'}>Outline</Button>\n    <Button variant={'outline'} icon=\"account-circle\">\n      Outline\n    </Button>\n    {sizeOptions.map((v, k) => (\n      <Button key={k} variant={'outline'} {...v}>\n        {v.label}\n      </Button>\n    ))}\n  </>\n);\n\nexport const Small: StoryFn<typeof Button> = () => (\n  <>\n    <Button small={true}>Small Button</Button>\n    <Button small={true} icon=\"delete\">\n      Small Button with Icon\n    </Button>\n  </>\n);\n\nexport const Large: StoryFn<typeof Button> = () => (\n  <>\n    <Button large={true}>Large Button</Button>\n    <Button large={true} icon=\"delete\">\n      Large Button with Icon\n    </Button>\n  </>\n);\n\nexport const IconOnly: StoryFn<typeof Button> = () => (\n  <>\n    <Button large={true} icon=\"delete\" showIconOnly={true}>\n      Icon Button with hidden text\n    </Button>\n  </>\n);\n\nexport const Icons: StoryFn<typeof Button> = () => (\n  <>\n    {sizeOptions.map((size) =>\n      ['primary', 'secondary', 'outline'].map((variant) =>\n        Object.keys(glyphs).map((glyph: any, key) => (\n          <Button icon={glyph} key={key} {...size} variant={variant}>\n            {size.label} with Icon\n          </Button>\n        )),\n      ),\n    )}\n  </>\n);\n"
  },
  {
    "path": "packages/components/src/Button/Button.tsx",
    "content": "import type { Colors } from 'oa-themes';\nimport React from 'react';\nimport type { ButtonProps as ThemeUiButtonProps } from 'theme-ui';\nimport { Flex, Text, Button as ThemeUiButton } from 'theme-ui';\nimport { Icon } from '../Icon/Icon';\nimport type { IGlyphs } from '../Icon/types';\n\n// extend to allow any default button props (e.g. onClick) to also be passed\nexport interface IBtnProps extends React.ButtonHTMLAttributes<HTMLElement> {\n  icon?: keyof IGlyphs;\n  disabled?: boolean;\n  small?: boolean;\n  large?: boolean;\n  showIconOnly?: boolean;\n  iconColor?: Colors;\n  iconFilter?: string;\n}\n\ntype ToArray<Type> = [Type] extends [any] ? Type[] : never;\ntype AvailableButtonProps = ToArray<keyof BtnProps>;\n\nconst buttonSizeProps: { [key: string]: any } = {\n  small: {\n    px: 2,\n    py: 1,\n    pl: '2rem',\n    fontSize: 1,\n    height: '2rem',\n  },\n  default: {\n    px: 3,\n    pl: 9,\n  },\n  large: {\n    px: 4,\n    py: 3,\n    pl: 10,\n    fontSize: 4,\n    height: '3.5rem',\n  },\n};\n\nexport type BtnProps = IBtnProps & ThemeUiButtonProps;\n\nfunction getSizeProps(size: string, hasIcon: boolean) {\n  if (!buttonSizeProps[size] && !hasIcon) {\n    return {};\n  }\n\n  if (!buttonSizeProps[size] && hasIcon) {\n    return {\n      px: 3,\n      pl: 9,\n    };\n  }\n\n  const sizeProps = { ...buttonSizeProps[size] };\n\n  if (!hasIcon) {\n    delete sizeProps.pl;\n  }\n\n  return sizeProps;\n}\n\nfunction getScaleTransform(size: string) {\n  if (size === 'large') {\n    return 1.25;\n  }\n\n  return 1;\n}\n\nfunction sanitizedProps(obj: BtnProps, keysToRemove: AvailableButtonProps) {\n  const sanitizedObj = { ...obj };\n\n  keysToRemove.forEach((prop) => {\n    if (sanitizedObj[prop]) {\n      delete sanitizedObj[prop];\n    }\n  });\n\n  return sanitizedObj;\n}\n\nexport const Button = (props: BtnProps) => {\n  let size = 'default';\n\n  if (props.small === true) {\n    size = 'small';\n  } else if (props.large === true) {\n    size = 'large';\n  }\n\n  return (\n    <ThemeUiButton\n      {...sanitizedProps(props, ['small', 'large', 'showIconOnly', 'iconColor', 'iconFilter'])}\n      sx={{\n        ...getSizeProps(size, !!props.icon),\n        ...(props.showIconOnly ? { pr: 0 } : {}),\n        ...props.sx,\n      }}\n    >\n      {props.icon && (\n        <Flex\n          aria-hidden={true}\n          sx={{\n            position: 'absolute',\n            top: 0,\n            left: 0,\n            height: '100%',\n            flexDirection: 'column',\n            justifyContent: 'center',\n            alignItems: 'center',\n            px: getSizeProps(size, !!props.icon)?.px || 0,\n            boxSizing: 'border-box',\n            fontSize: 0,\n            maxWidth: '100%',\n            lineHeight: 0,\n            transform: `translateY(-1px) scale(${getScaleTransform(size)})`,\n            pointerEvents: 'none',\n          }}\n        >\n          <Icon glyph={props.icon} color={props.iconColor} filter={props.iconFilter} />\n        </Flex>\n      )}\n      <Text\n        sx={{\n          ...(props.showIconOnly\n            ? {\n                clipPath: 'inset(100%)',\n                clip: 'rect(1px, 1px, 1px, 1px)',\n                height: '1px',\n                overflow: 'hidden',\n                position: 'absolute',\n                whiteSpace: 'nowrap',\n                width: '1px',\n              }\n            : {}),\n        }}\n      >\n        {props.children}\n      </Text>\n    </ThemeUiButton>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ButtonIcon/ButtonIcon.stories.tsx",
    "content": "import { ButtonIcon } from './ButtonIcon';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Map/ButtonIcon',\n  component: ButtonIcon,\n} as Meta<typeof ButtonIcon>;\n\nexport const WithClose: StoryFn<typeof ButtonIcon> = () => <ButtonIcon icon=\"close\" />;\n"
  },
  {
    "path": "packages/components/src/ButtonIcon/ButtonIcon.tsx",
    "content": "import type { ThemeUIStyleObject } from 'theme-ui';\nimport { Button } from 'theme-ui';\nimport { Icon } from '../Icon/Icon';\nimport type { IGlyphs } from '../Icon/types';\n\nexport interface IProps extends React.ButtonHTMLAttributes<HTMLElement> {\n  icon: keyof IGlyphs;\n  sx?: ThemeUIStyleObject | undefined;\n}\n\nexport const ButtonIcon = (props: IProps) => {\n  return (\n    <Button {...props} sx={{ background: 'white', borderRadius: 99, padding: 1, ...props.sx }}>\n      <Icon glyph={props.icon} size={18} />\n    </Button>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ButtonShowReplies/ButtonShowReplies.stories.tsx",
    "content": "import { useState } from 'react';\n\nimport { createFakeCommentsSB } from '../utils';\nimport { ButtonShowReplies } from './ButtonShowReplies';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/ButtonShowReplies',\n  component: ButtonShowReplies,\n} as Meta<typeof ButtonShowReplies>;\n\nexport const DefaultComponent = () => {\n  const [isShowReplies, setIsShowReplies] = useState<boolean>(false);\n\n  const replies = createFakeCommentsSB(7);\n\n  return (\n    <ButtonShowReplies\n      replies={replies}\n      isShowReplies={isShowReplies}\n      setIsShowReplies={() => setIsShowReplies(!isShowReplies)}\n    />\n  );\n};\n\nexport const Default: StoryFn<typeof ButtonShowReplies> = () => {\n  return <DefaultComponent />;\n};\n\nexport const RepliesShowing: StoryFn<typeof ButtonShowReplies> = () => {\n  const replies = createFakeCommentsSB(6);\n\n  return <ButtonShowReplies isShowReplies={true} replies={replies} setIsShowReplies={() => null} />;\n};\n\nexport const OneReply: StoryFn<typeof ButtonShowReplies> = () => {\n  const replies = createFakeCommentsSB(1);\n\n  return (\n    <ButtonShowReplies isShowReplies={false} replies={replies} setIsShowReplies={() => null} />\n  );\n};\n\nexport const NoReplies: StoryFn<typeof ButtonShowReplies> = () => {\n  return <ButtonShowReplies isShowReplies={false} replies={[]} setIsShowReplies={() => null} />;\n};\n\nexport const NoCreatorName: StoryFn<typeof ButtonShowReplies> = () => {\n  const replies = createFakeCommentsSB(1);\n\n  return (\n    <ButtonShowReplies isShowReplies={false} replies={replies} setIsShowReplies={() => null} />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ButtonShowReplies/ButtonShowReplies.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { createFakeCommentsSB } from '../utils';\nimport { ButtonShowReplies } from './ButtonShowReplies';\nimport { DefaultComponent } from './ButtonShowReplies.stories';\n\ndescribe('ButtonShowReplies', () => {\n  it('renders the button text', () => {\n    const { getByTestId, getByText } = render(<DefaultComponent />);\n    const icon = getByTestId('show-replies');\n\n    expect(getByText('Show 7 replies')).toBeInTheDocument();\n    expect(icon.getAttribute('icon')).toContain('chevron-down');\n  });\n\n  it('renders the button text', () => {\n    const replies = createFakeCommentsSB(6);\n    const { getByTestId } = render(\n      <ButtonShowReplies isShowReplies={true} replies={replies} setIsShowReplies={() => null} />,\n    );\n    const icon = getByTestId('show-replies');\n\n    expect(icon.getAttribute('icon')).toContain('chevron-up');\n  });\n\n  it('renders the word reply when expected', () => {\n    const replies = createFakeCommentsSB(1);\n\n    const { getByText } = render(\n      <ButtonShowReplies isShowReplies={false} replies={replies} setIsShowReplies={() => null} />,\n    );\n\n    expect(getByText('Show 1 reply')).toBeInTheDocument();\n  });\n\n  it('renders the number zero when expected', () => {\n    const { getByText } = render(\n      <ButtonShowReplies isShowReplies={false} replies={[]} setIsShowReplies={() => null} />,\n    );\n\n    expect(getByText('Reply')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/ButtonShowReplies/ButtonShowReplies.tsx",
    "content": "import type { Comment } from 'oa-shared';\nimport { Button } from '../Button/Button';\n\nexport interface Props {\n  isShowReplies: boolean;\n  replies: Comment[];\n  setIsShowReplies: () => void;\n}\n\nexport const ButtonShowReplies = (props: Props) => {\n  const { isShowReplies, replies, setIsShowReplies } = props;\n\n  const count = replies.filter(({ deleted }) => deleted !== true).length;\n  const icon = isShowReplies ? 'chevron-up' : 'chevron-down';\n\n  const text = count\n    ? isShowReplies\n      ? `Hide ${count} ${count === 1 ? 'reply' : 'replies'}`\n      : `Show ${count} ${count === 1 ? 'reply' : 'replies'}`\n    : isShowReplies\n      ? `Hide`\n      : `Reply`;\n\n  return (\n    <Button\n      type=\"button\"\n      data-cy=\"show-replies\"\n      data-testid=\"show-replies\"\n      icon={icon}\n      onClick={setIsShowReplies}\n      sx={{ alignSelf: 'flex-start' }}\n      variant=\"subtle\"\n      small\n    >\n      {text}\n    </Button>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CardButton/CardButton.stories.tsx",
    "content": "import { CardButton } from './CardButton';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/CardButton',\n  component: CardButton,\n} as Meta<typeof CardButton>;\n\nexport const Basic: StoryFn<typeof CardButton> = () => (\n  <div style={{ width: '300px' }}>\n    <CardButton isSelected={false}>\n      <div style={{ padding: '20px' }}>Basic Implementation</div>\n    </CardButton>\n  </div>\n);\n"
  },
  {
    "path": "packages/components/src/CardButton/CardButton.tsx",
    "content": "import type { BoxProps, ThemeUIStyleObject } from 'theme-ui';\nimport { Card } from 'theme-ui';\n\nexport interface IProps extends BoxProps {\n  children: React.ReactNode;\n  extrastyles?: ThemeUIStyleObject | undefined;\n  isSelected?: boolean;\n}\n\nexport const CardButton = (props: IProps) => {\n  const { children, extrastyles, isSelected } = props;\n\n  return (\n    <Card\n      sx={{\n        alignItems: 'center',\n        alignContent: 'center',\n        display: 'flex',\n        gap: 2,\n        marginTop: '4px',\n        borderRadius: 2,\n        padding: 0,\n        transition: 'borderBottom 0.2s, transform 0.2s',\n        '&:hover': !isSelected && {\n          animationSpeed: '0.3s',\n          cursor: 'pointer',\n          marginTop: '4px',\n          borderBottom: '2px solid',\n          transform: 'translateY(-2px)',\n          borderColor: 'black',\n        },\n        '&:active': {\n          transform: 'translateY(1px)',\n          marginTop: '3px',\n          borderBottom: '3px solid',\n          borderColor: 'grey',\n          transition: 'borderBottom 0.2s, transform 0.2s, borderColor 0.2s',\n        },\n        ...(isSelected\n          ? {\n              marginTop: '2px',\n              borderBottom: '4px solid',\n              borderColor: 'grey',\n              transform: 'translateY(-2px)',\n            }\n          : {}),\n        ...extrastyles,\n      }}\n      {...props}\n    >\n      {children}\n    </Card>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CardListItem/CardListItem.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\n\nimport { CardListItem } from './CardListItem';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { MapPin, Moderation, ProfileType } from 'oa-shared';\n\nexport default {\n  title: 'Map/CardListItem',\n  component: CardListItem,\n} as Meta<typeof CardListItem>;\n\nconst onPinClick = () => undefined;\nconst viewport = 'desktop';\n\nconst member: ProfileType = {\n  name: 'member',\n  description: 'A member profile',\n  displayName: 'Member',\n  id: 2,\n  imageUrl: faker.image.avatar(),\n  mapPinName: 'Member',\n  order: 1,\n  smallImageUrl: faker.image.avatar(),\n  isSpace: false,\n};\n\nconst space: ProfileType = {\n  name: 'space',\n  description: 'A space profile',\n  displayName: 'Space',\n  id: 3,\n  imageUrl: faker.image.avatar(),\n  mapPinName: 'Space',\n  order: 1,\n  smallImageUrl: faker.image.avatar(),\n  isSpace: true,\n};\n\nexport const DefaultMember: StoryFn<typeof CardListItem> = () => {\n  const item = {\n    id: 1,\n    lat: 0,\n    lng: 0,\n    administrative: '',\n    country: 'Brazil',\n    countryCode: 'BR',\n    moderation: 'accepted' as Moderation,\n    profile: {\n      id: 1,\n      photo: {\n        publicUrl: faker.image.avatar(),\n      },\n      displayName: 'member_no1',\n      isContactable: false,\n      type: member,\n    },\n  } as MapPin;\n\n  return (\n    <div style={{ width: '500px' }}>\n      <CardListItem item={item} isSelectedPin={false} onPinClick={onPinClick} viewport={viewport} />\n    </div>\n  );\n};\n\nexport const DefaultSpace: StoryFn<typeof CardListItem> = () => {\n  const item = {\n    id: 1,\n    lat: 0,\n    lng: 0,\n    administrative: '',\n    country: 'United Kingdom',\n    countryCode: 'UK',\n    moderation: 'accepted' as Moderation,\n    profile: {\n      id: 1,\n      photo: {\n        publicUrl: faker.image.avatar(),\n      },\n      about:\n        'Lorem ipsum odor amet, consectetuer adipiscing elit. Lorem ipsum odor amet, consectetuer adipiscing elit.',\n      displayName: 'member_no1',\n      isContactable: false,\n      type: space,\n      tags: [{ id: 1, name: 'Sheetpress' }],\n    },\n  } as MapPin;\n\n  return (\n    <div style={{ width: '500px' }}>\n      <CardListItem item={item} isSelectedPin={false} onPinClick={onPinClick} viewport={viewport} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CardListItem/CardListItem.tsx",
    "content": "import type { MapPin } from 'oa-shared';\nimport { Box } from 'theme-ui';\nimport { CardButton } from '../CardButton/CardButton';\nimport { CardProfile } from '../CardProfile/CardProfile';\nimport { InternalLink } from '../InternalLink/InternalLink';\n\nexport interface IProps {\n  item: MapPin;\n  isSelectedPin: boolean;\n  onPinClick: (arg: MapPin) => void;\n  viewport: string;\n}\n\nexport const CardListItem = (props: IProps) => {\n  const { item, onPinClick, isSelectedPin, viewport } = props;\n  const testProp = `CardListItem${isSelectedPin ? '-selected' : ''}`;\n\n  const Card = (\n    <CardButton isSelected={isSelectedPin}>\n      <CardProfile item={item} />\n    </CardButton>\n  );\n\n  const wrapperProps = {\n    'data-cy': testProp,\n    'data-testid': testProp,\n    sx: {\n      borderRadius: 2,\n      padding: 2,\n    },\n  };\n\n  if (viewport === 'mobile') {\n    return (\n      <InternalLink\n        target=\"_blank\"\n        to={item.profile?.username ? `/u/${item.profile.username}` : '/settings/profile'}\n        {...wrapperProps}\n      >\n        {Card}\n      </InternalLink>\n    );\n  }\n\n  return (\n    <Box\n      data-cy={testProp}\n      data-testid={testProp}\n      onClick={() => onPinClick(item)}\n      sx={{\n        borderRadius: 2,\n        padding: 2,\n      }}\n    >\n      {Card}\n    </Box>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CardProfile/CardDetailsMemberProfile.tsx",
    "content": "import type { PinProfile } from 'oa-shared';\nimport { Avatar, Box, Flex } from 'theme-ui';\nimport defaultProfileImage from '../../assets/images/default_member.svg';\nimport { MemberBadge } from '../MemberBadge/MemberBadge';\nimport { ProfileTagsList } from '../ProfileTagsList/ProfileTagsList';\nimport { Username } from '../Username/Username';\n\ninterface IProps {\n  profile: PinProfile;\n  isLink: boolean;\n}\n\nexport const CardDetailsMemberProfile = ({ profile, isLink }: IProps) => {\n  const photoUrl = profile.photo?.publicUrl;\n\n  return (\n    <Flex\n      data-testid=\"CardDetailsMemberProfile\"\n      sx={{\n        gap: 2,\n        justifyContent: 'center',\n        alignItems: 'center',\n        padding: 2,\n        alignContent: 'stretch',\n      }}\n    >\n      <Box sx={{ aspectRatio: 1, width: '60px', height: '60px' }}>\n        <Flex\n          sx={{\n            alignContent: 'flex-start',\n            justifyContent: 'flex-end',\n            flexWrap: 'wrap',\n          }}\n        >\n          <Avatar\n            src={photoUrl || defaultProfileImage}\n            sx={{ width: '60px', height: '60px', objectFit: 'cover' }}\n            loading=\"lazy\"\n          />\n          <MemberBadge\n            profileType={profile.type || undefined}\n            size={22}\n            sx={{ transform: 'translateY(-22px)' }}\n          />\n        </Flex>\n      </Box>\n\n      <Flex sx={{ flexDirection: 'column', gap: 1, flex: 1, minWidth: 0 }}>\n        <Username user={profile} sx={{ alignSelf: 'flex-start' }} isLink={isLink} target=\"_blank\" />\n        {profile.tags && profile.tags.length > 0 && (\n          <ProfileTagsList tags={profile.tags} isSpace={false} />\n        )}\n      </Flex>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CardProfile/CardDetailsSpaceProfile.tsx",
    "content": "import type { PinProfile } from 'oa-shared';\nimport { Box, Flex, Image, Text } from 'theme-ui';\nimport { MemberBadge } from '../MemberBadge/MemberBadge';\nimport { ProfileTagsList } from '../ProfileTagsList/ProfileTagsList';\nimport { Username } from '../Username/Username';\n\ninterface IProps {\n  profile: PinProfile;\n  isLink: boolean;\n}\n\nexport const CardDetailsSpaceProfile = ({ profile, isLink }: IProps) => {\n  const coverImage =\n    profile.coverImages && profile.coverImages[0] && profile.coverImages[0]?.publicUrl;\n  const profileUrl = profile.photo?.publicUrl;\n  const hasImage = coverImage || profileUrl;\n\n  const aboutText =\n    profile.about && profile.about.length > 80 ? profile.about.slice(0, 78) + '...' : profile.about;\n\n  return (\n    <Flex data-testid=\"CardDetailsSpaceProfile\" sx={{ flexDirection: 'column', width: '100%' }}>\n      {hasImage && (\n        <>\n          <Flex sx={{ aspectRatio: 16 / 6, overflow: 'hidden' }}>\n            <Image\n              src={coverImage || profileUrl}\n              sx={{\n                aspectRatio: 16 / 6,\n                alignSelf: 'stretch',\n                objectFit: 'cover',\n              }}\n              loading=\"lazy\"\n            />\n          </Flex>\n          <Box\n            sx={{\n              position: 'relative',\n              height: 0,\n              top: '-20px',\n              width: '100%',\n            }}\n          >\n            <MemberBadge\n              profileType={profile.type || undefined}\n              size={40}\n              sx={{\n                float: 'right',\n                marginX: 2,\n              }}\n            />\n          </Box>\n        </>\n      )}\n      <Flex\n        sx={{\n          alignItems: 'flex-start',\n          flexDirection: 'column',\n          gap: 1,\n          padding: 2,\n        }}\n      >\n        <Flex sx={{ gap: 2, minWidth: 0, width: '100%' }}>\n          {!hasImage && <MemberBadge profileType={profile.type || undefined} size={30} />}\n          <Username\n            user={profile}\n            sx={{ alignSelf: 'flex-start' }}\n            isLink={isLink}\n            target=\"_blank\"\n          />\n        </Flex>\n\n        {profile.tags && profile.tags.length > 0 && (\n          <ProfileTagsList tags={profile.tags} isSpace={true} />\n        )}\n\n        {aboutText && (\n          <Text variant=\"quiet\" sx={{ fontSize: 2, wordBreak: 'break-word' }}>\n            {aboutText}\n          </Text>\n        )}\n      </Flex>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CardProfile/CardProfile.stories.tsx",
    "content": "import { fakePinProfile, fakeProfileType } from '../utils';\nimport { CardProfile } from './CardProfile';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { MapPin, PinProfile } from 'oa-shared';\n\nexport default {\n  title: 'Components/CardProfile',\n  component: CardProfile,\n} as Meta<typeof CardProfile>;\n\nconst member: PinProfile = fakePinProfile();\n\nconst space: PinProfile = fakePinProfile({\n  type: fakeProfileType({ isSpace: true }),\n});\n\nexport const Member: StoryFn<typeof CardProfile> = () => (\n  <CardProfile item={{ profile: member } as MapPin} />\n);\n\nexport const Space: StoryFn<typeof CardProfile> = () => (\n  <CardProfile item={{ profile: space } as MapPin} />\n);\n"
  },
  {
    "path": "packages/components/src/CardProfile/CardProfile.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { fakePinProfile, fakeProfileType } from '../utils';\nimport { CardProfile } from './CardProfile';\n\nimport type { MapPin, PinProfile } from 'oa-shared';\n\ndescribe('CardProfile', () => {\n  it('renders the member profile', () => {\n    const member: PinProfile = fakePinProfile();\n    const { getByTestId } = render(<CardProfile item={{ profile: member } as MapPin} />);\n\n    expect(getByTestId('CardDetailsMemberProfile')).toBeInTheDocument();\n  });\n  it('renders the space profile', () => {\n    const space: PinProfile = fakePinProfile({\n      type: fakeProfileType({ isSpace: true }),\n    });\n    const { getByTestId } = render(<CardProfile item={{ profile: space } as MapPin} />);\n\n    expect(getByTestId('CardDetailsSpaceProfile')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/CardProfile/CardProfile.tsx",
    "content": "import type { MapPin } from 'oa-shared';\nimport { Flex } from 'theme-ui';\nimport { CardDetailsMemberProfile } from './CardDetailsMemberProfile';\nimport { CardDetailsSpaceProfile } from './CardDetailsSpaceProfile';\n\nexport interface IProps {\n  item: MapPin;\n  isLink?: boolean;\n}\n\nexport const CardProfile = ({ item, isLink = false }: IProps) => {\n  const { profile } = item;\n\n  const isWorkspace = profile?.type && profile?.type.isSpace;\n\n  return (\n    <Flex sx={{ alignItems: 'stretch', alignContent: 'stretch' }}>\n      {isWorkspace ? (\n        <CardDetailsSpaceProfile profile={profile} isLink={isLink} />\n      ) : (\n        <CardDetailsMemberProfile profile={profile} isLink={isLink} />\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Category/Category.stories.tsx",
    "content": "import { Category } from './Category';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { Category as CategoryType } from 'oa-shared';\n\nexport default {\n  title: 'Components/Category',\n  component: Category,\n} as Meta<typeof Category>;\n\nexport const Default: StoryFn<typeof Category> = () => (\n  <Category\n    category={\n      {\n        name: 'Label',\n      } as CategoryType\n    }\n  />\n);\n"
  },
  {
    "path": "packages/components/src/Category/Category.tsx",
    "content": "import type { Category as CategoryType } from 'oa-shared';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Flex, Text } from 'theme-ui';\n\nexport interface Props {\n  category: CategoryType;\n  sx?: ThemeUIStyleObject | undefined;\n}\n\nexport const Category = (props: Props) => {\n  const { category, sx } = props;\n\n  return (\n    <Flex sx={{ alignItems: 'start' }}>\n      <Text\n        data-cy=\"category\"\n        sx={{\n          fontSize: 1,\n          backgroundColor: 'lightGrey',\n          paddingX: 2,\n          paddingY: 1,\n          borderRadius: 1,\n          ...sx,\n        }}\n      >\n        {category.name}\n      </Text>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CategoryHorizonalList/CategoryHorizonalList.stories.tsx",
    "content": "import { useState } from 'react';\n\nimport { CategoryHorizonalList } from './CategoryHorizonalList';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { Category, ContentType } from 'oa-shared';\n\nexport default {\n  title: 'Components/CategoryHorizonalList',\n  component: CategoryHorizonalList,\n} as Meta<typeof CategoryHorizonalList>;\n\nconst allCategoriesForPreciousPlastic = [\n  {\n    createdAt: new Date('2024-12-03T18:03:51.313Z'),\n    id: 1,\n    modifiedAt: null,\n    name: 'Guides',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-01T18:03:51.313Z'),\n    id: 2,\n    modifiedAt: null,\n    name: 'Machines',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 3,\n    modifiedAt: null,\n    name: 'Moulds',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 4,\n    modifiedAt: null,\n    name: 'Products',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 5,\n    modifiedAt: null,\n    name: 'Starter Kits',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-04T18:03:51.313Z'),\n    id: 6,\n    modifiedAt: null,\n    name: 'Recycling',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-05T18:03:51.313Z'),\n    id: 7,\n    modifiedAt: null,\n    name: 'Version 5',\n    type: 'questions' as ContentType,\n  },\n];\n\nconst allCategoriesForProjectKamp = [\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 8,\n    modifiedAt: null,\n    name: 'Construction',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 9,\n    modifiedAt: null,\n    name: 'Food',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 10,\n    modifiedAt: null,\n    name: 'Landscape',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 11,\n    modifiedAt: null,\n    name: 'Other',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 12,\n    modifiedAt: null,\n    name: 'Utilities',\n    type: 'questions' as ContentType,\n  },\n];\n\nexport const Basic: StoryFn<typeof CategoryHorizonalList> = () => {\n  const [activeCategory, setActiveCategory] = useState<Category | null>(null);\n  const allCategories = [...allCategoriesForPreciousPlastic, ...allCategoriesForProjectKamp];\n\n  return (\n    <div style={{ maxWidth: '500px' }}>\n      <CategoryHorizonalList\n        activeCategory={activeCategory}\n        allCategories={allCategories}\n        setActiveCategory={setActiveCategory}\n      />\n    </div>\n  );\n};\n\nexport const WhenGlyphNotPresent: StoryFn<typeof CategoryHorizonalList> = () => {\n  const [activeCategory, setActiveCategory] = useState<Category | null>(null);\n  const noGlyphCategories = [\n    {\n      createdAt: new Date('2022-12-03T18:03:51.313Z'),\n      id: 13,\n      modifiedAt: null,\n      name: 'No Glphy A',\n      type: 'questions' as ContentType,\n    },\n    {\n      createdAt: new Date('2022-12-03T18:03:51.313Z'),\n      id: 14,\n      modifiedAt: null,\n      name: 'No Glphy B',\n      type: 'questions' as ContentType,\n    },\n    {\n      createdAt: new Date('2022-12-03T18:03:51.313Z'),\n      id: 15,\n      modifiedAt: null,\n      name: 'No Glphy C',\n      type: 'questions' as ContentType,\n    },\n  ];\n\n  return (\n    <div style={{ maxWidth: '500px' }}>\n      <CategoryHorizonalList\n        activeCategory={activeCategory}\n        allCategories={noGlyphCategories}\n        setActiveCategory={setActiveCategory}\n      />\n    </div>\n  );\n};\n\nexport const OnlyOne: StoryFn<typeof CategoryHorizonalList> = () => {\n  const [activeCategory, setActiveCategory] = useState<Category | null>(null);\n\n  const twoCategories = [allCategoriesForPreciousPlastic[0], allCategoriesForPreciousPlastic[1]];\n\n  return (\n    <div style={{ maxWidth: '500px' }}>\n      <CategoryHorizonalList\n        activeCategory={activeCategory}\n        allCategories={twoCategories}\n        setActiveCategory={setActiveCategory}\n      />\n      (Shouldn't see anything, only renders for two or more)\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CategoryHorizonalList/CategoryHorizonalList.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { CategoryHorizonalList } from './CategoryHorizonalList';\n\nimport type { ContentType } from 'oa-shared';\n\nconst allCategoriesForPreciousPlastic = [\n  {\n    createdAt: new Date('2024-12-03T18:03:51.313Z'),\n    id: 1,\n    modifiedAt: null,\n    name: 'Guides',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-01T18:03:51.313Z'),\n    id: 2,\n    modifiedAt: null,\n    name: 'Machines',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 3,\n    modifiedAt: null,\n    name: 'Moulds',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 4,\n    modifiedAt: null,\n    name: 'Products',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 5,\n    modifiedAt: null,\n    name: 'Starter Kits',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-04T18:03:51.313Z'),\n    id: 6,\n    modifiedAt: null,\n    name: 'Recycling',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-05T18:03:51.313Z'),\n    id: 7,\n    modifiedAt: null,\n    name: 'Version 5',\n    type: 'questions' as ContentType,\n  },\n];\n\nconst allCategoriesForProjectKamp = [\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 8,\n    modifiedAt: null,\n    name: 'Construction',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 9,\n    modifiedAt: null,\n    name: 'Food',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 10,\n    modifiedAt: null,\n    name: 'Landscape',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 11,\n    modifiedAt: null,\n    name: 'Other',\n    type: 'questions' as ContentType,\n  },\n  {\n    createdAt: new Date('2022-12-03T18:03:51.313Z'),\n    id: 12,\n    modifiedAt: null,\n    name: 'Utilities',\n    type: 'questions' as ContentType,\n  },\n];\n\ndescribe('CategoryHorizonalList', () => {\n  // https://stackoverflow.com/a/62148101\n  beforeEach(() => {\n    window.IntersectionObserver = vi.fn().mockImplementation(function () {\n      return {\n        observe: vi.fn(),\n        unobserve: vi.fn(),\n        disconnect: vi.fn(),\n      };\n    });\n  });\n\n  it('renders each member type given', async () => {\n    const allCategories = [...allCategoriesForPreciousPlastic, ...allCategoriesForProjectKamp];\n\n    const { findAllByTestId } = render(\n      <CategoryHorizonalList\n        activeCategory={null}\n        allCategories={allCategories}\n        setActiveCategory={vi.fn()}\n      />,\n    );\n\n    const allItems = await findAllByTestId('CategoryHorizonalList-Item');\n\n    expect(allItems).toHaveLength(12);\n  });\n\n  it('orders by _created with oldest first', async () => {\n    const allCategories = [...allCategoriesForPreciousPlastic, ...allCategoriesForProjectKamp];\n\n    const { findAllByTestId } = render(\n      <CategoryHorizonalList\n        activeCategory={null}\n        allCategories={allCategories}\n        setActiveCategory={vi.fn()}\n      />,\n    );\n\n    const allItems = await findAllByTestId('CategoryHorizonalList-Item');\n\n    expect(allItems[0].title).toEqual('Machines');\n    expect(allItems[11].title).toEqual('Guides');\n  });\n\n  it('renders default category glyph when specific glyph is missing', async () => {\n    const noGlyphCategories = [\n      {\n        createdAt: new Date('2022-12-03T18:03:51.313Z'),\n        id: 13,\n        modifiedAt: null,\n        name: 'No Glphy A',\n        type: 'questions' as ContentType,\n      },\n      {\n        createdAt: new Date('2022-12-03T18:03:51.313Z'),\n        id: 14,\n        modifiedAt: null,\n        name: 'No Glphy B',\n        type: 'questions' as ContentType,\n      },\n      {\n        createdAt: new Date('2022-12-03T18:03:51.313Z'),\n        id: 15,\n        modifiedAt: null,\n        name: 'No Glphy C',\n        type: 'questions' as ContentType,\n      },\n    ];\n\n    const { findAllByTestId } = render(\n      <CategoryHorizonalList\n        activeCategory={null}\n        allCategories={noGlyphCategories}\n        setActiveCategory={vi.fn()}\n      />,\n    );\n\n    const allItems = await findAllByTestId('category-icon');\n\n    expect(allItems).toHaveLength(3);\n  });\n\n  it(\"doesn't render items when less than three at present\", () => {\n    const twoCategories = [allCategoriesForPreciousPlastic[0], allCategoriesForPreciousPlastic[1]];\n\n    const { getByTestId } = render(\n      <CategoryHorizonalList\n        activeCategory={null}\n        allCategories={twoCategories}\n        setActiveCategory={vi.fn()}\n      />,\n    );\n\n    expect(() => getByTestId('MemberTypeVerticalList-Item')).toThrow();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/CategoryHorizonalList/CategoryHorizonalList.tsx",
    "content": "import type { Category } from 'oa-shared';\nimport { Text } from 'theme-ui';\nimport { CardButton } from '../CardButton/CardButton';\nimport { getGlyph, Icon } from '../Icon/Icon';\nimport type { availableGlyphs } from '../Icon/types';\nimport { VerticalList } from '../VerticalList/VerticalList.client';\n\nexport interface IProps {\n  activeCategory: Category | null;\n  allCategories: Category[];\n  setActiveCategory: (category: Category | null) => void;\n}\n\nexport const CategoryHorizonalList = (props: IProps) => {\n  const { activeCategory, allCategories, setActiveCategory } = props;\n\n  if (!allCategories || !allCategories.length || allCategories.length < 3) {\n    return null;\n  }\n\n  const orderedCategories = allCategories\n    .slice()\n    .sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1));\n\n  const isCategorySelected = (category: Category) => {\n    return category.id === activeCategory?.id;\n  };\n\n  return (\n    <VerticalList dataCy=\"CategoryHorizonalList\">\n      {orderedCategories.map((category, index) => {\n        const isSelected = isCategorySelected(category);\n        const name = category.name;\n        const glyph = name.toLowerCase() as availableGlyphs;\n        const hasGlyph = getGlyph(glyph);\n\n        return (\n          <CardButton\n            data-cy={`CategoryHorizonalList-Item${isSelected ? '-active' : ''}`}\n            data-testid=\"CategoryHorizonalList-Item\"\n            title={name}\n            key={index}\n            onClick={() => setActiveCategory(isSelected ? null : category)}\n            extrastyles={{\n              alignItems: 'center',\n              background: 'none',\n              flexDirection: 'column',\n              justifyContent: 'center',\n              minWidth: ['80px', '100px', '130px'],\n              marginX: 1,\n              paddingY: 2,\n              textAlign: 'center',\n              width: ['80px', '100px', '130px'],\n              ...(isSelected\n                ? {\n                    borderColor: 'green',\n                    ':hover': { borderColor: 'green' },\n                  }\n                : {\n                    borderColor: 'background',\n                    ':hover': { borderColor: 'background' },\n                  }),\n            }}\n            isSelected={isSelected}\n          >\n            <Icon size={40} glyph={hasGlyph ? glyph : 'category'} />\n            <Text variant=\"quiet\" sx={{ fontSize: 1 }}>\n              {name}\n            </Text>\n          </CardButton>\n        );\n      })}\n    </VerticalList>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CharacterCount/CharacterCount.stories.tsx",
    "content": "import { CharacterCount } from './CharacterCount';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/CharacterCount',\n  component: CharacterCount,\n} as Meta<typeof CharacterCount>;\n\nconst errorValues = [\n  {\n    currentSize: 10,\n    minSize: 50,\n    maxSize: 200,\n  },\n  {\n    currentSize: 200,\n    minSize: 0,\n    maxSize: 100,\n  },\n];\n\nexport const Default: StoryFn<typeof CharacterCount> = () => (\n  <CharacterCount currentSize={0} minSize={0} maxSize={100} />\n);\n\nexport const WithValidState: StoryFn<typeof CharacterCount> = () => (\n  <CharacterCount currentSize={50} minSize={0} maxSize={100} />\n);\n\nexport const WithError: StoryFn<typeof CharacterCount> = () => (\n  <>\n    {errorValues.map((state, index) => {\n      return (\n        <CharacterCount\n          key={index}\n          currentSize={state.currentSize}\n          minSize={state.minSize}\n          maxSize={state.maxSize}\n        />\n      );\n    })}\n  </>\n);\n"
  },
  {
    "path": "packages/components/src/CharacterCount/CharacterCount.tsx",
    "content": "import { Text } from 'theme-ui';\n\nexport interface ICharacterCountProps {\n  currentSize: number;\n  minSize: number;\n  maxSize: number;\n}\n\nexport const CharacterCount = ({ currentSize, minSize, maxSize }: ICharacterCountProps) => {\n  const percentageOfMax = currentSize / maxSize;\n  const characterCountThresholds = [\n    { value: 1.0, color: 'red', font_weight: 'bold' },\n    { value: 0.95, color: 'subscribed', font_weight: 'bold' },\n    { value: 0.9, color: 'green', font_weight: 'bold' },\n  ];\n\n  let color =\n    characterCountThresholds.find((threshold) => threshold.value <= percentageOfMax)?.color ??\n    'green';\n\n  let fontWeight =\n    characterCountThresholds.find((threshold) => threshold.value <= percentageOfMax)?.font_weight ??\n    'normal';\n\n  if (currentSize < minSize) {\n    color = characterCountThresholds[0].color;\n    fontWeight = 'bold';\n  }\n\n  return (\n    <Text\n      data-cy=\"character-count\"\n      color={color}\n      ml=\"auto\"\n      sx={{\n        position: 'absolute',\n        right: 1,\n        bottom: -4,\n        alignSelf: 'flex-end',\n        fontSize: 1,\n        fontWeight,\n      }}\n    >\n      {currentSize} / {maxSize}\n    </Text>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CommentAvatar/CommentAvatar.tsx",
    "content": "import { Avatar, Image } from 'theme-ui';\n\nimport defaultBaloonUrl from '../../assets/images/author.svg';\nimport defaultProfileImage from '../../assets/images/default_member.svg';\n\ntype CommentAvatarProps = {\n  displayName?: string;\n  isCommentAuthor?: boolean;\n  photo?: string | null;\n};\n\nexport const CommentAvatar = (props: CommentAvatarProps) => {\n  const { displayName, isCommentAuthor = false, photo } = props;\n\n  const alt = displayName ? `Avatar of ${displayName}` : 'Avatar of comment author';\n\n  return (\n    <>\n      {isCommentAuthor && (\n        <Image\n          src={defaultBaloonUrl}\n          ml={1}\n          mt={-10}\n          sx={{\n            marginLeft: ['-10px', '5px'],\n            marginTop: ['-35px', '-35px'],\n            width: ['85px', '85px'],\n            zIndex: 1,\n            position: 'absolute',\n            pointerEvents: 'none',\n            maxWidth: 'none',\n          }}\n        />\n      )}\n      <Avatar\n        data-cy=\"commentAvatarImage\"\n        src={photo ?? defaultProfileImage}\n        sx={{\n          objectFit: 'cover',\n          width: ['30px', '50px'],\n          height: ['30px', '50px'],\n          ...(isCommentAuthor && {\n            zIndex: 2,\n            position: 'relative',\n          }),\n        }}\n        alt={alt}\n        loading=\"lazy\"\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CommentBody/CommentBody.tsx",
    "content": "import { useEffect, useRef, useState } from 'react';\nimport { Text } from 'theme-ui';\n\nimport { LinkifyText } from '../LinkifyText/LinkifyText';\n\ninterface IProps {\n  body: string;\n}\nconst SHORT_COMMENT = 129;\n\nexport const CommentBody = ({ body }: IProps) => {\n  const textRef = useRef<HTMLDivElement>(null);\n  const [textHeight, setTextHeight] = useState(0);\n  const [isShowMore, setShowMore] = useState(false);\n\n  useEffect(() => {\n    if (textRef.current) {\n      setTextHeight(textRef.current.scrollHeight);\n    }\n  }, [body]);\n\n  const maxHeight = isShowMore ? 'max-content' : '128px';\n\n  return (\n    <>\n      <Text\n        ref={textRef}\n        data-cy=\"comment-text\"\n        data-testid=\"commentText\"\n        sx={{\n          fontFamily: 'body',\n          lineHeight: 1.4,\n          maxHeight,\n          overflow: 'hidden',\n          wordBreak: 'break-word',\n          whiteSpace: 'pre-wrap',\n          fontSize: [3],\n        }}\n      >\n        <LinkifyText>{body.trim()}</LinkifyText>\n      </Text>\n      {textHeight > SHORT_COMMENT && (\n        <Text\n          as=\"a\"\n          onClick={() => setShowMore((prev) => !prev)}\n          sx={{\n            color: 'gray',\n            cursor: 'pointer',\n            fontSize: [3],\n          }}\n        >\n          {isShowMore ? 'Show less' : 'Show more'}\n        </Text>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CommentDisplay/CommentDisplay.stories.tsx",
    "content": "import { fakeCommentSB } from '../utils';\nimport { CommentDisplay } from './CommentDisplay';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Commenting/CommentDisplay',\n  component: CommentDisplay,\n} as Meta<typeof CommentDisplay>;\n\nconst itemType = 'CommentItem';\nconst isEditable = false;\nconst isLoggedIn = true;\nconst votedUsefulCount = 0;\nconst hasUserVotedUseful = false;\nconst mockHandleUsefulClick = async (vote: 'add' | 'delete', eventCategory = 'Comment'): Promise<void> => {\n  console.log('handleUsefulClick called with:', { vote, eventCategory });\n  return new Promise<void>((resolve) => setTimeout(() => resolve(), 300));\n};\n\n// Common useful button config\nconst usefulButtonConfig = {\n  votedUsefulCount,\n  hasUserVotedUseful,\n  isLoggedIn,\n  onUsefulClick: mockHandleUsefulClick,\n};\n\nexport const Default: StoryFn<typeof CommentDisplay> = () => {\n  const comment = fakeCommentSB();\n\n  return (\n    <CommentDisplay comment={comment} itemType={itemType} isEditable={isEditable} usefulButtonConfig={usefulButtonConfig} actions={<></>} />\n  );\n};\n\nexport const Editable: StoryFn<typeof CommentDisplay> = () => {\n  const comment = fakeCommentSB();\n\n  return <CommentDisplay comment={comment} itemType={itemType} isEditable={true} usefulButtonConfig={usefulButtonConfig} actions={<></>} />;\n};\n\nexport const Edited: StoryFn<typeof CommentDisplay> = () => {\n  const comment = fakeCommentSB({ modifiedAt: new Date() });\n\n  return (\n    <CommentDisplay comment={comment} itemType={itemType} isEditable={isEditable} usefulButtonConfig={usefulButtonConfig} actions={<></>} />\n  );\n};\n\nexport const LongText: StoryFn<typeof CommentDisplay> = () => {\n  const commentText = `Ut dignissim, odio a cursus pretium, erat ex dictum quam, a eleifend augue mauris vel metus. Suspendisse pellentesque, elit efficitur rutrum maximus, arcu enim congue ipsum, vel aliquam ipsum urna quis tellus. Mauris at imperdiet nisi. Integer at neque ex. Nullam vel ipsum sodales, porttitor nulla vitae, tincidunt est. Pellentesque vitae lectus arcu. Integer dapibus rutrum facilisis. Nullam tincidunt quam at arcu interdum, vitae egestas libero vehicula. Morbi metus tortor, dapibus id finibus ac, egestas quis leo. Phasellus scelerisque suscipit mauris sed rhoncus. In quis ultricies ipsum. Integer vitae iaculis risus, sit amet elementum augue. Pellentesque vitae sagittis erat, eget consectetur lorem.\\n\\nUt pharetra molestie quam id dictum. In molestie, arcu sit amet faucibus pulvinar, eros erat egestas leo, at molestie nunc velit a arcu. Aliquam erat volutpat. Vivamus vehicula mi sit amet nibh auctor efficitur. Duis fermentum sem et nibh facilisis, ut tincidunt sem commodo. Nullam ornare ex a elementum accumsan. Etiam a neque ut lacus suscipit blandit. Maecenas id tortor velit.\\n\\nInterdum et malesuada fames ac ante ipsum primis in faucibus. Nam ut commodo tellus. Maecenas at leo metus. Vivamus ullamcorper ex purus, volutpat auctor nunc lobortis a. Integer sit amet ornare nisi, sed ultrices enim. Pellentesque ut aliquam urna, eu fringilla ante. Nullam dui nibh, feugiat id vestibulum nec, efficitur a lorem. In vitae pellentesque tellus. Pellentesque sed odio iaculis, imperdiet turpis at, aliquam ex. Praesent iaculis bibendum nibh, vel egestas turpis ultrices ac. Praesent tincidunt libero sed gravida ornare. Aliquam vehicula risus ut molestie suscipit. Nunc erat odio, venenatis nec posuere in, placerat eget massa. Sed in ultrices ex, vel egestas quam. Integer lectus magna, ornare at nisl sed, convallis euismod enim. Cras pretium commodo arcu non bibendum.\\n\\nNullam dictum lectus felis. Duis vitae lacus vitae nisl aliquet faucibus. Integer neque lacus, dignissim sed mi et, dignissim luctus metus. Cras sollicitudin vestibulum leo, ac ultrices sapien bibendum ac. Phasellus lobortis aliquam libero eu volutpat. Donec vitae rutrum tellus. Fusce vel ante ipsum. Suspendisse mollis tempus porta. Sed a orci tempor, rhoncus tortor eu, sodales justo.`;\n  const comment = fakeCommentSB({ comment: commentText });\n\n  return (\n    <CommentDisplay comment={comment} itemType={itemType} isEditable={isEditable} usefulButtonConfig={usefulButtonConfig} actions={<></>} />\n  );\n};\n\nexport const ShortTextWithLink: StoryFn<typeof CommentDisplay> = () => {\n  const comment = fakeCommentSB({\n    comment: `Ut dignissim, odio a cursus pretium. https://example.com`,\n  });\n\n  return (\n    <CommentDisplay comment={comment} itemType={itemType} isEditable={isEditable} usefulButtonConfig={usefulButtonConfig} actions={<></>} />\n  );\n};\n\nexport const UserVotedUseful: StoryFn<typeof CommentDisplay> = () => {\n  const comment = fakeCommentSB();\n  const votedConfig = {\n    ...usefulButtonConfig,\n    hasUserVotedUseful: true,\n    votedUsefulCount: 5,\n  };\n\n  return <CommentDisplay comment={comment} itemType={itemType} isEditable={isEditable} usefulButtonConfig={votedConfig} actions={<></>} />;\n};\n"
  },
  {
    "path": "packages/components/src/CommentDisplay/CommentDisplay.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { fireEvent } from '@testing-library/react';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { AuthorsContext } from '../providers/AuthorsContext';\nimport { render } from '../test/utils';\nimport { CommentDisplay } from './CommentDisplay';\nimport { UsefulConfig } from '../UsefulStatsButton/UsefulButtonLite';\n\nvi.mock('../UsefulStatsButton/UsefulButtonLite', () => ({\n  UsefulButtonLite: ({ onUsefulClick, votedUsefulCount }: UsefulConfig) => (\n    <button data-testid=\"useful-button\" onClick={() => onUsefulClick('add')}>\n      Useful {votedUsefulCount}\n    </button>\n  ),\n}));\n\nvi.mock('../CommentAvatar/CommentAvatar', () => ({\n  CommentAvatar: ({ displayName }: any) => <div data-testid=\"comment-avatar\">{displayName}</div>,\n}));\n\nvi.mock('../CommentBody/CommentBody', () => ({\n  CommentBody: ({ body }: any) => <div data-testid=\"comment-body\">{body}</div>,\n}));\n\ndescribe('CommentDisplay', () => {\n  const mockOnUsefulClick = vi.fn(async () => Promise.resolve());\n\n  const mockComment = {\n    id: 1,\n    comment: 'Test comment',\n    createdAt: new Date('2023-01-01T00:00:00Z'),\n    modifiedAt: new Date('2023-01-01T00:00:00Z'),\n    deleted: false,\n    highlighted: false,\n    createdBy: {\n      username: 'testuser',\n      displayName: 'Test User',\n      id: 0,\n      isVerified: false,\n      isSupporter: false,\n      photo: null,\n    },\n    sourceId: 1,\n    sourceType: 'questions' as const,\n    parentId: null,\n    voteCount: 0,\n    hasVoted: false,\n  };\n\n  const mockUsefulButtonConfig = {\n    hasUserVotedUseful: false,\n    votedUsefulCount: 5,\n    isLoggedIn: true,\n    onUsefulClick: mockOnUsefulClick,\n  };\n\n  const mockAuthorsContextValue = {\n    authors: [0],\n  };\n  const renderWithAuthorsContext = (ui: React.ReactElement) => {\n    return render(<AuthorsContext.Provider value={mockAuthorsContextValue}>{ui}</AuthorsContext.Provider>);\n  };\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n  it('renders comment body and avatar', () => {\n    const { getByTestId, getAllByTestId } = renderWithAuthorsContext(\n      <CommentDisplay\n        comment={mockComment}\n        itemType=\"CommentItem\"\n        isEditable={false}\n        actions={<></>}\n        usefulButtonConfig={mockUsefulButtonConfig}\n      />,\n    );\n\n    expect(getByTestId('comment-body')).toBeInTheDocument();\n    expect(getAllByTestId('comment-avatar').length).toBeGreaterThanOrEqual(1);\n    expect(getByTestId('useful-button')).toBeInTheDocument();\n  });\n\n  it('calls onUsefulClick when useful button is clicked', () => {\n    const { getByTestId } = render(\n      <CommentDisplay\n        comment={mockComment}\n        itemType=\"CommentItem\"\n        isEditable={false}\n        actions={<></>}\n        usefulButtonConfig={mockUsefulButtonConfig}\n      />,\n    );\n\n    fireEvent.click(getByTestId('useful-button'));\n    expect(mockOnUsefulClick).toHaveBeenCalledWith('add');\n  });\n\n  it('increments useful count when useful button is clicked', () => {\n    let count = 5;\n    const handleUsefulClick = vi.fn(() => {\n      count += 1;\n      return Promise.resolve();\n    });\n\n    const { getByTestId, rerender } = render(\n      <CommentDisplay\n        comment={mockComment}\n        itemType=\"CommentItem\"\n        isEditable={false}\n        actions={<></>}\n        usefulButtonConfig={{\n          ...mockUsefulButtonConfig,\n          votedUsefulCount: count,\n          onUsefulClick: handleUsefulClick,\n        }}\n      />,\n    );\n\n    const button = getByTestId('useful-button');\n    expect(button).toHaveTextContent('Useful 5');\n\n    fireEvent.click(button);\n    expect(handleUsefulClick).toHaveBeenCalledWith('add');\n\n    rerender(\n      <CommentDisplay\n        comment={mockComment}\n        itemType=\"CommentItem\"\n        isEditable={false}\n        actions={<></>}\n        usefulButtonConfig={{\n          ...mockUsefulButtonConfig,\n          votedUsefulCount: count,\n          onUsefulClick: handleUsefulClick,\n        }}\n      />,\n    );\n\n    expect(getByTestId('useful-button')).toHaveTextContent('Useful 6');\n  });\n});\n"
  },
  {
    "path": "packages/components/src/CommentDisplay/CommentDisplay.tsx",
    "content": "import type { Comment } from 'oa-shared';\nimport { useContext } from 'react';\nimport { Box, Flex, Text } from 'theme-ui';\nimport { CommentAvatar } from '../CommentAvatar/CommentAvatar';\nimport { CommentBody } from '../CommentBody/CommentBody';\nimport { DisplayDate } from '../DisplayDate/DisplayDate';\nimport { AuthorsContext } from '../providers/AuthorsContext';\nimport { UsefulButtonLite, UsefulConfig } from '../UsefulStatsButton/UsefulButtonLite';\nimport { Username } from '../Username/Username';\n\nexport interface IProps {\n  comment: Comment;\n  itemType: 'ReplyItem' | 'CommentItem';\n  isEditable: boolean | undefined;\n  actions: React.ReactNode;\n  usefulButtonConfig: UsefulConfig;\n}\n\nconst DELETED_COMMENT = 'The original comment got deleted';\n\nexport const CommentDisplay = (props: IProps) => {\n  const { comment, actions, usefulButtonConfig } = props;\n\n  const { authors } = useContext(AuthorsContext);\n  const border = `${comment.highlighted ? '2px dashed black' : 'none'}`;\n\n  if (comment.deleted) {\n    return (\n      <Box\n        sx={{\n          marginBottom: 2,\n          border,\n        }}\n        data-cy=\"deletedComment\"\n      >\n        <Text sx={{ color: 'grey' }}>[{DELETED_COMMENT}]</Text>\n      </Box>\n    );\n  }\n\n  if (!comment.deleted) {\n    return (\n      <Flex\n        sx={{\n          gap: 2,\n          flexGrow: 1,\n          border,\n        }}\n        data-cy={comment.highlighted ? 'highlighted-comment' : ''}\n      >\n        <Box\n          data-cy=\"commentAvatar\"\n          data-testid=\"commentAvatar\"\n          sx={{\n            flexDirection: 'column',\n            position: 'relative',\n            display: ['none', 'inline-block'],\n          }}\n        >\n          <CommentAvatar\n            displayName={comment.createdBy?.displayName}\n            photo={comment.createdBy?.photo?.publicUrl}\n            isCommentAuthor={\n              comment.createdBy?.id ? authors.includes(comment.createdBy?.id) : false\n            }\n          />\n        </Box>\n\n        <Flex sx={{ flexDirection: 'column', flex: 1 }}>\n          <Flex\n            sx={{\n              justifyContent: 'space-between',\n              flexDirection: 'column',\n              gap: 1,\n            }}\n          >\n            <Flex\n              sx={{\n                gap: 2,\n                flexDirection: 'row',\n                justifyContent: 'space-between',\n              }}\n            >\n              <Flex sx={{ alignItems: 'center', gap: 2 }}>\n                <Box sx={{ display: ['flex', 'none'], position: 'relative' }}>\n                  <CommentAvatar\n                    displayName={comment.createdBy?.displayName}\n                    photo={comment.createdBy?.photo?.publicUrl}\n                    isCommentAuthor={\n                      comment.createdBy?.id ? authors.includes(comment.createdBy?.id) : false\n                    }\n                  />\n                </Box>\n                {comment.createdBy && <Username user={comment.createdBy} />}\n                <Text sx={{ fontSize: 1, color: 'darkGrey' }}>\n                  <DisplayDate createdAt={comment.createdAt} showLabel={false} />\n                </Text>\n              </Flex>\n\n              {actions && <Flex sx={{ alignItems: 'center', gap: 1 }}>{actions}</Flex>}\n            </Flex>\n            <Flex\n              sx={{\n                flexDirection: 'column',\n              }}\n            >\n              <CommentBody body={comment.comment} />\n              <UsefulButtonLite {...usefulButtonConfig} />\n            </Flex>\n          </Flex>\n        </Flex>\n      </Flex>\n    );\n  }\n};\n"
  },
  {
    "path": "packages/components/src/CommentsTitle/CommentsTitle.stories.tsx",
    "content": "import { CommentsTitle } from './CommentsTitle';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { Comment } from 'oa-shared';\n\nexport default {\n  title: 'Commenting/CommentsTitle',\n  component: CommentsTitle,\n} as Meta<typeof CommentsTitle>;\n\nexport const NoComments: StoryFn<typeof CommentsTitle> = () => <CommentsTitle comments={[]} />;\n\nexport const OneComment: StoryFn<typeof CommentsTitle> = () => {\n  const comment = {} as Comment;\n\n  return <CommentsTitle comments={[comment]} />;\n};\n\nexport const MultipleComments: StoryFn<typeof CommentsTitle> = () => {\n  const comment = {} as Comment;\n  return <CommentsTitle comments={[comment, comment, comment]} />;\n};\n"
  },
  {
    "path": "packages/components/src/CommentsTitle/CommentsTitle.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { COMMENTS, CommentsTitle, NO_COMMENTS, ONE_COMMENT } from './CommentsTitle';\n\nimport type { Comment } from 'oa-shared';\n\ndescribe('CommentsTitle', () => {\n  it('renders correctly when there are zero comments', () => {\n    const { getByText } = render(<CommentsTitle comments={[]} />);\n\n    expect(getByText(NO_COMMENTS)).toBeInTheDocument();\n  });\n\n  it('renders correctly when there is one comment', () => {\n    const comment = {} as Comment;\n    const { getByText } = render(<CommentsTitle comments={[comment]} />);\n\n    expect(getByText(ONE_COMMENT)).toBeInTheDocument();\n  });\n\n  it('renders correctly when there are multiple comments', () => {\n    const comment = {} as Comment;\n    const { getByText } = render(<CommentsTitle comments={[comment, comment, comment]} />);\n\n    expect(getByText(`3 ${COMMENTS}`)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/CommentsTitle/CommentsTitle.tsx",
    "content": "import type { Comment } from 'oa-shared';\nimport { useMemo } from 'react';\nimport { Heading } from 'theme-ui';\n\nexport const NO_COMMENTS = 'Start the discussion';\nexport const ONE_COMMENT = '1 Comment';\nexport const COMMENTS = 'Comments';\n\nexport interface IProps {\n  comments: Comment[];\n}\n\nexport const CommentsTitle = ({ comments }: IProps) => {\n  const title = useMemo(() => {\n    const commentCount =\n      comments.filter((x) => !x.deleted).length +\n      comments.flatMap((x) => x.replies).filter((x) => !!x).length;\n\n    if (commentCount === 0) {\n      return NO_COMMENTS;\n    }\n    if (commentCount === 1) {\n      return ONE_COMMENT;\n    }\n\n    return `${commentCount} ${COMMENTS}`;\n  }, [comments]);\n\n  return (\n    <Heading as=\"h3\" variant=\"h3\" data-cy=\"DiscussionTitle\" sx={{ whiteSpace: 'nowrap' }}>\n      {title}\n    </Heading>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ConfirmModal/ConfirmModal.stories.tsx",
    "content": "import { ConfirmModal } from './ConfirmModal';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/ConfirmModal',\n  component: ConfirmModal,\n} as Meta<typeof ConfirmModal>;\n\nexport const Default: StoryFn<typeof ConfirmModal> = () => (\n  <ConfirmModal\n    message=\"Are you sure you want to delete this item?\"\n    confirmButtonText=\"Delete\"\n    isOpen={true}\n    handleCancel={() => null}\n    handleConfirm={() => null}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/ConfirmModal/ConfirmModal.tsx",
    "content": "import { Flex, Text } from 'theme-ui';\n\nimport { Button } from '../Button/Button';\nimport { Modal } from '../Modal/Modal';\n\nexport interface Props {\n  message: string;\n  confirmButtonText: string;\n  isOpen: boolean;\n  handleCancel: () => void;\n  handleConfirm: () => void;\n  width?: number;\n  cancelVariant?: 'outline' | 'destructive' | 'primary';\n  confirmVariant?: 'outline' | 'destructive' | 'primary';\n}\n\nexport const ConfirmModal = (props: Props) => {\n  const {\n    message,\n    confirmButtonText,\n    isOpen,\n    width,\n    confirmVariant = 'primary',\n    cancelVariant = 'outline',\n  } = props;\n\n  return (\n    <Modal onDismiss={() => props?.handleCancel} isOpen={isOpen} width={width}>\n      <Flex\n        data-cy=\"Confirm.modal: Modal\"\n        sx={{\n          alignItems: 'flex-start',\n          flexDirection: 'column',\n          padding: 1,\n          gap: 2,\n          justifyContent: 'flex-start',\n        }}\n      >\n        <Text sx={{ alignSelf: 'stretch', fontWeight: 'bold' }}>{message}</Text>\n        <Flex sx={{ gap: 2, flexWrap: 'wrap' }}>\n          <Button\n            type=\"button\"\n            variant={cancelVariant}\n            data-cy=\"Confirm.modal: Cancel\"\n            onClick={() => props?.handleCancel()}\n          >\n            Cancel\n          </Button>\n\n          <Button\n            type=\"button\"\n            aria-label={`Confirm ${confirmButtonText} action`}\n            data-cy=\"Confirm.modal: Confirm\"\n            variant={confirmVariant}\n            onClick={() => props?.handleConfirm()}\n          >\n            {confirmButtonText}\n          </Button>\n        </Flex>\n      </Flex>\n    </Modal>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ContentStatistics/ContentStatistics.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\n\nimport { ContentStatistics } from './ContentStatistics';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/ContentStatistics',\n  component: ContentStatistics,\n} as Meta<typeof ContentStatistics>;\n\nexport const Default: StoryFn<typeof ContentStatistics> = () => (\n  <ContentStatistics\n    statistics={[\n      {\n        icon: 'show',\n        label: `${faker.number.int()} views`,\n        stat: faker.number.int(),\n      },\n      {\n        icon: 'star',\n        label: `${faker.number.int()} useful`,\n        stat: faker.number.int(),\n      },\n      {\n        icon: 'comment',\n        label: `${faker.number.int()} comments`,\n        stat: faker.number.int(),\n      },\n      {\n        icon: 'update',\n        label: `${faker.number.int()} steps`,\n        stat: faker.number.int(),\n      },\n    ]}\n  />\n);\n\nexport const SingleCount: StoryFn<typeof ContentStatistics> = () => (\n  <ContentStatistics\n    statistics={[\n      {\n        icon: 'show',\n        label: '1 view',\n        stat: 1,\n      },\n      {\n        icon: 'star',\n        label: '1 useful',\n        stat: 1,\n      },\n      {\n        icon: 'comment',\n        label: '1 comment',\n        stat: 1,\n      },\n      {\n        icon: 'update',\n        label: '1 step',\n        stat: 1,\n      },\n    ]}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/ContentStatistics/ContentStatistics.tsx",
    "content": "import React, { cloneElement, isValidElement, useCallback, useState } from 'react';\nimport { Flex, Text } from 'theme-ui';\n\nimport { Button } from '../Button/Button';\nimport { StatisticsList } from './ContentStatisticsList';\n\nimport type { IStatistic } from './types';\n\nexport interface IProps {\n  statistics: IStatistic[];\n  alwaysShow?: boolean;\n}\n\nexport const ContentStatistics = ({ statistics, alwaysShow }: IProps) => {\n  const [showStats, setShowStats] = useState(false);\n  const [activeModal, setActiveModal] = useState<React.ReactNode | null>(null);\n\n  const handleShowStats = () => {\n    setShowStats(!showStats);\n  };\n\n  const handleOpenModal = useCallback(async (stat: IStatistic) => {\n    if (!stat.modalComponent) return;\n\n    let data = undefined;\n    if (stat.onOpen) {\n      try {\n        data = await stat.onOpen();\n      } catch (error) {\n        console.error('Error loading modal data:', error);\n      }\n    }\n\n    const modalElement = stat.modalComponent(data);\n    if (isValidElement(modalElement)) {\n      setActiveModal(\n        cloneElement(modalElement, {\n          onClose: () => setActiveModal(null),\n        }),\n      );\n    } else {\n      setActiveModal(null);\n    }\n  }, []);\n\n  const visible = showStats || alwaysShow === true;\n\n  return (\n    <Flex\n      data-cy=\"ContentStatistics\"\n      sx={{\n        alignItems: ['flex-start', 'center', 'center'],\n        gap: 2,\n        flexDirection: alwaysShow ? 'row' : ['column', 'row', 'row'],\n        pl: alwaysShow ? 0 : [2, 0, 0],\n        flexWrap: 'wrap',\n        height: '44px', // fix layout shift due to useful + follow button loading\n      }}\n    >\n      <Flex\n        sx={{\n          flexDirection: 'row',\n          alignItems: 'center',\n          justifyContent: 'space-between',\n          display: [alwaysShow ? 'none' : 'flex', 'none'],\n          width: '100%',\n        }}\n        onClick={handleShowStats}\n      >\n        <Text sx={{ fontSize: '13px' }}>{showStats ? '' : 'More Information'}</Text>\n        <Button\n          type=\"button\"\n          variant=\"subtle\"\n          showIconOnly\n          icon={showStats ? 'chevron-up' : 'chevron-down'}\n          small\n          sx={{\n            borderWidth: 0,\n            '&:hover': { bg: 'white' },\n            '&:active': { bg: 'white' },\n          }}\n        />\n      </Flex>\n\n      <StatisticsList statistics={statistics} visible={visible} onOpenModal={handleOpenModal} />\n\n      {activeModal && activeModal}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ContentStatistics/ContentStatisticsList.tsx",
    "content": "import { Flex, Text } from 'theme-ui';\n\nimport { Icon } from '../Icon/Icon';\nimport { Tooltip } from '../Tooltip/Tooltip';\n\nimport type { IStatistic } from './types';\n\ninterface Props {\n  statistics: IStatistic[];\n  visible: boolean;\n  onOpenModal: (stat: IStatistic) => Promise<void>;\n}\n\nexport const StatisticsList = ({ statistics, visible, onOpenModal }: Props) => {\n  return (\n    <>\n      {statistics.map((stat, idx) => {\n        return (\n          <StatisticItem key={idx} statistic={stat} visible={visible} onOpenModal={onOpenModal} />\n        );\n      })}\n    </>\n  );\n};\n\nconst StatisticItem = ({\n  statistic,\n  visible,\n  onOpenModal,\n}: {\n  statistic: IStatistic;\n  visible: boolean;\n  onOpenModal: (stat: IStatistic) => Promise<void>;\n}) => {\n  const displayModal = !!statistic.modalComponent && statistic.stat;\n  // capitalize first letter of label\n  const label = statistic.label.slice(0, 1).toUpperCase() + statistic.label.slice(1).toLowerCase();\n\n  return (\n    <>\n      <Flex\n        sx={{\n          alignItems: 'center',\n          fontSize: '1',\n          paddingX: 2,\n          display: [visible ? 'flex' : 'none', 'flex', 'flex'],\n          cursor: displayModal ? 'pointer' : 'default',\n        }}\n        onClick={() => displayModal && onOpenModal(statistic)}\n        data-testid={`ContentStatistics-${statistic.icon}`}\n        data-cy={`ContentStatistics-${statistic.label}`}\n        data-tooltip-id={statistic.label}\n        data-tooltip-content={label}\n      >\n        <Icon glyph={statistic.icon} mr={1} size=\"sm\" opacity=\"0.5\" />\n        <Text\n          sx={{\n            textDecoration: displayModal ? 'underline' : 'none',\n          }}\n        >\n          {statistic.stat}\n        </Text>\n      </Flex>\n      <Tooltip id={statistic.label} />\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ContentStatistics/types.ts",
    "content": "import type { availableGlyphs } from '../Icon/types';\n\nexport type IStatistic = {\n  icon: availableGlyphs;\n  label: string;\n  stat: number;\n  modalComponent?: (data?: any) => React.ReactElement<{ onClose?: () => void }>;\n  onOpen?: () => Promise<any>;\n};\n"
  },
  {
    "path": "packages/components/src/CreateComment/CreateComment.css",
    "content": "/* https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ */\n.grow-wrap {\n  /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */\n  display: grid;\n}\n.grow-wrap::after {\n  /* Note the weird space! Needed to preventy jumpy behavior */\n  content: attr(data-replicated-value) \" \";\n\n  /* This is how textarea text behaves */\n  white-space: pre-wrap;\n\n  /* Hidden from view, clicks, and screen readers */\n  visibility: hidden;\n}\n.grow-wrap > textarea {\n  /* You could leave this, but after a user resizes, then it ruins the auto sizing */\n  resize: none;\n\n  /* Firefox shows scrollbar on growth, you can hide like this. */\n  overflow: hidden;\n}\n.grow-wrap > textarea,\n.grow-wrap::after {\n  /* Identical styling required!! */\n  background: none;\n  resize: none;\n  padding: 15px;\n  word-wrap: anywhere;\n  border-color: transparent;\n  font-size: 12px;\n\n  /* Place on top of each other */\n  grid-area: 1 / 1 / 2 / 2;\n}\n.grow-wrap > textarea:focus {\n  border-color: transparent;\n}\n\n.grow-wrap.value-set > textarea,\n.grow-wrap.value-set::after {\n  /* Identical styling required!! */\n  padding-bottom: 27px !important;\n}\n"
  },
  {
    "path": "packages/components/src/CreateComment/CreateComment.stories.tsx",
    "content": "import { useState } from 'react';\nimport { faker } from '@faker-js/faker';\n\nimport { CreateComment } from './CreateComment';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { ProfileType } from 'oa-shared';\n\nexport default {\n  title: 'Commenting/CreateComment',\n  component: CreateComment,\n} as Meta<typeof CreateComment>;\n\nconst member: ProfileType = {\n  name: 'member',\n  description: 'A member profile',\n  displayName: 'Member',\n  id: 2,\n  imageUrl: faker.image.avatar(),\n  mapPinName: 'Member',\n  order: 1,\n  smallImageUrl: faker.image.avatar(),\n  isSpace: false,\n};\n\nexport const Default: StoryFn<typeof CreateComment> = () => {\n  const [comment, setComment] = useState('');\n  return (\n    <CreateComment\n      comment={comment}\n      onChange={setComment}\n      onSubmit={() => null}\n      profileType={member}\n      maxLength={1000}\n      isLoggedIn={true}\n    />\n  );\n};\n\nexport const LoggedOut: StoryFn<typeof CreateComment> = () => {\n  const [comment, setComment] = useState('');\n  return (\n    <CreateComment\n      comment={comment}\n      onChange={setComment}\n      onSubmit={() => null}\n      profileType={member}\n      maxLength={123}\n      isLoggedIn={false}\n    />\n  );\n};\n\nexport const WithLongComment: StoryFn<typeof CreateComment> = () => {\n  const [comment, setComment] =\n    useState(`Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque sodales sodales nunc, ut pharetra magna. Nulla malesuada sodales finibus. In condimentum nulla et nunc convallis, ac interdum turpis convallis. Praesent nec ipsum et lacus rhoncus facilisis id ac enim. Nunc cursus facilisis libero non blandit. Maecenas in mauris vel odio sollicitudin rutrum. Sed suscipit fermentum mi, nec faucibus urna mattis quis. In euismod mi ut lorem imperdiet semper.\n\nVestibulum mi felis, blandit ut mollis sed, consequat et massa. Vivamus vitae sem mattis, scelerisque odio ac, convallis lectus. Duis arcu velit, euismod et leo eget, iaculis molestie est. Phasellus facilisis in metus id sodales. Integer vestibulum interdum euismod. Fusce in lectus non lorem accumsan condimentum a non enim. Quisque porta fermentum facilisis.\n\nDonec ut tristique sapien. Morbi consectetur, elit sit amet molestie fringilla, eros odio rutrum est, sed dictum tortor massa et mi. Donec nec justo lorem. Suspendisse potenti. Proin molestie dolor sed ipsum porta sollicitudin eget quis mauris. Integer quis nisl magna. Vivamus convallis nunc ac mauris interdum tempor. Nullam elit velit, sollicitudin et porttitor sit amet, ultricies a sem. Mauris erat orci, sodales eget enim a, semper porta enim. Integer at mi molestie enim consectetur laoreet. Mauris diam ligula, lobortis nec tortor eget, pulvinar finibus dolor. Fusce ac tincidunt leo. Pellentesque elementum, tellus a feugiat commodo, leo risus ornare nulla, ut interdum justo dolor non turpis. Aliquam semper tortor quis nunc posuere tincidunt.\n\nDonec dapibus leo quis sagittis fringilla. Phasellus ut imperdiet sapien. Nullam posuere elementum odio, a condimentum velit. Integer diam lacus, iaculis eu faucibus eu, iaculis ac eros. Suspendisse accumsan accumsan congue. Vivamus feugiat mi quis massa convallis, sed vestibulum elit volutpat. Suspendisse in elit arcu. Cras fermentum condimentum odio, et ultrices urna auctor et. Ut orci metus, sagittis sit amet sollicitudin in, sagittis sed metus.`);\n\n  return (\n    <CreateComment\n      comment={comment}\n      onChange={setComment}\n      onSubmit={() => null}\n      profileType={member}\n      maxLength={12300}\n      isLoggedIn={true}\n    />\n  );\n};\n\nexport const WithCustomPlaceholder: StoryFn<typeof CreateComment> = () => {\n  const [comment, setComment] = useState('');\n\n  return (\n    <CreateComment\n      comment={comment}\n      placeholder=\"Custom placeholder\"\n      onChange={setComment}\n      onSubmit={() => null}\n      profileType={member}\n      maxLength={12300}\n      isLoggedIn={true}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CreateComment/CreateComment.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { faker } from '@faker-js/faker';\nimport { fireEvent } from '@testing-library/react';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { CreateComment } from './CreateComment';\n\nimport type { ProfileType } from 'oa-shared';\n\nconst member: ProfileType = {\n  name: 'member',\n  description: 'A member profile',\n  displayName: 'Member',\n  id: 2,\n  imageUrl: faker.image.avatar(),\n  mapPinName: 'Member',\n  order: 1,\n  smallImageUrl: faker.image.avatar(),\n  isSpace: false,\n};\n\ndescribe('CreateComment Component', () => {\n  const mockOnSubmit = vi.fn();\n  const mockOnChange = vi.fn();\n\n  it('renders correctly when logged in', () => {\n    const screen = render(\n      <CreateComment\n        maxLength={100}\n        isLoggedIn={true}\n        comment=\"\"\n        onSubmit={mockOnSubmit}\n        onChange={mockOnChange}\n      />,\n    );\n    expect(screen.getByPlaceholderText('Leave your questions or feedback...')).toBeInTheDocument();\n    expect(screen.queryByText('Login to leave a comment')).toBeNull();\n  });\n\n  it('renders login prompt when not logged in', () => {\n    const screen = render(\n      <CreateComment\n        maxLength={100}\n        isLoggedIn={false}\n        comment=\"\"\n        onSubmit={mockOnSubmit}\n        onChange={mockOnChange}\n      />,\n    );\n    expect(screen.getByText('Hi there! Login to leave a comment')).toBeInTheDocument();\n    expect(screen.queryByPlaceholderText('Leave your questions or feedback...')).toBeNull();\n  });\n\n  it('enables submit button when comment is entered and user is logged in', () => {\n    const screen = render(\n      <CreateComment\n        maxLength={100}\n        isLoggedIn={true}\n        comment=\"Test comment\"\n        onSubmit={mockOnSubmit}\n        onChange={mockOnChange}\n      />,\n    );\n    expect(screen.getByTestId('send-comment-button')).not.toBeDisabled();\n  });\n\n  it('disables submit button when no comment is entered', () => {\n    const screen = render(\n      <CreateComment\n        maxLength={100}\n        isLoggedIn={true}\n        comment=\"\"\n        onSubmit={mockOnSubmit}\n        onChange={mockOnChange}\n      />,\n    );\n    expect(screen.getByTestId('send-comment-button')).toBeDisabled();\n  });\n\n  it('handles user input in textarea', () => {\n    const screen = render(\n      <CreateComment\n        maxLength={100}\n        isLoggedIn={true}\n        comment=\"\"\n        onSubmit={mockOnSubmit}\n        onChange={mockOnChange}\n      />,\n    );\n    const textarea = screen.getByPlaceholderText('Leave your questions or feedback...');\n    fireEvent.change(textarea, { target: { value: 'New comment' } });\n    expect(mockOnChange).toHaveBeenCalledWith('New comment');\n  });\n\n  it('calls onSubmit when the submit button is clicked', () => {\n    const screen = render(\n      <CreateComment\n        maxLength={100}\n        isLoggedIn={true}\n        comment=\"Test comment\"\n        onSubmit={mockOnSubmit}\n        onChange={mockOnChange}\n      />,\n    );\n    const button = screen.getByTestId('send-comment-button');\n    fireEvent.click(button);\n    expect(mockOnSubmit).toHaveBeenCalledWith('Test comment');\n  });\n\n  it('renders with custom placeholder', () => {\n    const screen = render(\n      <CreateComment\n        comment={''}\n        placeholder=\"Custom placeholder\"\n        onChange={vi.fn()}\n        onSubmit={() => null}\n        profileType={member}\n        maxLength={12300}\n        isLoggedIn={true}\n      />,\n    );\n\n    expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/CreateComment/CreateComment.tsx",
    "content": "import type { ProfileType } from 'oa-shared';\nimport { useState } from 'react';\nimport { Box, Button, Flex, Image, Text, Textarea } from 'theme-ui';\nimport sendMobile from '../../assets/icons/contact.svg';\nimport { MemberBadge } from '../MemberBadge/MemberBadge';\nimport { ReturnPathLink } from '../ReturnPathLink/ReturnPathLink';\n\nimport './CreateComment.css';\n\nexport interface Props {\n  maxLength: number;\n  isLoggedIn: boolean;\n  isLoading?: boolean;\n  isReply?: boolean;\n  onSubmit: (value: string) => void;\n  onChange: (value: string) => void;\n  comment: string;\n  placeholder?: string;\n  profileType?: ProfileType;\n  buttonLabel?: string;\n}\n\nexport const CreateComment = (props: Props) => {\n  const [textareaIsFocussed, setTextareaIsFocussed] = useState<boolean>(false);\n\n  const { comment, isLoggedIn, isReply, maxLength, onSubmit, isLoading } = props;\n  const placeholder = props.placeholder || 'Leave your questions or feedback...';\n  const buttonLabel = props.buttonLabel ?? 'Leave a comment';\n\n  const onChange = ({ parentNode, value }: HTMLTextAreaElement) => {\n    (parentNode! as HTMLDivElement).dataset.replicatedValue = value;\n    props?.onChange(value);\n  };\n\n  const commentIsActive = comment.length > 0 || textareaIsFocussed;\n  const onClick = () => {\n    !isLoading && onSubmit(comment);\n  };\n\n  return (\n    <Flex data-target=\"create-comment-container\" sx={{ gap: 2 }}>\n      <Box\n        sx={{\n          lineHeight: 0,\n          display: ['none', 'block'],\n          flexShrink: 0,\n        }}\n      >\n        <MemberBadge profileType={props.profileType} useLowDetailVersion />\n      </Box>\n      <Box\n        sx={{\n          display: 'block',\n          background: 'white',\n          flex: 1,\n          marginLeft: [0, 3],\n          borderRadius: 1,\n          position: 'relative',\n          width: 'min-content',\n          '&:before': {\n            display: ['none', 'block'],\n            content: '\"\"',\n            position: 'absolute',\n            borderWidth: '1em 1em',\n            borderStyle: 'solid',\n            borderColor: 'transparent white transparent transparent',\n            margin: '.5em -2em',\n          },\n        }}\n      >\n        {!isLoggedIn && <LoginPrompt />}\n        {isLoggedIn && (\n          <Flex sx={{ flexDirection: 'column' }}>\n            <Box className={`grow-wrap ${commentIsActive ? 'value-set' : ''}`}>\n              <Textarea\n                value={comment}\n                maxLength={maxLength}\n                onChange={(event) => {\n                  onChange && onChange(event.target);\n                }}\n                aria-label=\"Comment\"\n                data-cy={isReply ? 'reply-form' : 'comments-form'}\n                placeholder={placeholder}\n                rows={2}\n                sx={{ padding: 2 }}\n                onFocus={() => setTextareaIsFocussed(true)}\n                onBlur={() => setTextareaIsFocussed(false)}\n              />\n            </Box>\n            <Text\n              sx={{\n                fontSize: 1,\n                display: commentIsActive ? 'flex' : 'none',\n                alignSelf: 'flex-end',\n                padding: 2,\n              }}\n            >\n              {comment.length}/{maxLength}\n            </Text>\n          </Flex>\n        )}\n      </Box>\n      <Flex\n        sx={{\n          alignSelf: 'flex-end',\n          height: ['40px', '52px'],\n          width: ['40px', 'auto'],\n        }}\n      >\n        <Button\n          data-cy={isReply ? 'reply-submit' : 'comment-submit'}\n          data-testid=\"send-comment-button\"\n          disabled={!comment.trim() || !isLoggedIn || isLoading}\n          variant=\"primary\"\n          onClick={onClick}\n          sx={{\n            height: ['40px', '100%'],\n            width: ['40px', 'auto'],\n            padding: [0, 1],\n          }}\n        >\n          {isLoading && 'Loading...'}\n          {!isLoading && (\n            <>\n              <Text sx={{ display: ['none', 'block'] }}>{buttonLabel}</Text>\n              <Image\n                src={sendMobile}\n                sx={{\n                  display: ['block', 'none'],\n                  width: '22px',\n                  margin: 'auto',\n                }}\n              />\n            </>\n          )}\n        </Button>\n      </Flex>\n    </Flex>\n  );\n};\n\nconst LoginPrompt = () => {\n  return (\n    <Box sx={{ padding: [3, 4] }}>\n      <Text data-cy=\"comments-login-prompt\">\n        <ReturnPathLink\n          to=\"/sign-in\"\n          style={{\n            textDecoration: 'underline',\n            color: 'inherit',\n          }}\n        >\n          Hi there! Login to leave a comment\n        </ReturnPathLink>\n      </Text>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CreateReply/CreateReply.stories.tsx",
    "content": "import { CreateReply } from './CreateReply';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Commenting/CreateReply',\n  component: CreateReply,\n} as Meta<typeof CreateReply>;\n\nexport const Default: StoryFn<typeof CreateReply> = () => {\n  return (\n    <CreateReply\n      commentId={'23543bh'}\n      isLoggedIn={false}\n      maxLength={75}\n      onSubmit={() => Promise.resolve()}\n    />\n  );\n};\n\nexport const LoggedIn: StoryFn<typeof CreateReply> = () => {\n  return (\n    <CreateReply\n      commentId={'23543bh'}\n      isLoggedIn={true}\n      maxLength={1000}\n      onSubmit={() => Promise.resolve()}\n    />\n  );\n};\n\nexport const LoggedInWithError: StoryFn<typeof CreateReply> = () => {\n  return (\n    <CreateReply\n      commentId={'23543bh'}\n      isLoggedIn={true}\n      maxLength={1000}\n      onSubmit={async () => {\n        return Promise.reject(new Error('Error!'));\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/CreateReply/CreateReply.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { fireEvent, waitFor } from '@testing-library/react';\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { CreateReply } from './CreateReply';\n\ndescribe('CreateReply', () => {\n  it('when logged out shows the login message', () => {\n    const { getByText } = render(\n      <CreateReply\n        commentId=\"23543bh\"\n        isLoggedIn={false}\n        maxLength={75}\n        onSubmit={() => Promise.resolve()}\n      />,\n    );\n\n    expect(getByText('to leave a comment', { exact: false })).toBeInTheDocument();\n  });\n\n  it('when logged in shows the login message', () => {\n    const screen = render(\n      <CreateReply\n        commentId=\"23543bh\"\n        isLoggedIn={true}\n        maxLength={1000}\n        onSubmit={() => Promise.resolve()}\n      />,\n    );\n\n    const textarea = screen.getByPlaceholderText('Leave your question', {\n      exact: false,\n    });\n\n    expect(textarea).toBeInTheDocument();\n  });\n\n  it('clears the field after successful submission', () => {\n    const screen = render(\n      <CreateReply\n        commentId=\"23543bh\"\n        isLoggedIn={true}\n        maxLength={1000}\n        onSubmit={() => Promise.resolve()}\n      />,\n    );\n\n    const emptyTextArea = screen.getByPlaceholderText('Leave your question', {\n      exact: false,\n    });\n    fireEvent.change(emptyTextArea, { target: { value: '123' } });\n    const withText = screen.getByText('123', {\n      exact: false,\n    });\n    expect(withText).toBeInTheDocument();\n\n    const submitButton = screen.getByText('Leave a reply');\n    fireEvent.click(submitButton);\n    expect(emptyTextArea).toBeInTheDocument();\n  });\n\n  it('handles an error in the onSubmit prop', async () => {\n    const screen = render(\n      <CreateReply\n        commentId=\"23543bh\"\n        isLoggedIn={true}\n        maxLength={1000}\n        onSubmit={async () => {\n          return Promise.reject(new Error('Error!'));\n        }}\n      />,\n    );\n\n    const emptyTextArea = screen.getByPlaceholderText('Leave your question', {\n      exact: false,\n    });\n    fireEvent.change(emptyTextArea, {\n      target: { value: 'A comment for this field' },\n    });\n\n    const submitButton = screen.getByText('Leave a reply');\n\n    fireEvent.click(submitButton);\n\n    await waitFor(() => {\n      expect(\n        screen.getByText('Unable to leave a comment at this time. Please try again later.'),\n      ).toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/components/src/CreateReply/CreateReply.tsx",
    "content": "import { useState } from 'react';\nimport { Alert, Box } from 'theme-ui';\n\nimport { CreateComment } from '../CreateComment/CreateComment';\n\nexport interface Props {\n  commentId: string;\n  isLoggedIn: boolean;\n  maxLength: number;\n  onSubmit: (_id: string, reply: string) => Promise<void>;\n}\n\nexport const CreateReply = (props: Props) => {\n  const [reply, setReply] = useState<string>('');\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [isError, setIsError] = useState<boolean>(false);\n  const { commentId, isLoggedIn, maxLength, onSubmit } = props;\n\n  const handleSubmit = async () => {\n    setIsLoading(true);\n    try {\n      await onSubmit(commentId, reply);\n      setReply('');\n      setIsLoading(false);\n    } catch (_) {\n      // Swallow the error for now\n      setIsLoading(false);\n      setIsError(true);\n    }\n  };\n\n  return (\n    <Box\n      sx={{\n        background: 'softblue',\n        borderRadius: 2,\n        marginBottom: 3,\n        padding: 3,\n      }}\n    >\n      <CreateComment\n        maxLength={maxLength}\n        comment={reply}\n        onChange={(text) => setReply(text)}\n        onSubmit={handleSubmit}\n        isLoggedIn={isLoggedIn}\n        isLoading={isLoading}\n        isReply\n        buttonLabel=\"Leave a reply\"\n      />\n      {isError ? (\n        <Alert variant=\"failure\" sx={{ mt: 3 }}>\n          Unable to leave a comment at this time. Please try again later.\n        </Alert>\n      ) : null}\n    </Box>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/DisplayDate/DisplayDate.stories.tsx",
    "content": "import { subMonths } from 'date-fns';\n\nimport { DisplayDate } from './DisplayDate';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  /* 👇 The title prop is optional.\n   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading\n   * to learn how to generate automatic titles\n   */\n  title: 'Components/DisplayDate',\n  component: DisplayDate,\n} as Meta<typeof DisplayDate>;\n\nexport const Default: StoryFn<typeof DisplayDate> = () => {\n  return <DisplayDate createdAt={new Date()}></DisplayDate>;\n};\n\nexport const TwoMonthsAGo: StoryFn<typeof DisplayDate> = () => {\n  const twoMonthsAGo = subMonths(new Date(), 2);\n  return <DisplayDate createdAt={twoMonthsAGo}></DisplayDate>;\n};\n"
  },
  {
    "path": "packages/components/src/DisplayDate/DisplayDate.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { subDays, subMonths } from 'date-fns';\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { DisplayDate } from './DisplayDate';\n\ndescribe('DisplayDate', () => {\n  describe('relative time display', () => {\n    it('renders \"less than a minute ago\" for current date', () => {\n      const { getByText } = render(<DisplayDate createdAt={new Date()} />);\n      expect(getByText('less than a minute ago', { exact: false })).toBeInTheDocument();\n    });\n\n    it('renders \"2 months ago\" for two months ago', () => {\n      const twoMonthsAgo = subMonths(new Date(), 2);\n      const { getByText } = render(<DisplayDate createdAt={twoMonthsAgo} />);\n      expect(getByText('2 months ago', { exact: false })).toBeInTheDocument();\n    });\n  });\n\n  describe('label selection', () => {\n    it('shows \"Created\" for drafts (no publishedAt)', () => {\n      const { getAllByText } = render(<DisplayDate createdAt={new Date()} />);\n      expect(getAllByText('Created', { exact: false }).length).toBeGreaterThan(0);\n    });\n\n    it('shows \"Published\" when publishedAt is set', () => {\n      const { getAllByText } = render(\n        <DisplayDate createdAt={new Date()} publishedAt={new Date()} />,\n      );\n      expect(getAllByText('Published', { exact: false }).length).toBeGreaterThan(0);\n    });\n\n    it('shows custom publishedAction when provided', () => {\n      const { getAllByText } = render(\n        <DisplayDate createdAt={new Date()} publishedAt={new Date()} publishedAction=\"Started\" />,\n      );\n      expect(getAllByText('Started', { exact: false }).length).toBeGreaterThan(0);\n    });\n\n    it('hides label when showLabel is false', () => {\n      const { queryByText } = render(\n        <DisplayDate createdAt={new Date()} publishedAt={new Date()} showLabel={false} />,\n      );\n      expect(queryByText('Published', { exact: false })).not.toBeInTheDocument();\n    });\n  });\n\n  describe('edit indication', () => {\n    it('shows \"Last edit\" when modified after publish', () => {\n      const publishedAt = subDays(new Date(), 2);\n      const modifiedAt = subDays(new Date(), 1);\n\n      const { getByText } = render(\n        <DisplayDate\n          createdAt={subDays(new Date(), 3)}\n          publishedAt={publishedAt}\n          modifiedAt={modifiedAt}\n        />,\n      );\n      expect(getByText('Last edit', { exact: false })).toBeInTheDocument();\n    });\n\n    it('does not show \"Last edit\" when modifiedAt equals publishedAt', () => {\n      const now = new Date();\n\n      const { queryByText } = render(\n        <DisplayDate createdAt={subDays(now, 1)} publishedAt={now} modifiedAt={now} />,\n      );\n      expect(queryByText('Last edit', { exact: false })).not.toBeInTheDocument();\n    });\n\n    it('shows \"Last edit\" for draft when modified after creation', () => {\n      const createdAt = subDays(new Date(), 2);\n      const modifiedAt = subDays(new Date(), 1);\n\n      const { getByText } = render(<DisplayDate createdAt={createdAt} modifiedAt={modifiedAt} />);\n      expect(getByText('Last edit', { exact: false })).toBeInTheDocument();\n    });\n  });\n\n  describe('tooltip', () => {\n    it('shows formatted date in title', () => {\n      const date = new Date();\n      const { container } = render(<DisplayDate createdAt={date} />);\n\n      const textElement = container.querySelector('[title]');\n      expect(textElement?.getAttribute('title')).toMatch(/^\\d{2}-\\d{2}-\\d{4} \\d{2}:\\d{2}$/);\n    });\n\n    it('shows edit date in title when edited', () => {\n      const createdAt = subDays(new Date(), 3);\n      const publishedAt = subDays(new Date(), 2);\n      const modifiedAt = subDays(new Date(), 1);\n\n      const { container } = render(\n        <DisplayDate createdAt={createdAt} publishedAt={publishedAt} modifiedAt={modifiedAt} />,\n      );\n\n      const textElement = container.querySelector('[title]');\n      const title = textElement?.getAttribute('title');\n      expect(title).toMatch(/^\\d{2}-\\d{2}-\\d{4} \\d{2}:\\d{2} \\(edited \\d{2}-\\d{2}-\\d{4} \\d{2}:\\d{2}\\)$/);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/components/src/DisplayDate/DisplayDate.tsx",
    "content": "import { differenceInSeconds, format, formatDistanceToNow } from 'date-fns';\nimport { Text } from 'theme-ui';\n\nimport './display-date.css';\n\ntype DateType = string | number | Date;\n\nexport type PublishedAction = 'Published' | 'Started' | 'Asked';\n\nexport interface IProps {\n  createdAt: DateType;\n  publishedAction?: PublishedAction;\n  showLabel?: boolean;\n  modifiedAt?: DateType | null;\n  publishedAt?: DateType | null;\n}\n\nexport const DisplayDate = (props: IProps) => {\n  const {\n    createdAt,\n    modifiedAt,\n    publishedAt,\n    publishedAction = 'Published',\n    showLabel = true,\n  } = props;\n\n  const modifiedTime = modifiedAt ? new Date(modifiedAt).getTime() : null;\n  const publishedTime = publishedAt ? new Date(publishedAt).getTime() : null;\n  const createdTime = new Date(createdAt).getTime();\n\n  const primaryDate = new Date(publishedAt || createdAt);\n  const primaryLabel = publishedTime ? publishedAction : 'Created';\n\n  const wasEdited =\n    modifiedTime &&\n    ((publishedTime && modifiedTime > publishedTime) ||\n      (!publishedTime && modifiedTime > createdTime));\n\n  const modifiedDate = modifiedAt ? new Date(modifiedAt) : null;\n\n  const primaryFormatted = format(primaryDate, 'dd-MM-yyyy HH:mm');\n  const primaryRelative = formatDistanceToNow(primaryDate, { addSuffix: true });\n  const primaryShort = formatDistanceShort(primaryDate);\n\n  const modifiedFormatted = modifiedDate ? format(modifiedDate, 'dd-MM-yyyy HH:mm') : '';\n  const modifiedRelative = modifiedDate\n    ? formatDistanceToNow(modifiedDate, { addSuffix: true })\n    : '';\n  const modifiedShort = modifiedDate ? formatDistanceShort(modifiedDate) : '';\n\n  return (\n    <Text\n      title={wasEdited ? `${primaryFormatted} (edited ${modifiedFormatted})` : primaryFormatted}\n    >\n      {/* Mobile version - show short format */}\n      <span className=\"date-mobile\">\n        {showLabel && `${primaryLabel} `}\n        {primaryShort}\n        {wasEdited ? `. Edited ${modifiedShort}.` : '.'}\n      </span>\n\n      {/* Desktop version - show full format */}\n      <span className=\"date-desktop\">\n        {showLabel && `${primaryLabel} `}\n        {primaryRelative}\n        {wasEdited ? `. Last edit ${modifiedRelative}.` : '.'}\n      </span>\n    </Text>\n  );\n};\n\nfunction formatDistanceShort(date: Date) {\n  const seconds = Math.abs(differenceInSeconds(new Date(), date));\n\n  const intervals = [\n    { label: 'y', seconds: 31536000 },\n    { label: 'mo', seconds: 2628000 },\n    { label: 'w', seconds: 604800 },\n    { label: 'd', seconds: 86400 },\n    { label: 'h', seconds: 3600 },\n    { label: 'm', seconds: 60 },\n  ];\n\n  for (const interval of intervals) {\n    const count = Math.floor(seconds / interval.seconds);\n    if (count >= 1) {\n      return `${count}${interval.label}`;\n    }\n  }\n\n  return 'now';\n}\n"
  },
  {
    "path": "packages/components/src/DisplayDate/display-date.css",
    "content": ".date-mobile {\n  display: inline;\n}\n\n.date-desktop {\n  display: none;\n}\n\n/* Show desktop version and hide mobile version on screens wider than 768px */\n@media (min-width: 768px) {\n  .date-mobile {\n    display: none;\n  }\n\n  .date-desktop {\n    display: inline;\n  }\n}\n"
  },
  {
    "path": "packages/components/src/DonationRequestModal/DonationRequestModal.stories.tsx",
    "content": "import { useState } from 'react';\n\nimport { DonationRequestModal } from './DonationRequestModal';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/DonationRequestModal',\n  component: DonationRequestModal,\n} as Meta<typeof DonationRequestModal>;\n\nexport const Default: StoryFn<typeof DonationRequestModal> = () => {\n  const [isModalOpen, setIsModalOpen] = useState<boolean>(true);\n\n  const toggleIsModalOpen = () => setIsModalOpen(!isModalOpen);\n\n  return (\n    <DonationRequestModal\n      description=\"All of the content here is free. Your donation supports this library of Open Source recycling knowledge. Making it possible for everyone in the world to use it and start recycling.\"\n      iframeSrc=\"https://donorbox.org/embed/ppcpdonor?language=en\"\n      imageUrl=\"https://images.unsplash.com/photo-1520222984843-df35ebc0f24d?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjF9\"\n      isOpen={isModalOpen}\n      onDidDismiss={() => toggleIsModalOpen()}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/DonationRequestModal/DonationRequestModal.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { DonationRequestModal } from './DonationRequestModal';\n\ndescribe('DonationRequestModal', () => {\n  const body =\n    'All of the content here is free. Your donation supports this library of Open Source recycling knowledge. Making it possible for everyone in the world to use it and start recycling.';\n  const iframeSrc = 'https://donorbox.org/embed/precious-plastic-2?default_interval=o&amp;a=b';\n  const imageUrl =\n    'https://images.unsplash.com/photo-1520222984843-df35ebc0f24d?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjF9';\n\n  it('shows the modal when isOpen is true', () => {\n    const mockCall = vi.fn();\n\n    const { getByText } = render(\n      <DonationRequestModal\n        description={body}\n        iframeSrc={iframeSrc}\n        imageUrl={imageUrl}\n        isOpen={true}\n        onDidDismiss={mockCall}\n      />,\n    );\n\n    expect(getByText(body)).toBeInTheDocument();\n  });\n\n  it(\"doesn't shows the modal when isOpen is false\", () => {\n    const { container } = render(\n      <DonationRequestModal\n        description={body}\n        iframeSrc={iframeSrc}\n        imageUrl={imageUrl}\n        isOpen={false}\n        onDidDismiss={() => vi.fn()}\n      />,\n    );\n\n    expect(container.innerHTML).toBe('');\n  });\n});\n"
  },
  {
    "path": "packages/components/src/DonationRequestModal/DonationRequestModal.tsx",
    "content": "import { AspectImage, Card, Flex, Text } from 'theme-ui';\n\nimport { Modal } from '../Modal/Modal';\n\nexport interface IProps {\n  description?: string;\n  imageUrl?: string;\n  iframeSrc?: string;\n  spaceName?: string;\n  isOpen: boolean;\n  onDidDismiss: () => void;\n  children?: React.ReactNode | React.ReactNode[];\n}\n\nconst FALLBACK_DONATION_WIDGET = 'https://donorbox.org/embed/onearmy?a=b&hide_donation_meter=true';\nconst REQUEST_THANKYOU = 'Thank you for helping to make this possible!';\n\nexport const DonationRequestModal = (props: IProps) => {\n  const { spaceName, description, iframeSrc, imageUrl, isOpen, onDidDismiss, children } = props;\n  const title = spaceName ? `Support ${spaceName}` : 'Support our work';\n  const iframeArgs = {\n    allowpaymentrequest: 'allowpaymentrequest',\n    allow: 'payment',\n    'data-donorbox-id': 'DonorBox-f2',\n    'data-testid': 'donationRequestIframe',\n    name: 'donorbox',\n    seamless: true,\n    src: iframeSrc ? iframeSrc + '?hide_donation_meter=true' : FALLBACK_DONATION_WIDGET,\n  };\n\n  return (\n    <Modal\n      onDismiss={onDidDismiss}\n      isOpen={isOpen}\n      sx={{\n        width: ['500px', '750px', '1050px'],\n        minWidth: '350px',\n        border: '0 !important',\n        background: 'none !important',\n      }}\n    >\n      <Card\n        sx={{\n          overflowY: 'auto',\n          scrollbarWidth: 'thin',\n          borderRadius: '4px 4px 0 0',\n        }}\n        data-cy=\"DonationRequest\"\n        data-testid=\"DonationRequest\"\n      >\n        <script\n          src=\"https://donorbox.org/widget.js\"\n          type=\"module\"\n          data-paypalexpress=\"false\"\n          async\n        ></script>\n        <Flex\n          sx={{\n            flexDirection: ['column', 'row'],\n          }}\n        >\n          <Flex sx={{ flexDirection: 'column', flex: 1 }}>\n            {imageUrl && (\n              <Flex sx={{ display: ['none', 'inline'] }}>\n                <AspectImage\n                  loading=\"lazy\"\n                  ratio={16 / 9}\n                  src={imageUrl}\n                  alt={title}\n                  data-testid=\"donationRequestImage\"\n                />\n              </Flex>\n            )}\n\n            <Text sx={{ padding: [2, 4, 6] }}>\n              <Text as=\"h1\">{title}</Text>\n              <p>{description}</p>\n              <p>{REQUEST_THANKYOU}</p>\n            </Text>\n          </Flex>\n\n          <Flex\n            sx={{\n              borderLeft: [0, '2px solid'],\n              minHeight: '524px',\n              width: ['100%', '350px', '400px'],\n            }}\n          >\n            <iframe\n              {...iframeArgs}\n              loading=\"lazy\"\n              style={{ border: '0', overflow: 'scroll', width: '100%' }}\n            ></iframe>\n          </Flex>\n        </Flex>\n      </Card>\n      {children}\n    </Modal>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/DownloadButton/DownloadButton.stories.tsx",
    "content": "import { DownloadButton } from './DownloadButton';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/DownloadButton',\n  component: DownloadButton,\n} as Meta<typeof DownloadButton>;\n\nexport const Default: StoryFn<typeof DownloadButton> = () => <DownloadButton onClick={() => {}} />;\n\nexport const CustomDetails: StoryFn<typeof DownloadButton> = () => (\n  <DownloadButton onClick={() => {}} glyph=\"download-cloud\" label=\"Hello there\" />\n);\n"
  },
  {
    "path": "packages/components/src/DownloadButton/DownloadButton.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { DownloadButton } from './DownloadButton';\n\ndescribe('DownloadButton', () => {\n  it('renders the default state', () => {\n    const { getByText } = render(<DownloadButton onClick={() => {}} />);\n\n    expect(getByText('Download files')).toBeInTheDocument();\n  });\n\n  it('renders the custom options', () => {\n    const { getByText } = render(\n      <DownloadButton onClick={() => {}} glyph=\"download-cloud\" label=\"Hello there\" />,\n    );\n\n    expect(getByText('Hello there')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/DownloadButton/DownloadButton.tsx",
    "content": "import { Flex, Text } from 'theme-ui';\n\nimport { Icon } from '../Icon/Icon';\nimport type { IGlyphs } from '../Icon/types';\nimport { Tooltip } from '../Tooltip/Tooltip';\n\nexport interface IProps {\n  onClick: () => void;\n\n  isLoggedIn?: boolean;\n  label?: string;\n  glyph?: keyof IGlyphs;\n}\n\nexport const DownloadButton = (props: IProps) => {\n  const { glyph, isLoggedIn, label, onClick } = props;\n  return (\n    <>\n      <Flex\n        sx={{\n          padding: 2,\n          background: 'accent',\n          border: '2px solid black',\n          flexDirection: 'row',\n          maxWidth: '300px',\n          borderRadius: 1,\n          cursor: 'pointer',\n          gap: 2,\n        }}\n        onClick={onClick}\n        data-cy=\"downloadButton\"\n        data-testid=\"downloadButton\"\n        data-tooltip-id=\"download-files\"\n        data-tooltip-content={!isLoggedIn ? 'Login to download' : ''}\n      >\n        <Icon size={24} glyph={glyph || 'external-url'} />\n        <Text\n          sx={{\n            flex: 1,\n            fontSize: 1,\n            color: 'black',\n            overflowWrap: 'break-word',\n            alignSelf: label ? 'flex-start' : 'center',\n          }}\n        >\n          {label ? label : 'Download files'}\n        </Text>\n      </Flex>\n      <Tooltip id=\"download-files\" />\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/DownloadCounter/DownloadCounter.stories.tsx",
    "content": "import { DownloadCounter } from './DownloadCounter';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/DownloadCounter',\n  component: DownloadCounter,\n} as Meta<typeof DownloadCounter>;\n\nexport const Default: StoryFn<typeof DownloadCounter> = () => <DownloadCounter total={1888999} />;\n\nexport const One: StoryFn<typeof DownloadCounter> = () => <DownloadCounter total={1} />;\n\nexport const Zero: StoryFn<typeof DownloadCounter> = () => <DownloadCounter total={undefined} />;\n"
  },
  {
    "path": "packages/components/src/DownloadCounter/DownloadCounter.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { DownloadCounter } from './DownloadCounter';\n\ndescribe('DownloadCounter', () => {\n  it('Adds commas for larger download counts', () => {\n    const { getByText } = render(<DownloadCounter total={1888999} />);\n\n    expect(getByText('1,888,999 downloads')).toBeInTheDocument();\n  });\n\n  it('Adds \"download\" when total is one', () => {\n    const { getByText } = render(<DownloadCounter total={1} />);\n\n    expect(getByText('1 download')).toBeInTheDocument();\n  });\n\n  it('Adds a zero for undefined total', () => {\n    const { getByText } = render(<DownloadCounter total={undefined} />);\n\n    expect(getByText('0 downloads')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/DownloadCounter/DownloadCounter.tsx",
    "content": "import { Text } from 'theme-ui';\n\nexport interface IProps {\n  total: number | undefined;\n}\n\n// Duplicated util from main app - should be in 'shared' once the setup\n// is right with typing and testing\nconst numberWithCommas = (number: number) => {\n  return new Intl.NumberFormat('en-US').format(number);\n};\n\nexport const DownloadCounter = ({ total }: IProps) => {\n  return (\n    <Text\n      data-cy=\"file-download-counter\"\n      sx={{\n        fontSize: 1,\n        color: 'grey',\n      }}\n    >\n      {numberWithCommas(total || 0)}\n      {total !== 1 ? ' downloads' : ' download'}\n    </Text>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/DownloadStaticFile/DownloadStaticFile.stories.tsx",
    "content": "import { DownloadStaticFile } from './DownloadStaticFile';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/DownloadStaticFile',\n  component: DownloadStaticFile,\n} as Meta<typeof DownloadStaticFile>;\n\nexport const Default: StoryFn<typeof DownloadStaticFile> = () => (\n  <DownloadStaticFile\n    file={{\n      name: 'example',\n      size: 1200000,\n      url: 'https://example.com',\n      id: '',\n    }}\n    fileDownloadCount={346}\n  />\n);\nexport const LoggedOut: StoryFn<typeof DownloadStaticFile> = () => (\n  <DownloadStaticFile\n    file={{\n      name: 'example',\n      size: 1200000,\n      url: 'https://example.com',\n      id: '',\n    }}\n    redirectToSignIn={async () => {\n      alert('Redirect to Sign In');\n    }}\n    fileDownloadCount={6}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/DownloadStaticFile/DownloadStaticFile.tsx",
    "content": "import type { MediaFile } from 'oa-shared';\nimport { Flex, Text } from 'theme-ui';\nimport { DownloadButton } from '../DownloadButton/DownloadButton';\nimport { ExternalLink } from '../ExternalLink/ExternalLink';\nimport { Icon } from '../Icon/Icon';\nimport type { availableGlyphs } from '../Icon/types';\nimport { Tooltip } from '../Tooltip/Tooltip';\n\nexport interface IProps {\n  file: MediaFile;\n  fileDownloadCount?: number;\n  forDonationRequest?: boolean;\n  isLoggedIn?: boolean;\n  allowDownload?: boolean;\n  handleClick?: () => void;\n  redirectToSignIn?: () => Promise<void>;\n}\n\ninterface IPropFileDetails {\n  name: string;\n  glyph: availableGlyphs;\n  size: string;\n  redirectToSignIn?: () => Promise<void>;\n}\n\nconst FileDetails = (props: IPropFileDetails) => {\n  const { name, glyph, size, redirectToSignIn } = props;\n\n  return (\n    <>\n      <Flex\n        sx={{\n          borderRadius: 1,\n          border: '2px solid black',\n          background: 'accent',\n          color: 'black',\n          justifyContent: 'space-between',\n          alignItems: 'center',\n          flexDirection: 'row',\n          width: '300px',\n          cursor: 'pointer',\n          padding: 2,\n          marginBottom: 1,\n        }}\n        onClick={() => redirectToSignIn && redirectToSignIn()}\n        data-tooltip-id=\"login-download\"\n        data-tooltip-content={redirectToSignIn ? 'Login to download' : ''}\n      >\n        <Icon size={24} glyph={glyph} mr={3} />\n        <Text\n          sx={{\n            flex: 1,\n            fontSize: 1,\n            whiteSpace: 'nowrap',\n            textOverflow: 'ellipsis',\n            overflow: 'hidden',\n            marginRight: 3,\n          }}\n        >\n          {name}\n        </Text>\n        <Text sx={{ fontSize: 1 }}>{size}</Text>\n      </Flex>\n      <Tooltip id=\"login-download\" />\n    </>\n  );\n};\n\nexport const DownloadStaticFile = (props: IProps) => {\n  const { file, allowDownload, handleClick, redirectToSignIn, isLoggedIn } = props;\n  const size = bytesToSize(file.size || 0);\n\n  if (!file) {\n    return null;\n  }\n\n  const forDownload = allowDownload && file.url && !redirectToSignIn;\n\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n      {forDownload && (\n        <ExternalLink\n          onClick={() => handleClick && handleClick()}\n          href={file.url}\n          download={file.name}\n          sx={{ width: '300px', marginLeft: 0, marginRight: 1 }}\n        >\n          <FileDetails name={file.name} glyph=\"download-cloud\" size={size} />\n        </ExternalLink>\n      )}\n\n      <DownloadButton\n        glyph=\"download-cloud\"\n        isLoggedIn={isLoggedIn}\n        label={`${file.name} (${size})`}\n        onClick={() => handleClick && handleClick()}\n      />\n    </Flex>\n  );\n};\n\nconst bytesToSize = (bytes: number) => {\n  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n  if (bytes === 0) {\n    return '0 Bytes';\n  }\n  const i = Number(Math.floor(Math.log(bytes) / Math.log(1024)));\n  return (bytes / Math.pow(1024, i)).toPrecision(3) + ' ' + sizes[i];\n};\n"
  },
  {
    "path": "packages/components/src/EditComment/EditComment.stories.tsx",
    "content": "import { EditComment } from './EditComment';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Commenting/EditComment',\n  component: EditComment,\n} as Meta<typeof EditComment>;\n\nexport const Default: StoryFn<typeof EditComment> = () => (\n  <EditComment\n    isReply={false}\n    comment=\"A short comment\"\n    setShowEditModal={() => null}\n    handleCancel={() => null}\n    handleSubmit={() => Promise.resolve(new Response(''))}\n  />\n);\n\nexport const EditReply: StoryFn<typeof EditComment> = () => (\n  <EditComment\n    isReply={true}\n    comment=\"A short comment here...\"\n    setShowEditModal={() => null}\n    handleCancel={() => null}\n    handleSubmit={() => Promise.resolve(new Response(''))}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/EditComment/EditComment.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { act, fireEvent } from '@testing-library/react';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { EditComment } from './EditComment';\n\nimport type { IProps } from './EditComment';\n\ndescribe('EditComment', () => {\n  const mockOnSubmit = vi.fn().mockImplementation(() => new Response());\n  const mockOnCancel = vi.fn();\n\n  const defaultProps: IProps = {\n    isReply: false,\n    comment: 'A short comment',\n    setShowEditModal: () => null,\n    handleCancel: mockOnCancel,\n    handleSubmit: () => Promise.resolve(new Response('')),\n  };\n\n  it('showed correct title when a comment', () => {\n    const { getByText } = render(<EditComment {...defaultProps} />);\n\n    expect(getByText('Edit Comment')).toBeInTheDocument();\n    expect(() => getByText('Edit Reply')).toThrow();\n  });\n\n  it('showed correct title when a reply', () => {\n    const { getByText } = render(<EditComment {...defaultProps} isReply={true} />);\n\n    expect(getByText('Edit Reply')).toBeInTheDocument();\n    expect(() => getByText('Edit Comment')).toThrow();\n  });\n\n  it('enables save button when comment is not empty', () => {\n    const screen = render(\n      <EditComment\n        isReply={false}\n        comment=\"Test comment\"\n        setShowEditModal={() => null}\n        handleCancel={mockOnCancel}\n        handleSubmit={mockOnSubmit}\n      />,\n    );\n    expect(screen.getByText('Save')).not.toBeDisabled();\n  });\n\n  it('calls onSubmit when the submit button is clicked', () => {\n    const screen = render(\n      <EditComment\n        isReply={false}\n        comment=\"Test comment\"\n        setShowEditModal={() => null}\n        handleCancel={mockOnCancel}\n        handleSubmit={mockOnSubmit}\n      />,\n    );\n    const button = screen.getByText('Save');\n    fireEvent.click(button);\n    expect(mockOnSubmit).toHaveBeenCalledWith('Test comment');\n  });\n\n  it('disables save button when comment is empty', () => {\n    const screen = render(\n      <EditComment\n        isReply={false}\n        comment=\"\"\n        setShowEditModal={() => null}\n        handleCancel={mockOnCancel}\n        handleSubmit={mockOnSubmit}\n      />,\n    );\n    expect(screen.getByTestId('edit-comment-submit')).toBeDisabled();\n  });\n\n  it('should dispaly error message when the comment is empty', () => {\n    const screen = render(\n      <EditComment\n        isReply={false}\n        comment=\"\"\n        setShowEditModal={() => null}\n        handleCancel={mockOnCancel}\n        handleSubmit={mockOnSubmit}\n      />,\n    );\n\n    act(() => {\n      const commentInput = screen.getByLabelText('Edit Comment');\n      fireEvent.change(commentInput, { target: { value: '' } });\n      fireEvent.blur(commentInput);\n    });\n\n    expect(screen.container.innerHTML).toMatch('Comment cannot be blank');\n  });\n});\n"
  },
  {
    "path": "packages/components/src/EditComment/EditComment.tsx",
    "content": "import { useState } from 'react';\nimport { Field, Form } from 'react-final-form';\nimport { Flex, Label } from 'theme-ui';\nimport { object, string } from 'yup';\nimport { Banner } from '../Banner/Banner';\nimport { Button } from '../Button/Button';\nimport { FieldTextarea } from '../FieldTextarea/FieldTextarea';\n\nexport interface IProps {\n  comment: string;\n  handleCancel: () => void;\n  handleSubmit: (commentText: string) => Promise<Response>;\n  isReply: boolean;\n  setShowEditModal: any;\n}\n\nexport const EditComment = (props: IProps) => {\n  const { comment, isReply, setShowEditModal } = props;\n\n  const [error, setError] = useState<string | undefined>(undefined);\n\n  const validationSchema = object({\n    comment: string().required(),\n  });\n\n  const required = (value: string) => (value?.trim() ? undefined : 'Comment cannot be blank');\n\n  const handleFormSubmit = async (comment: string) => {\n    if (!comment?.trim()) {\n      return;\n    }\n\n    const response = await props.handleSubmit(comment);\n\n    if (response.ok) {\n      setShowEditModal(false);\n    } else {\n      setError(response.statusText);\n    }\n  };\n\n  const validateEditedComment = async (values: any) => {\n    try {\n      await validationSchema.validate(values, { abortEarly: false });\n    } catch (err: any) {\n      return err.inner.reduce(\n        (acc: any, error: any) => ({\n          ...acc,\n          [error.path]: error.message,\n        }),\n        {},\n      );\n    }\n  };\n\n  return (\n    <Form\n      onSubmit={() => {\n        // do nothing\n      }}\n      initialValues={{\n        comment,\n      }}\n      validate={validateEditedComment}\n      data-cy=\"EditCommentForm\"\n      render={({ invalid, handleSubmit, values }) => {\n        const disabled = invalid;\n\n        return (\n          <Flex\n            as=\"form\"\n            sx={{\n              flexDirection: 'column',\n              gap: 2,\n            }}\n            onSubmit={handleSubmit}\n          >\n            <Label as=\"label\" htmlFor=\"comment\" sx={{ fontSize: 3 }}>\n              Edit {isReply ? 'Reply' : 'Comment'}\n            </Label>\n\n            {error && <Banner variant=\"failure\">{error}</Banner>}\n\n            <Field\n              component={FieldTextarea}\n              data-cy=\"edit-comment\"\n              id=\"comment\"\n              validate={required}\n              name=\"comment\"\n              rows={4}\n              sx={{ padding: 1, fontSize: '16px', lineHeight: '1.4' }}\n            />\n            <Flex sx={{ gap: 2, justifyContent: 'flex-end' }}>\n              <Button type=\"button\" small variant=\"outline\" onClick={() => props?.handleCancel()}>\n                Cancel\n              </Button>\n              <Button\n                data-cy=\"edit-comment-submit\"\n                data-testid=\"edit-comment-submit\"\n                type=\"submit\"\n                aria-label=\"Save changes\"\n                small\n                disabled={disabled}\n                onClick={() => {\n                  handleFormSubmit(values.comment);\n                }}\n              >\n                Save\n              </Button>\n            </Flex>\n          </Flex>\n        );\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ElWithBeforeIcon/ElWithBeforeIcon.stories.tsx",
    "content": "import HeaderHowtoIcon from '../../../../src/assets/images/header-section/howto-header-icon.svg';\nimport { ElWithBeforeIcon } from './ElWithBeforeIcon';\n\nimport type { Meta, StoryFn, StoryObj } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/ElWithBeforeIcon',\n  component: ElWithBeforeIcon,\n} as Meta<typeof ElWithBeforeIcon>;\n\nexport const Default: StoryFn<typeof ElWithBeforeIcon> = () => (\n  <ElWithBeforeIcon icon={HeaderHowtoIcon}>\n    <p>Element</p>\n  </ElWithBeforeIcon>\n);\n\nexport const Sizes: StoryObj<typeof ElWithBeforeIcon> = {\n  render: (args) => (\n    <ElWithBeforeIcon {...args} icon={HeaderHowtoIcon}>\n      <p>Element</p>\n    </ElWithBeforeIcon>\n  ),\n};\n"
  },
  {
    "path": "packages/components/src/ElWithBeforeIcon/ElWithBeforeIcon.tsx",
    "content": "import type { JSX } from 'react';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Box } from 'theme-ui';\nimport checkmarkIcon from '../../assets/icons/icon-checkmark.svg';\n\nexport interface ElWithBeforeIconProps {\n  children?: React.ReactNode;\n  icon: JSX.Element | string;\n  size?: number;\n  ticked?: boolean;\n  contain?: boolean;\n}\n\nconst DEFAULT_ICON_SIZE = 22;\n\nexport const ElWithBeforeIcon = ({\n  icon,\n  size,\n  children,\n  ticked,\n  contain,\n}: ElWithBeforeIconProps) => {\n  let after: ThemeUIStyleObject = {};\n\n  const iconSize = size || DEFAULT_ICON_SIZE;\n\n  if (ticked) {\n    after = {\n      content: \"''\",\n      position: 'absolute',\n      right: '0',\n      bottom: '50%',\n      transform: 'translateY(50%)',\n\n      width: iconSize,\n      height: iconSize,\n\n      backgroundImage: `url(\"${checkmarkIcon}\")`,\n      backgroundRepeat: 'no-repeat',\n    };\n  }\n\n  return (\n    <Box\n      sx={{\n        position: 'relative',\n        pl: '30px',\n        marginRight: 0,\n        '::after': after,\n        '::before': {\n          content: \"''\",\n          position: 'absolute',\n          left: '0',\n          bottom: '50%',\n          transform: 'translateY(50%)',\n\n          width: iconSize,\n          height: iconSize,\n          backgroundPosition: 'center',\n\n          backgroundRepeat: 'no-repeat',\n          backgroundImage: `url(\"${icon}\")`,\n          backgroundSize: contain ? 'contain' : 'initial',\n        },\n      }}\n    >\n      {children}\n    </Box>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ExternalLink/ExternalLink.stories.tsx",
    "content": "import { Text } from 'theme-ui';\n\nimport { Icon } from '..';\nimport { ExternalLink } from './ExternalLink';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  /* 👇 The title prop is optional.\n   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading\n   * to learn how to generate automatic titles\n   */\n  title: 'Components/ExternalLink',\n  component: ExternalLink,\n} as Meta<typeof ExternalLink>;\n\nexport const Basic: StoryFn<typeof ExternalLink> = () => (\n  <ExternalLink href=\"#\">Link Text</ExternalLink>\n);\n\nexport const Styled: StoryFn<typeof ExternalLink> = () => (\n  <ExternalLink href=\"#\" color=\"black\" sx={{ textDecoration: 'underline' }}>\n    Link Text\n  </ExternalLink>\n);\n\nexport const WithIcon: StoryFn<typeof ExternalLink> = () => (\n  <ExternalLink href=\"#\">\n    <Text>Link Text</Text>\n    <Icon glyph=\"external-url\" ml={[1]}></Icon>\n  </ExternalLink>\n);\n"
  },
  {
    "path": "packages/components/src/ExternalLink/ExternalLink.tsx",
    "content": "import type { LinkProps } from 'theme-ui';\nimport { Link } from 'theme-ui';\n\n/**\n * Provides a styled `a` tag. Opens in new tab with noopener and noreferrer rel attributes\n *\n * https://pointjupiter.com/what-noopener-noreferrer-nofollow-explained/\n */\nexport const ExternalLink = (props: LinkProps) => {\n  return (\n    <Link\n      {...props}\n      sx={{\n        ':hover': {\n          textDecoration: 'underline',\n        },\n      }}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n    ></Link>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/FieldCheckbox/FieldCheckbox.tsx",
    "content": "import styled from '@emotion/styled';\nimport type { FieldRenderProps } from 'react-final-form';\nimport { Flex, Text } from 'theme-ui';\n\ntype FieldProps = FieldRenderProps<boolean, any>;\n\nexport interface Props extends FieldProps {\n  disabled?: boolean;\n  'data-cy'?: string;\n}\n\nconst StyledCheckbox = styled.input`\n  width: 20px;\n  height: 20px;\n  cursor: pointer;\n`;\n\nexport const FieldCheckbox = ({ input, meta, disabled, ...rest }: Props) => {\n  const { value, type, ...inputProps } = input;\n\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n      {meta.error && meta.touched && <Text sx={{ fontSize: 1, color: 'error' }}>{meta.error}</Text>}\n      <StyledCheckbox\n        {...inputProps}\n        {...rest}\n        type=\"checkbox\"\n        disabled={disabled}\n        checked={!!value}\n      />\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/FieldInput/FieldInput.stories.tsx",
    "content": "import { FieldInput } from './FieldInput';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Forms/FieldInput',\n  component: FieldInput,\n} as Meta<typeof FieldInput>;\n\nexport const Default: StoryFn<typeof FieldInput> = () => (\n  <FieldInput placeholder=\"Input placeholder\" meta={{}} input={{} as any} />\n);\n\nexport const WithError: StoryFn<typeof FieldInput> = () => (\n  <FieldInput\n    placeholder=\"Text area input\"\n    input={{} as any}\n    meta={{ error: 'What an error', touched: true }}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/FieldInput/FieldInput.tsx",
    "content": "import type { FieldRenderProps } from 'react-final-form';\nimport { Box, Flex, Input, Text } from 'theme-ui';\nimport { CharacterCount } from '../CharacterCount/CharacterCount';\n\ntype FieldProps = FieldRenderProps<any, any> & { children?: React.ReactNode };\n\nexport interface Props extends FieldProps {\n  // additional fields intending to pass down\n  disabled?: boolean;\n  children?: React.ReactNode;\n  showCharacterCount?: boolean;\n  'data-cy'?: string;\n  customOnBlur?: (event: any) => void;\n  endAdornment?: any;\n}\n\ntype InputModifiers = {\n  capitalize?: boolean;\n  trim?: boolean;\n};\n\nconst capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);\n\nconst processInputModifiers = (value: any, modifiers: InputModifiers = {}) => {\n  if (typeof value !== 'string') return value;\n  if (modifiers.trim) {\n    value = value.trim();\n  }\n  if (modifiers.capitalize) {\n    value = capitalizeFirstLetter(value);\n  }\n  return value;\n};\n\nexport const FieldInput = ({\n  input,\n  meta,\n  disabled,\n  modifiers,\n  customOnBlur,\n  showCharacterCount,\n  minLength,\n  maxLength,\n  endAdornment,\n  ...rest\n}: Props) => {\n  const curLength = input?.value?.length ?? 0;\n\n  const InputElement = (\n    <Input\n      disabled={disabled}\n      variant={meta?.error && meta?.touched ? 'textareaError' : 'textarea'}\n      {...input}\n      {...rest}\n      minLength={minLength}\n      maxLength={maxLength}\n      onBlur={(e) => {\n        if (modifiers) {\n          e.target.value = processInputModifiers(e.target.value, modifiers);\n          input.onChange(e.target.value);\n        }\n        if (customOnBlur) {\n          customOnBlur(e);\n        }\n        input.onBlur();\n      }}\n      onChange={(ev) => {\n        input.onChange(ev.target.value);\n      }}\n    />\n  );\n\n  return (\n    <Flex sx={{ position: 'relative', flexDirection: 'column', flex: 1, gap: 1 }}>\n      {meta.error && meta.touched && <Text sx={{ fontSize: 1, color: 'error' }}>{meta.error}</Text>}\n      {endAdornment ? (\n        <Box\n          style={{\n            display: 'flex',\n            alignItems: 'center',\n            position: 'relative',\n          }}\n        >\n          {InputElement}\n          <Box\n            sx={{\n              position: 'absolute',\n              right: 2,\n            }}\n          >\n            {endAdornment}\n          </Box>\n        </Box>\n      ) : (\n        InputElement\n      )}\n      {showCharacterCount && maxLength && (\n        <CharacterCount currentSize={curLength} minSize={minLength} maxSize={maxLength} />\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/FieldMarkdown/AddImage.tsx",
    "content": "import { insertImage$, usePublisher } from '@mdxeditor/editor';\nimport { MediaWithPublicUrl } from 'oa-shared';\nimport { useState } from 'react';\nimport { Box, Flex } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { ImageInputV2 } from '../ImageInput/ImageInputV2';\nimport { Loader } from '../Loader/Loader';\nimport { Modal } from '../Modal/Modal';\n\ninterface IProps {\n  imageUploadHandler: (image: File) => Promise<MediaWithPublicUrl | null>;\n}\n\nexport const AddImage = ({ imageUploadHandler }: IProps) => {\n  const insertImage = usePublisher(insertImage$);\n  const [isOpen, setIsOpen] = useState(false);\n  const [isLoading, setIsLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const onFilesChange = async (file: File | undefined) => {\n    if (!file) {\n      return;\n    }\n\n    setIsLoading(true);\n    setError(null);\n\n    try {\n      const mediaFile = await imageUploadHandler(file);\n\n      if (mediaFile) {\n        insertImage({\n          src: mediaFile.publicUrl,\n        });\n      } else {\n        setError('Failed to upload image. Please try again.');\n      }\n\n      setIsOpen(false);\n    } catch (error) {\n      setError(\n        error instanceof Error ? error.message : 'Failed to upload image. Please try again.',\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const handleError = (errorMsg: string) => {\n    setError(errorMsg);\n  };\n\n  return (\n    <>\n      <Button\n        small\n        variant=\"subtle\"\n        icon=\"image\"\n        type=\"button\"\n        showIconOnly\n        onClick={() => setIsOpen(true)}\n      >\n        Upload\n      </Button>\n\n      <Modal isOpen={isOpen} width={600} onDismiss={() => {}}>\n        <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n          {error && <Box sx={{ color: 'error', fontSize: 1 }}>{error}</Box>}\n          <Box sx={{ height: '300px' }}>\n            <ImageInputV2 onFilesChange={onFilesChange} onError={handleError} />\n          </Box>\n          <Flex>\n            {isLoading ? (\n              <Loader />\n            ) : (\n              <Button variant=\"secondary\" type=\"button\" onClick={() => setIsOpen(false)}>\n                Cancel\n              </Button>\n            )}\n          </Flex>\n        </Flex>\n      </Modal>\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/FieldMarkdown/FieldMarkdown.stories.tsx",
    "content": "import { MediaWithPublicUrl } from 'oa-shared';\nimport { FieldMarkdown } from './FieldMarkdown';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Forms/FieldMarkdown',\n  component: FieldMarkdown,\n} as Meta<typeof FieldMarkdown>;\n\nconst imageUpload = () => Promise.resolve({} as MediaWithPublicUrl);\n\nexport const Default: StoryFn<typeof FieldMarkdown> = () => (\n  <FieldMarkdown\n    imageUploadHandler={imageUpload}\n    input={{} as any}\n    placeholder=\"Text area input\"\n    meta={{}}\n  />\n);\n\nexport const WithError: StoryFn<typeof FieldMarkdown> = () => (\n  <FieldMarkdown\n    imageUploadHandler={imageUpload}\n    input={{} as any}\n    placeholder=\"Text area input\"\n    meta={{ error: 'What an error', touched: true }}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/FieldMarkdown/FieldMarkdown.tsx",
    "content": "import type { MDXEditorMethods } from '@mdxeditor/editor';\nimport {\n  BlockTypeSelect,\n  BoldItalicUnderlineToggles,\n  CreateLink,\n  DiffSourceToggleWrapper,\n  diffSourcePlugin,\n  headingsPlugin,\n  imagePlugin,\n  ListsToggle,\n  linkDialogPlugin,\n  linkPlugin,\n  listsPlugin,\n  MDXEditor,\n  markdownShortcutPlugin,\n  quotePlugin,\n  thematicBreakPlugin,\n  toolbarPlugin,\n  UndoRedo,\n} from '@mdxeditor/editor';\nimport { useMemo, useRef } from 'react';\nimport type { FieldRenderProps } from 'react-final-form';\nimport { Box, Flex, Text } from 'theme-ui';\nimport { AddImage } from './AddImage';\n\nimport '@mdxeditor/editor/style.css';\nimport './style.css';\nimport { MediaWithPublicUrl } from 'oa-shared';\n\ntype FieldProps = FieldRenderProps<any, any> & { children?: React.ReactNode };\n\nexport interface IProps extends FieldProps {\n  imageUploadHandler: (image: File) => Promise<MediaWithPublicUrl | null>;\n  disabled?: boolean;\n  children?: React.ReactNode;\n  'data-cy'?: string;\n}\n\nexport const FieldMarkdown = (props: IProps) => {\n  const ref = useRef<MDXEditorMethods>(null);\n  const { imageUploadHandler, input, meta, ...rest } = props;\n\n  // Capture initial value once to use as key - this ensures editor remounts with new content\n  // but stays mounted while typing\n  const initialValueRef = useRef(input.value);\n  const editorKey = useRef(initialValueRef.current ? 'has-content' : 'empty').current;\n\n  const mainPluginList = useMemo(\n    () => [\n      headingsPlugin({ allowedHeadingLevels: [1, 2] }),\n      listsPlugin(),\n      quotePlugin(),\n      imagePlugin({\n        disableImageSettingsButton: true,\n        disableImageResize: true,\n      }),\n      thematicBreakPlugin(),\n      linkPlugin(),\n      linkDialogPlugin(),\n      diffSourcePlugin({ readOnlyDiff: true }),\n      markdownShortcutPlugin(),\n    ],\n    [],\n  );\n\n  const toolbar = useMemo(\n    () =>\n      toolbarPlugin({\n        toolbarContents: () => (\n          <DiffSourceToggleWrapper>\n            <UndoRedo />\n            <BoldItalicUnderlineToggles />\n            <ListsToggle />\n            <CreateLink />\n            <AddImage imageUploadHandler={imageUploadHandler} />\n            <BlockTypeSelect />\n          </DiffSourceToggleWrapper>\n        ),\n      }),\n    [imageUploadHandler],\n  );\n\n  const showError = meta.error && meta.touched;\n\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n      {showError && <Text sx={{ fontSize: 1, color: 'error' }}>{meta.error}</Text>}\n      <Box\n        sx={{\n          alignSelf: 'stretch',\n          fontFamily: 'body',\n          lineHeight: 1.5,\n          a: {\n            textDecoration: 'underline',\n            '&:hover': { textDecoration: 'none' },\n          },\n          h3: { fontSize: 2 },\n          h4: { fontSize: 2 },\n          h5: { fontSize: 2 },\n          h6: { fontSize: 2 },\n          img: {\n            borderRadius: 2,\n            maxWidth: '100%',\n          },\n        }}\n      >\n        <MDXEditor\n          key={editorKey}\n          ref={ref}\n          className={showError ? 'mdxeditor-error' : ''}\n          markdown={input.value}\n          plugins={[toolbar, ...mainPluginList]}\n          onBlur={() => input.onBlur()}\n          onChange={(ev) => input.onChange(ev)}\n          {...rest}\n        />\n      </Box>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/FieldMarkdown/style.css",
    "content": ".mdxeditor {\n  border-radius: 8px;\n  border: 2px solid #f0f0f3;\n  position: relative;\n  z-index: 0;\n\n  &.mdxeditor-error {\n    border: 2px solid red;\n  }\n}\n\n/* hacky way to hide the markdown diff part of https://mdxeditor.dev/editor/docs/diff-source */\n[aria-label=\"Diff mode\"] {\n  display: none;\n}\n\n._editImageToolbar_1e2ox_933 {\n  button {\n    cursor: pointer;\n  }\n}\n"
  },
  {
    "path": "packages/components/src/FieldTextarea/FieldTextarea.stories.tsx",
    "content": "import { FieldTextarea } from './FieldTextarea';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Forms/FieldTextarea',\n  component: FieldTextarea,\n} as Meta<typeof FieldTextarea>;\n\nexport const Default: StoryFn<typeof FieldTextarea> = () => (\n  <FieldTextarea input={{} as any} placeholder=\"Text area input\" meta={{}} />\n);\n\nexport const WithoutResizeHandle: StoryFn<typeof FieldTextarea> = () => (\n  <FieldTextarea\n    input={{} as any}\n    placeholder=\"Text area input is not resizable\"\n    sx={{ resize: 'none' }}\n    meta={{ error: 'What an error', touched: true }}\n  />\n);\n\nexport const WithError: StoryFn<typeof FieldTextarea> = () => (\n  <FieldTextarea\n    input={{} as any}\n    placeholder=\"Text area input\"\n    meta={{ error: 'What an error', touched: true }}\n  />\n);\n\nconst characterCountValues = [\n  {\n    currentSize: 5,\n    minSize: 0,\n    maxSize: 200,\n    error: null,\n  },\n  {\n    currentSize: 25,\n    minSize: 50,\n    maxSize: 200,\n    error: 'Character count must be a greater than 50 characters',\n  },\n  {\n    currentSize: 500,\n    minSize: 0,\n    maxSize: 100,\n    error: 'Character count must be a less than 100 characters',\n  },\n];\n\nexport const WithCharacterCounts: StoryFn<typeof FieldTextarea> = () => (\n  <>\n    {characterCountValues.map((state, index) => {\n      return (\n        <FieldTextarea\n          key={index}\n          input={{ value: 'Hello '.repeat(Math.round(state.currentSize / 6)) } as any}\n          placeholder=\"Text area input\"\n          meta={{ touched: true }}\n          minLength={state.minSize}\n          maxLength={state.maxSize}\n          showCharacterCount\n        />\n      );\n    })}\n  </>\n);\n\nexport const CustomRowHeight: StoryFn<typeof FieldTextarea> = () => (\n  <FieldTextarea input={{} as any} placeholder=\"Text area input\" meta={{}} rows={10} />\n);\n"
  },
  {
    "path": "packages/components/src/FieldTextarea/FieldTextarea.tsx",
    "content": "import { useMemo } from 'react';\nimport type { FieldRenderProps } from 'react-final-form';\nimport { Flex, Text, Textarea } from 'theme-ui';\nimport { CharacterCount } from '../CharacterCount/CharacterCount';\n\ntype FieldProps = FieldRenderProps<any, any> & { children?: React.ReactNode };\nexport interface Props extends FieldProps {\n  // additional fields intending to pass down\n  disabled?: boolean;\n  children?: React.ReactNode;\n  showCharacterCount?: boolean;\n  'data-cy'?: string;\n  customOnBlur?: (event: any) => void;\n  rows?: number;\n}\n\ntype InputModifiers = {\n  capitalize?: boolean;\n  trim?: boolean;\n};\n\nconst capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);\n\nconst processInputModifiers = (value: any, modifiers: InputModifiers) => {\n  if (typeof value !== 'string') return value;\n  if (modifiers.trim) {\n    value = value.trim();\n  }\n  if (modifiers.capitalize) {\n    value = capitalizeFirstLetter(value);\n  }\n  return value;\n};\n\nexport const FieldTextarea = ({\n  input,\n  meta,\n  disabled,\n  modifiers,\n  customOnBlur,\n  minLength = 0,\n  maxLength,\n  showCharacterCount,\n  rows,\n  ...rest\n}: Props) => {\n  const curLength = useMemo<number>(() => input?.value?.length ?? 0, [input?.value]);\n  const { sx: restSx, ...restWithoutSx } = rest;\n\n  return (\n    <Flex sx={{ position: 'relative', flexDirection: 'column', gap: 1, width: '100%' }}>\n      {meta.error && meta.touched && <Text sx={{ fontSize: 1, color: 'error' }}>{meta.error}</Text>}\n\n      <Textarea\n        disabled={disabled}\n        minLength={minLength}\n        maxLength={maxLength}\n        variant={meta?.error && meta?.touched ? 'textareaError' : 'textarea'}\n        rows={rows ? rows : 5}\n        sx={{\n          resize: rest?.style?.resize ? rest.style.resize : 'vertical',\n          minWidth: '100%',\n          height: 'auto',\n          fieldSizing: 'fixed',\n          ...restSx,\n        }}\n        {...input}\n        {...restWithoutSx}\n        onBlur={(e) => {\n          if (modifiers) {\n            e.target.value = processInputModifiers(e.target.value, modifiers);\n            input.onChange(e.target.value);\n          }\n          if (customOnBlur) {\n            customOnBlur(e);\n          }\n          input.onBlur();\n        }}\n        onChange={(ev) => input.onChange(ev.target.value)}\n      />\n\n      {showCharacterCount && maxLength && (\n        <CharacterCount minSize={minLength} maxSize={maxLength} currentSize={curLength} />\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/FlagIcon/FlagIcon.tsx",
    "content": "import { ReactCountryFlag } from 'react-country-flag';\n\ninterface IProps {\n  countryCode: string;\n}\n\nexport const FlagIcon = ({ countryCode }: IProps) => {\n  return (\n    <ReactCountryFlag\n      data-cy={`country:${countryCode}`}\n      countryCode={countryCode}\n      title={countryCode}\n      svg={true}\n      style={{\n        borderRadius: '3px',\n        backgroundSize: 'cover',\n        height: '14px',\n        width: '21px',\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/FollowButton/FollowButton.stories.tsx",
    "content": "import { FollowButton } from './FollowButton';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/FollowButton',\n  component: FollowButton,\n} as Meta<typeof FollowButton>;\n\nexport const LoggedOut: StoryFn<typeof FollowButton> = () => (\n  <FollowButton isLoggedIn={false} isFollowing={false} onFollowClick={() => null} />\n);\n\nexport const LoggedIn: StoryFn<typeof FollowButton> = () => (\n  <FollowButton isLoggedIn={true} isFollowing={false} onFollowClick={() => null} />\n);\n\nexport const CurrentUserSubscribed: StoryFn<typeof FollowButton> = () => (\n  <FollowButton isLoggedIn={true} isFollowing={true} onFollowClick={() => null} />\n);\n"
  },
  {
    "path": "packages/components/src/FollowButton/FollowButton.tsx",
    "content": "import { useId } from 'react';\nimport { useNavigate } from 'react-router';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { Tooltip } from '../Tooltip/Tooltip';\n\nexport interface FollowButtonProps {\n  isLoggedIn: boolean;\n  onFollowClick: () => void;\n  isFollowing?: boolean;\n  labelFollow?: string;\n  labelUnfollow?: string;\n  tooltipFollow?: string;\n  tooltipUnfollow?: string;\n  small?: boolean;\n  variant?: string;\n  sx?: ThemeUIStyleObject;\n}\n\nexport const FollowButton = (props: FollowButtonProps) => {\n  const {\n    isFollowing,\n    isLoggedIn,\n    labelFollow = 'Follow',\n    labelUnfollow = 'Following',\n    onFollowClick,\n    sx,\n    variant = 'outline',\n  } = props;\n  const navigate = useNavigate();\n  const uuid = useId();\n\n  return (\n    <>\n      <Button\n        type=\"button\"\n        data-testid={isLoggedIn ? 'follow-button' : 'follow-redirect'}\n        data-cy={isLoggedIn ? 'follow-button' : 'follow-redirect'}\n        data-tooltip-id={uuid}\n        data-tooltip-content={isFollowing ? props.tooltipUnfollow : props.tooltipFollow}\n        variant={variant}\n        sx={{ fontSize: 2, ...sx }}\n        onClick={() =>\n          isLoggedIn\n            ? onFollowClick()\n            : navigate('/sign-in?returnUrl=' + encodeURIComponent(location.pathname))\n        }\n        icon={isFollowing ? 'thunderbolt' : 'thunderbolt-grey'}\n        small={!!props.small}\n      >\n        {isFollowing ? labelUnfollow : labelFollow}\n      </Button>\n      <Tooltip id={uuid} />\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/FollowIcon/FollowIcon.stories.tsx",
    "content": "import { FollowIcon } from './FollowIcon';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/FollowIcon',\n  component: FollowIcon,\n} as Meta<typeof FollowIcon>;\n\nexport const FollowingReplies: StoryFn<typeof FollowIcon> = () => <FollowIcon tooltip=\"Following replies\" />;\nexport const FollowingUpdates: StoryFn<typeof FollowIcon> = () => <FollowIcon tooltip=\"Following updates\" />;\n"
  },
  {
    "path": "packages/components/src/FollowIcon/FollowIcon.tsx",
    "content": "import { useId } from 'react';\nimport { Box, ThemeUIStyleObject } from 'theme-ui';\nimport { Icon } from '../Icon/Icon';\nimport { Tooltip } from '../Tooltip/Tooltip';\n\nexport interface FollowIconProps {\n  tooltip: string;\n  sx?: ThemeUIStyleObject;\n}\n\nexport const FollowIcon = ({ tooltip, sx }: FollowIconProps) => {\n  const uuid = useId();\n\n  return (\n    <>\n      <Box\n        data-testid=\"follow-icon\"\n        data-cy=\"follow-icon\"\n        data-tooltip-id={uuid}\n        data-tooltip-content={tooltip}\n        role=\"img\"\n        aria-label={tooltip}\n        sx={sx}\n      >\n        <Icon glyph=\"thunderbolt\" />\n      </Box>\n      <Tooltip id={uuid} />\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/GlobalStyles/GlobalStyles.tsx",
    "content": "import { css } from '@emotion/react';\nimport { commonStyles, GlobalFonts } from 'oa-themes';\n\nexport const GlobalStyles = css`\n  ${GlobalFonts}\n  html {\n    overflow-y: scroll;\n  }\n  body {\n    font-family: 'Varela Round', Arial, sans-serif;\n    background-color: ${commonStyles.colors.background};\n    margin: 0;\n    padding: 0;\n    min-height: 100vh;\n  }\n\n  a {\n    text-decoration: none;\n  }\n\n  * {\n    box-sizing: border-box;\n  }\n\n  .beta-tester-feature {\n    border: 4px dashed ${commonStyles.colors.betaGreen};\n  }\n\n  body:has(.beta-tester-feature) .user-beta-icon > span {\n    background-color: ${commonStyles.colors.betaGreen};\n  }\n\n  /***** Fix for Algolia search Icon *******/\n  .ap-icon-pin {\n    display: none;\n  }\n\n  /* Screen-reader text only - Taken from bootstrap 4 */\n  .sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    overflow: hidden;\n    clip: rect(0, 0, 0, 0);\n    white-space: nowrap;\n    border: 0;\n  }\n  dialog::backdrop {\n    background: rgba(0, 0, 0, 0.4);\n  }\n  dialog {\n    z-index: 4000;\n  }\n`;\n"
  },
  {
    "path": "packages/components/src/GridForm/GridForm.stories.tsx",
    "content": "import { Card, Checkbox, Text } from 'theme-ui';\n\nimport { GridForm } from './GridForm';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/GridForm',\n  component: GridForm,\n} as Meta<typeof GridForm>;\n\nexport const Default: StoryFn<typeof GridForm> = () => (\n  <Card sx={{ maxWidth: '600px', padding: 2 }}>\n    <GridForm\n      fields={[\n        {\n          glyph: 'discussion',\n          name: 'An Odd Row',\n          description: 'With a description.',\n          component: <Text sx={{ textAlign: 'center' }}>Any old component</Text>,\n        },\n        {\n          glyph: 'plastic',\n          name: 'An Even Row',\n          description: 'With a description.',\n          component: <Checkbox />,\n        },\n      ]}\n    />\n  </Card>\n);\n"
  },
  {
    "path": "packages/components/src/GridForm/GridForm.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { Box, Flex, Grid, Text } from 'theme-ui';\nimport { Icon } from '../Icon/Icon';\nimport type { availableGlyphs } from '../Icon/types';\n\nexport interface GridFormFields {\n  glyph: availableGlyphs;\n  name: string;\n  description: string;\n  component: ReactNode;\n}\n\nexport interface IProps {\n  fields: GridFormFields[];\n}\n\nexport const GridForm = ({ fields }: IProps) => {\n  return (\n    <>\n      {fields.map((field, index) => (\n        <Grid\n          key={index}\n          gap={2}\n          columns={[2, '80% 20%']}\n          sx={{\n            borderRadius: 1,\n            background: index % 2 == 0 ? 'softblue' : 'white',\n            padding: 4,\n          }}\n          data-cy={`field: ${field.name}`}\n        >\n          <Flex sx={{ gap: 2 }}>\n            <Icon glyph={field.glyph} size={20} />\n            <Box>\n              <Text as=\"h4\">{field.name}</Text>\n              <Text sx={{ color: 'GrayText', fontSize: 2 }}>{field.description}</Text>\n            </Box>\n          </Flex>\n          <Flex\n            sx={{\n              justifyContent: 'center',\n              alignItems: 'center',\n            }}\n          >\n            {field.component}\n          </Flex>\n        </Grid>\n      ))}\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Guidelines/Guidelines.stories.tsx",
    "content": "import { ExternalLink } from '../ExternalLink/ExternalLink';\nimport { Guidelines } from './Guidelines';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Forms/Guidelines',\n  component: Guidelines,\n} as Meta<typeof Guidelines>;\n\nexport const DefaultComponent = () => (\n  <Guidelines\n    title=\"How does it work?\"\n    steps={[\n      <>\n        Choose a topic you want to research{' '}\n        <span role=\"img\" aria-label=\"raised-hand\">\n          🙌\n        </span>\n      </>,\n      <>\n        Read{' '}\n        <ExternalLink sx={{ color: 'blue' }} href=\"/academy/guides/research\">\n          our guidelines{' '}\n          <span role=\"img\" aria-label=\"nerd-face\">\n            🤓\n          </span>\n        </ExternalLink>\n      </>,\n      <>\n        Write your introduction{' '}\n        <span role=\"img\" aria-label=\"archive-box\">\n          🗄️\n        </span>\n      </>,\n    ]}\n  />\n);\n\nexport const Default: StoryFn<typeof Guidelines> = () => <DefaultComponent />;\n"
  },
  {
    "path": "packages/components/src/Guidelines/Guidelines.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { DefaultComponent } from './Guidelines.stories';\n\ndescribe('Guidelines', () => {\n  it('validates the component behaviour', () => {\n    const { getByText } = render(<DefaultComponent />);\n\n    expect(getByText('How does it work?')).toBeInTheDocument();\n    expect(getByText('Choose a topic you want to research', { exact: false })).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/Guidelines/Guidelines.tsx",
    "content": "import { Card, Flex, Heading, Text } from 'theme-ui';\n\nexport interface IProps {\n  title: string;\n  steps: React.ReactElement[];\n}\n\nexport const Guidelines = ({ title, steps }: IProps) => {\n  return (\n    <Card>\n      <Flex sx={{ flexDirection: 'column', padding: [2, 3, 4], gap: 1 }}>\n        <Heading as=\"h2\" variant=\"h2\">\n          {title}\n        </Heading>\n\n        {steps.map((step, index) => {\n          return (\n            <Text variant=\"auxiliary\" sx={{ fontSize: 2 }} key={index}>\n              {`${index + 1}. `} {step}\n            </Text>\n          );\n        })}\n      </Flex>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Heading/Heading.stories.tsx",
    "content": "import { Heading } from 'theme-ui';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/Heading',\n  component: Heading,\n} as Meta<typeof Heading>;\n\nexport const Default: StoryFn<typeof Heading> = () => <Heading>Default Heading style</Heading>;\n\nexport const Small: StoryFn<typeof Heading> = () => (\n  <Heading variant=\"small\">Default Heading style</Heading>\n);\n"
  },
  {
    "path": "packages/components/src/HeroBanner/HeroBanner.stories.tsx",
    "content": "import { HeroBanner } from './HeroBanner';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/HeroBanner',\n  component: HeroBanner,\n} as Meta<typeof HeroBanner>;\n\nexport const Celebration: StoryFn<typeof HeroBanner> = () => <HeroBanner type=\"celebration\" />;\n\nexport const Email: StoryFn<typeof HeroBanner> = () => <HeroBanner type=\"email\" />;\n"
  },
  {
    "path": "packages/components/src/HeroBanner/HeroBanner.tsx",
    "content": "import { Image } from 'theme-ui';\n\nimport Celebration from '../../assets/images/celebration.svg';\nimport Email from '../../assets/images/email.svg';\n\nexport interface IProps {\n  type: 'celebration' | 'email';\n}\n\nexport const HeroBanner = ({ type }: IProps) => {\n  const src = {\n    celebration: Celebration,\n    email: Email,\n  };\n\n  return <Image loading=\"lazy\" src={src[type]} />;\n};\n"
  },
  {
    "path": "packages/components/src/Icon/DonateIcon.tsx",
    "content": "export const DonateIcon = () => (\n  <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path\n      d=\"M6.55931 21.4797C3.31477 20.0523 1.95371 18.4633 1.44851 16.081C0.681241 12.463 1.278 8.30075 3.35895 5.24497C4.66309 3.27261 7.44142 1.48715 9.39862 1.50452C14.0228 1.54557 16.1379 1.19413 19.1089 3.74496C22.9086 7.00716 23.6089 10.495 22.2 15.8647C21.4993 18.5349 20.1194 20.1941 18.0539 21.1006C15.8351 22.0744 11.1022 23.4785 6.55931 21.4797Z\"\n      fill=\"currentColor\"\n    />\n    <path\n      d=\"M9.40499 0.75455C11.6526 0.774501 13.4399 0.695603 14.9919 0.943514C16.6158 1.20296 18.0138 1.81637 19.5974 3.17594C21.5853 4.8827 22.816 6.69874 23.3357 8.83756C23.8509 10.9586 23.6454 13.311 22.9255 16.0548C22.1758 18.9119 20.6612 20.7761 18.3552 21.7882C16.0675 22.7923 11.0881 24.2916 6.25704 22.1662C4.58187 21.4292 3.33028 20.6239 2.42647 19.6481C1.51167 18.6604 0.990197 17.5386 0.714069 16.2365C-0.0887838 12.4502 0.525482 8.07212 2.73848 4.82242C3.46068 3.73372 4.56425 2.72784 5.73555 1.99381C6.89748 1.26567 8.22472 0.744074 9.40499 0.75455ZM9.3918 2.25455C8.61503 2.24765 7.5748 2.61073 6.53243 3.26383C5.50243 3.90929 4.56564 4.77859 3.9836 5.65885L3.97921 5.66764C2.03039 8.52938 1.45028 12.4763 2.18184 15.9259C2.41091 17.0059 2.82361 17.8695 3.52657 18.6286C4.24066 19.3996 5.29145 20.1032 6.86055 20.7936C11.115 22.6654 15.6016 21.3578 17.7517 20.4142C19.5767 19.6132 20.8222 18.1574 21.4738 15.674C22.1627 13.0485 22.3106 10.9727 21.8781 9.19205C21.4499 7.42911 20.4319 5.86947 18.6203 4.31412C17.2329 3.12292 16.0869 2.6373 14.7546 2.42447C13.3503 2.20023 11.7681 2.27564 9.3918 2.25455Z\"\n      fill=\"black\"\n    />\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M13.1092 3.74561C13.5234 3.7457 13.8592 4.08145 13.8592 4.49561C13.8592 4.61489 13.8283 4.72655 13.7787 4.82666C13.7669 4.87719 13.7483 4.96017 13.7274 5.08301C13.6853 5.33129 13.6345 5.69036 13.5751 6.15381C14.1625 6.19836 14.8025 6.25629 15.494 6.33838C15.905 6.38724 16.1986 6.75946 16.1503 7.17041C16.1014 7.58154 15.7293 7.87515 15.3182 7.82666C14.6165 7.74335 13.9764 7.68616 13.3978 7.64355C13.2854 8.62681 13.155 9.82391 13.0126 11.1958L13.0111 11.209L13.0082 11.2222C12.9912 11.3427 12.9757 11.4608 12.9598 11.5767C13.2419 11.641 13.5045 11.7034 13.7377 11.7583C14.2356 11.8757 14.6747 12.181 15.015 12.5317C15.361 12.8884 15.6506 13.3374 15.8309 13.8193C16.0102 14.2987 16.096 14.853 15.9774 15.3999C15.8542 15.9673 15.5193 16.4775 14.952 16.834C14.2015 17.3054 13.3372 17.5806 12.4896 17.7393C12.4636 18.2886 12.4241 18.8892 12.3548 19.5718C12.3126 19.9834 11.9448 20.2829 11.5331 20.2412C11.1213 20.1992 10.822 19.8312 10.8636 19.4194C10.9195 18.8683 10.952 18.3744 10.9764 17.915C10.9356 17.9172 10.8951 17.9206 10.8548 17.9224C9.55598 17.9781 8.44548 17.8387 8.03502 17.7788L7.98668 17.7715L7.93834 17.7583C7.65716 17.6779 7.33267 17.5418 7.00963 17.2192C6.70753 16.9175 6.44342 16.4892 6.18639 15.8936C6.02277 15.5134 6.1974 15.0717 6.5775 14.9077C6.95766 14.7439 7.39919 14.9203 7.56334 15.3003C7.78166 15.8062 7.95526 16.0424 8.07018 16.1572C8.15328 16.2402 8.22072 16.2729 8.31334 16.3022C8.71849 16.3588 9.68183 16.4714 10.7904 16.4238C10.8712 16.4204 10.9522 16.4129 11.0336 16.4077C11.0358 16.3309 11.04 16.2543 11.0423 16.1777V16.1733C11.0891 14.8854 11.1714 13.8537 11.3046 12.709C10.8929 12.594 10.4701 12.466 10.0697 12.312C9.46993 12.0814 8.86415 11.7842 8.38219 11.3906C7.89648 10.9938 7.48186 10.4515 7.39928 9.73828C7.25202 8.46522 7.84458 7.37124 8.45543 6.75C8.72835 6.47282 9.12371 6.33205 9.48229 6.24756C9.8695 6.15638 10.349 6.1011 10.9076 6.07764C11.2566 6.06299 11.6453 6.06137 12.0736 6.07178C12.1398 5.54993 12.1988 5.13041 12.2494 4.83252C12.2759 4.67626 12.303 4.53494 12.3314 4.42529C12.3443 4.37557 12.3659 4.29982 12.3988 4.22461C12.4136 4.19088 12.4486 4.11478 12.5116 4.03564C12.5521 3.98481 12.7493 3.74561 13.1092 3.74561ZM12.7738 13.0723C12.6589 14.0992 12.5849 15.0398 12.5423 16.1968C13.1297 16.0606 13.6847 15.8585 14.1537 15.564C14.378 15.4229 14.4746 15.2569 14.5126 15.082C14.5548 14.8868 14.5331 14.6313 14.4261 14.3452C14.3199 14.0613 14.1435 13.7877 13.9383 13.5762C13.7274 13.3588 13.5285 13.2491 13.3934 13.2173C13.2176 13.1758 13.0081 13.1251 12.7738 13.0723ZM10.9706 7.57617C10.4645 7.59743 10.088 7.64645 9.82652 7.70801C9.60811 7.75946 9.52744 7.80677 9.51598 7.81201L9.38561 7.9585C9.068 8.35335 8.81696 8.94096 8.88902 9.56543C8.91175 9.76172 9.02899 9.982 9.33141 10.229C9.63788 10.4793 10.0777 10.7075 10.6087 10.9116C10.8967 11.0223 11.1964 11.1192 11.4964 11.2075C11.5053 11.1437 11.5137 11.0793 11.5228 11.0142C11.6597 9.69625 11.7876 8.53571 11.8978 7.56885C11.5561 7.56296 11.2471 7.56457 10.9706 7.57617Z\"\n      fill=\"black\"\n    />\n  </svg>\n);\n"
  },
  {
    "path": "packages/components/src/Icon/DownloadIcon.tsx",
    "content": "import SvgDownloadIcon from '../../assets/icons/icon-download.svg';\n\nexport const DownloadIcon = () => (\n  <img alt=\"download-icon\" style={{ height: '100%' }} src={SvgDownloadIcon} />\n);\n"
  },
  {
    "path": "packages/components/src/Icon/ExternalUrl.tsx",
    "content": "import ImageTargetBlank from '../../assets/icons/link-target-blank.svg';\n\nexport const ExternalUrl = () => (\n  <img alt=\"link-target-blank\" style={{ height: '100%' }} src={ImageTargetBlank} />\n);\n"
  },
  {
    "path": "packages/components/src/Icon/Icon.stories.tsx",
    "content": "import { Flex } from 'theme-ui';\nimport { glyphs, Icon } from './Icon';\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport { Colors } from 'oa-themes';\n\nexport default {\n  title: 'Components/Icon',\n  component: Icon,\n} as Meta<typeof Icon>;\n\nexport const Default: StoryFn<typeof Icon> = () => <Icon glyph=\"delete\" />;\n\nexport const Sizes: StoryFn<typeof Icon> = () => (\n  <>\n    {['xl', 'lg', 'md', 'sm', 'xs'].map((size, key) => (\n      <Icon glyph=\"delete\" key={key} size={size} />\n    ))}\n  </>\n);\n\nexport const Available: StoryFn<typeof Icon> = () => (\n  <Flex sx={{ flexWrap: 'wrap', gap: 2 }}>\n    {Object.keys(glyphs).map((glyph: any, key) => (\n      <a key={key} title={glyph}>\n        <Icon glyph={glyph} size={30} />\n      </a>\n    ))}\n  </Flex>\n);\n\nexport const Colours: StoryFn<typeof Icon> = () => {\n  const colors: Colors[] = ['bluetag', 'silver', 'betaGreen', 'activeYellow'];\n\n  return (\n    <>\n      {colors.map((color, key) => (\n        <Icon glyph=\"delete\" key={key} color={color} size={'lg'} />\n      ))}\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Icon/Icon.tsx",
    "content": "/** @jsxImportSource theme-ui */\n\nimport styled from '@emotion/styled';\nimport type { Colors } from 'oa-themes';\nimport { IconContext } from 'react-icons';\nimport {\n  FaCloudUploadAlt,\n  FaFacebookF,\n  FaFilePdf,\n  FaFilter,\n  FaInstagram,\n  FaSignal,\n  FaSlack,\n} from 'react-icons/fa';\nimport {\n  MdAccessTime,\n  MdAccountCircle,\n  MdAdd,\n  MdArrowBack,\n  MdArrowForward,\n  MdCheck,\n  MdFileDownload,\n  MdImage,\n  MdKeyboardArrowDown,\n  MdLocationOn,\n  MdLock,\n  MdMailOutline,\n  MdMenu,\n  MdMoreVert,\n  MdNotifications,\n  MdTurnedIn,\n} from 'react-icons/md';\nimport type { SpaceProps, VerticalAlignProps } from 'styled-system';\nimport { space, verticalAlign } from 'styled-system';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { DonateIcon } from './DonateIcon';\nimport { DownloadIcon } from './DownloadIcon';\nimport { ExternalUrl } from './ExternalUrl';\nimport { iconMap } from './svgs';\nimport type { IGlyphs } from './types';\n\nexport interface IProps extends React.ButtonHTMLAttributes<HTMLElement> {\n  glyph: keyof IGlyphs;\n  color?: Colors;\n  filter?: string;\n  size?: number | string;\n  marginRight?: string;\n  opacity?: string;\n  onClick?: () => void;\n  sx?: ThemeUIStyleObject | undefined;\n}\n\nexport const glyphs: IGlyphs = {\n  'account-circle': <MdAccountCircle />,\n  add: <MdAdd />,\n  account: iconMap.account,\n  approved: iconMap.approved,\n  'arrow-back': <MdArrowBack />,\n  'arrow-down': <MdKeyboardArrowDown />,\n  'arrow-forward': <MdArrowForward />,\n  'arrow-full-down': iconMap.arrowFullDown,\n  'arrow-full-up': iconMap.arrowFullUp,\n  attention: iconMap.attention,\n  bazar: iconMap.bazar,\n  category: iconMap.category,\n  comment: iconMap.comment,\n  'comment-outline': iconMap.commentOutline,\n  donate: <DonateIcon />,\n  construction: iconMap.construction,\n  contact: iconMap.contact,\n  'copy-link': iconMap.copyLink,\n  check: <MdCheck />,\n  'chevron-down': iconMap.chevronDown,\n  'chevron-left': iconMap.chevronLeft,\n  'chevron-right': iconMap.chevronRight,\n  'chevron-up': iconMap.chevronUp,\n  close: iconMap.close,\n  'close-modal': iconMap.crossCloseModal,\n  declined: iconMap.declined,\n  delete: iconMap.delete,\n  difficulty: <FaSignal />,\n  discord: iconMap.discord,\n  discussion: iconMap.discussion,\n  doubleTick: iconMap.doubleTick,\n  download: <MdFileDownload />,\n  'download-cloud': <DownloadIcon />,\n  'double-arrow-left': iconMap.doubleArrowLeft,\n  'double-arrow-right': iconMap.doubleArrowRight,\n  edit: iconMap.edit,\n  email: iconMap.email,\n  employee: iconMap.employee,\n  'email-outline': iconMap.emailOutline,\n  'external-url': <ExternalUrl />,\n  facebook: <FaFacebookF />,\n  filter: <FaFilter />,\n  'flag-unknown': iconMap.flagUnknown,\n  food: iconMap.food,\n  'version 5': iconMap.fromTheTeam,\n  globe: iconMap.globe,\n  'gps-location': iconMap.gpsLocation,\n  guides: iconMap.guides,\n  hide: iconMap.hide,\n  hyperlink: iconMap.hyperlink,\n  information: iconMap.information,\n  image: <MdImage />,\n  impact: iconMap.impact,\n  instagram: <FaInstagram />,\n  landscape: iconMap.landscape,\n  'location-on': <MdLocationOn />,\n  lock: <MdLock />,\n  machine: iconMap.machine,\n  machines: iconMap.machines,\n  'mail-outline': <MdMailOutline />,\n  map: iconMap.map,\n  megaphone: iconMap.megaphone,\n  'megaphone-active': iconMap.megaphoneActive,\n  'megaphone-inactive': iconMap.megaphoneInactive,\n  menu: <MdMenu />,\n  moulds: iconMap.moulds,\n  'more-vert': <MdMoreVert />,\n  notifications: <MdNotifications />,\n  other: iconMap.other,\n  patreon: iconMap.patreon,\n  pdf: <FaFilePdf />,\n  plastic: iconMap.plastic,\n  products: iconMap.products,\n  profile: iconMap.profile,\n  recycling: iconMap.recycling,\n  reply: iconMap.reply,\n  'reply-outline': iconMap.replyOutline,\n  report: iconMap.report,\n  research: iconMap.research,\n  revenue: iconMap.revenue,\n  search: iconMap.search,\n  'service-email': iconMap.serviceEmail,\n  slack: <FaSlack />,\n  sliders: iconMap.sliders,\n  star: iconMap.star,\n  'starter kits': iconMap.starterKits,\n  'star-active': iconMap.starActive,\n  step: iconMap.step,\n  thunderbolt: iconMap.thunderbolt,\n  'thunderbolt-grey': iconMap.thunderboltGrey,\n  time: <MdAccessTime />,\n  'turned-in': <MdTurnedIn />,\n  'social-media': iconMap.socialMedia,\n  supporter: iconMap.supporter,\n  show: iconMap.show,\n  update: iconMap.update,\n  upload: <FaCloudUploadAlt />,\n  utilities: iconMap.utilities,\n  useful: iconMap.useful,\n  verified: iconMap.verified,\n  volunteer: iconMap.volunteer,\n  website: iconMap.website,\n  paginationSingleLeft: iconMap.paginationSingleLeft,\n  paginationSingleRight: iconMap.paginationSingleRight,\n  success: iconMap.success,\n  error: iconMap.error,\n  warning: iconMap.warning,\n  info: iconMap.info,\n  loading: iconMap.loading,\n};\n\nexport type IconProps = IProps & VerticalAlignProps & SpaceProps;\n\nconst IconWrapper = styled.div<IconProps>`\n  display: inline-block;\n  flex: 0 0 ${(props) => (props.size ? `${props.size}px` : '32px')};\n  width: ${(props) => (props.size ? `${props.size}px` : '32px')};\n  height: ${(props) => (props.size ? `${props.size}px` : '32px')};\n  min-width: ${(props) => (props.size ? `${props.size}px` : '32px')};\n  min-height: ${(props) => (props.size ? `${props.size}px` : '32px')};\n  position: relative;\n  ${verticalAlign} ${space}\n    ${(props) =>\n      props.onClick &&\n      `\n    cursor: pointer;\n  `};\n`;\n\nconst sizeMap = {\n  xs: 8,\n  sm: 16,\n  md: 32,\n  lg: 48,\n  xl: 64,\n};\n\nexport const getGlyph = (glyph: string) => {\n  return glyph in glyphs ? glyphs[glyph as keyof IGlyphs] : null;\n};\n\nexport const Icon = (props: IconProps) => {\n  const { glyph, size, sx } = props;\n\n  if (!getGlyph(glyph)) {\n    return null;\n  }\n\n  const isSizeNumeric = !isNaN(size as any);\n\n  let definedSize = 16;\n  if (isSizeNumeric) {\n    definedSize = size as number;\n  } else if (Object.keys(sizeMap).includes(size as string)) {\n    const pointer = size as 'xs' | 'sm' | 'md' | 'lg' | 'xl';\n    definedSize = sizeMap[pointer];\n  }\n\n  return (\n    <IconWrapper\n      {...props}\n      sx={{\n        color: props.color ?? 'inherit',\n        opacity: props.opacity ?? '1',\n        '& svg': {\n          fontSize: definedSize,\n        },\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        filter: props.filter ?? 'unset',\n        ...sx,\n      }}\n      size={definedSize}\n    >\n      <IconContext.Provider\n        value={{\n          style: {\n            width: definedSize,\n            height: definedSize,\n          },\n        }}\n      >\n        {getGlyph(glyph)}\n      </IconContext.Provider>\n    </IconWrapper>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Icon/svgs.tsx",
    "content": "import React from 'react';\nimport accountSVG from '../../assets/icons/account.svg';\nimport approvedSVG from '../../assets/icons/approved.svg';\nimport attentionSVG from '../../assets/icons/attention.svg';\nimport categorySVG from '../../assets/icons/category.svg';\nimport chevronDownSVG from '../../assets/icons/chevron-down.svg';\nimport chevronLeftSVG from '../../assets/icons/chevron-left.svg';\nimport chevronRightSVG from '../../assets/icons/chevron-right.svg';\nimport chevronUpSVG from '../../assets/icons/chevron-up.svg';\nimport collaboratorSVG from '../../assets/icons/collaborator.svg';\nimport commentOutlineSVG from '../../assets/icons/comment-outline.svg';\nimport constructionSVG from '../../assets/icons/construction.svg';\nimport contactSVG from '../../assets/icons/contact.svg';\nimport copyLinkSVG from '../../assets/icons/copy-link.svg';\nimport closeSVG from '../../assets/icons/cross-close.svg';\nimport crossCloseModalSVG from '../../assets/icons/cross-close-modal.svg';\nimport declinedSVG from '../../assets/icons/declined.svg';\nimport deleteSVG from '../../assets/icons/delete.svg';\nimport discussionSVG from '../../assets/icons/discussion.svg';\nimport doubleArrowLeft from '../../assets/icons/double-arrow-left.svg';\nimport doubleArrowRight from '../../assets/icons/double-arrow-right.svg';\nimport doubleTickSVG from '../../assets/icons/double-tick.svg';\nimport editSVG from '../../assets/icons/edit.svg';\nimport emailSVG from '../../assets/icons/email.svg';\nimport employeeSVG from '../../assets/icons/employee.svg';\nimport errorSVG from '../../assets/icons/error.svg';\nimport eyeSVG from '../../assets/icons/eye.svg';\nimport eyeCrossedSVG from '../../assets/icons/eye-crossed.svg';\nimport flagUnknownSVG from '../../assets/icons/flag-unknown.svg';\nimport foodSVG from '../../assets/icons/food.svg';\nimport fromTheTeamSVG from '../../assets/icons/from-the-team.svg';\nimport guidesSVG from '../../assets/icons/guides.svg';\nimport hyperlinkSVG from '../../assets/icons/hyperlink.svg';\nimport arrowFullDownSVG from '../../assets/icons/icon-arrow-down.svg';\nimport arrowFullUpSVG from '../../assets/icons/icon-arrow-up.svg';\nimport bazarSVG from '../../assets/icons/icon-bazar.svg';\nimport commentSVG from '../../assets/icons/icon-comment.svg';\nimport discordSVG from '../../assets/icons/icon-discord.svg';\nimport emailOutlineSVG from '../../assets/icons/icon-email-outline.svg';\nimport researchSVG from '../../assets/icons/icon-research.svg';\nimport searchSVG from '../../assets/icons/icon-search.svg';\nimport socialMediaSVG from '../../assets/icons/icon-social-media.svg';\nimport starActiveSVG from '../../assets/icons/icon-star-active.svg';\nimport starSVG from '../../assets/icons/icon-star-default.svg';\nimport updateSVG from '../../assets/icons/icon-update.svg';\nimport usefulSVG from '../../assets/icons/icon-useful.svg';\nimport verifiedSVG from '../../assets/icons/icon-verified-badge.svg';\nimport websiteSVG from '../../assets/icons/icon-website.svg';\nimport impactSVG from '../../assets/icons/impact.svg';\nimport infoSVG from '../../assets/icons/info.svg';\nimport informationSVG from '../../assets/icons/information.svg';\nimport landscapeSVG from '../../assets/icons/landscape.svg';\nimport loadingSVG from '../../assets/icons/loading.svg';\nimport machineSVG from '../../assets/icons/machine.svg';\nimport machinesSVG from '../../assets/icons/machines.svg';\nimport mapSVG from '../../assets/icons/map.svg';\nimport globe from '../../assets/icons/map-globe.svg';\nimport gpsLocation from '../../assets/icons/map-gpsLocation.svg';\nimport megaphoneSVG from '../../assets/icons/megaphone.svg';\nimport megaphoneActiveSVG from '../../assets/icons/megaphone-active.svg';\nimport megaphoneInactiveSVG from '../../assets/icons/megaphone-inactive.svg';\nimport mouldsSVG from '../../assets/icons/moulds.svg';\nimport otherSVG from '../../assets/icons/other.svg';\nimport paginationSingleLeftSVG from '../../assets/icons/pagination-arrow-left.svg';\nimport paginationSingleRightSVG from '../../assets/icons/pagination-arrow-right.svg';\nimport patreonSVG from '../../assets/icons/patreon.svg';\nimport plasticSVG from '../../assets/icons/plastic.svg';\nimport productsSVG from '../../assets/icons/products.svg';\nimport profileSVG from '../../assets/icons/profile.svg';\nimport recyclingSVG from '../../assets/icons/recycling.svg';\nimport replySVG from '../../assets/icons/reply.svg';\nimport replyOutlineSVG from '../../assets/icons/reply-outline.svg';\nimport reportSVG from '../../assets/icons/report.svg';\nimport revenueSVG from '../../assets/icons/revenue.svg';\nimport serviceEmailSVG from '../../assets/icons/service-email.svg';\nimport slidersSVG from '../../assets/icons/sliders.svg';\nimport starterKitsSVG from '../../assets/icons/starter-kits.svg';\nimport stepSVG from '../../assets/icons/step.svg';\nimport successSVG from '../../assets/icons/success.svg';\nimport supporterSVG from '../../assets/icons/supporter.svg';\nimport thunderboltSVG from '../../assets/icons/thunderbolt.svg';\nimport thunderboltGreySVG from '../../assets/icons/thunderbolt-grey.svg';\nimport utilitiesSVG from '../../assets/icons/utilities.svg';\nimport visitorsAppointmentSVG from '../../assets/icons/visitors-appointment.svg';\nimport visitorsClosedSVG from '../../assets/icons/visitors-closed.svg';\nimport visitorsOpenSVG from '../../assets/icons/visitors-open.svg';\nimport volunteerSVG from '../../assets/icons/volunteer.svg';\nimport warningSVG from '../../assets/icons/warning.svg';\n\nconst imgStyle = {\n  maxWidth: '100%',\n};\ninterface IProps {\n  src: string;\n  style?: React.CSSProperties;\n}\n\nconst ImageIcon = (props: IProps) => {\n  return <img alt=\"icon\" {...props} style={{ ...imgStyle, ...props.style }} />;\n};\n\nexport const iconMap = {\n  approved: <ImageIcon src={approvedSVG} />,\n  arrowFullDown: <ImageIcon src={arrowFullDownSVG} />,\n  arrowFullUp: <ImageIcon src={arrowFullUpSVG} />,\n  attention: <ImageIcon src={attentionSVG} />,\n  account: <ImageIcon src={accountSVG} />,\n  bazar: <ImageIcon src={bazarSVG} />,\n  category: <ImageIcon src={categorySVG} data-testid=\"category-icon\" />,\n  chevronDown: <ImageIcon src={chevronDownSVG} />,\n  chevronLeft: <ImageIcon src={chevronLeftSVG} />,\n  chevronRight: <ImageIcon src={chevronRightSVG} />,\n  chevronUp: <ImageIcon src={chevronUpSVG} />,\n  collaborator: <ImageIcon src={collaboratorSVG} />,\n  close: <ImageIcon src={closeSVG} data-cy=\"close\" />,\n  crossCloseModal: <ImageIcon src={crossCloseModalSVG} data-cy=\"close-modal\" />,\n  comment: <ImageIcon src={commentSVG} />,\n  commentOutline: <ImageIcon src={commentOutlineSVG} />,\n  construction: <ImageIcon src={constructionSVG} />,\n  contact: <ImageIcon src={contactSVG} />,\n  copyLink: <ImageIcon src={copyLinkSVG} />,\n  declined: <ImageIcon src={declinedSVG} />,\n  delete: <ImageIcon src={deleteSVG} />,\n  discord: <ImageIcon src={discordSVG} />,\n  discussion: <ImageIcon src={discussionSVG} />,\n  doubleTick: <ImageIcon src={doubleTickSVG} />,\n  doubleArrowLeft: (\n    <ImageIcon\n      src={doubleArrowLeft}\n      style={{\n        maxWidth: 'none',\n      }}\n    />\n  ),\n  doubleArrowRight: (\n    <ImageIcon\n      src={doubleArrowRight}\n      style={{\n        maxWidth: 'none',\n      }}\n    />\n  ),\n  edit: <ImageIcon src={editSVG} />,\n  email: <ImageIcon src={emailSVG} />,\n  emailOutline: <ImageIcon src={emailOutlineSVG} />,\n  employee: <ImageIcon src={employeeSVG} />,\n  flagUnknown: <ImageIcon src={flagUnknownSVG} />,\n  food: <ImageIcon src={foodSVG} />,\n  fromTheTeam: <ImageIcon src={fromTheTeamSVG} />,\n  globe: <ImageIcon src={globe} />,\n  gpsLocation: <ImageIcon src={gpsLocation} />,\n  guides: <ImageIcon src={guidesSVG} />,\n  hide: <ImageIcon src={eyeCrossedSVG} />,\n  hyperlink: <ImageIcon src={hyperlinkSVG} />,\n  impact: <ImageIcon src={impactSVG} />,\n  information: <ImageIcon src={informationSVG} />,\n  landscape: <ImageIcon src={landscapeSVG} />,\n  machine: <ImageIcon src={machineSVG} />,\n  machines: <ImageIcon src={machinesSVG} />,\n  map: <ImageIcon src={mapSVG} />,\n  megaphone: <ImageIcon src={megaphoneSVG} />,\n  megaphoneActive: <ImageIcon src={megaphoneActiveSVG} />,\n  megaphoneInactive: <ImageIcon src={megaphoneInactiveSVG} />,\n  moulds: <ImageIcon src={mouldsSVG} />,\n  other: <ImageIcon src={otherSVG} />,\n  patreon: <ImageIcon src={patreonSVG} />,\n  paginationSingleLeft: (\n    <ImageIcon\n      src={paginationSingleLeftSVG}\n      style={{\n        maxWidth: 'none',\n      }}\n    />\n  ),\n  paginationSingleRight: (\n    <ImageIcon\n      src={paginationSingleRightSVG}\n      style={{\n        maxWidth: 'none',\n      }}\n    />\n  ),\n  plastic: <ImageIcon src={plasticSVG} />,\n  profile: <ImageIcon src={profileSVG} />,\n  products: <ImageIcon src={productsSVG} />,\n  recycling: <ImageIcon src={recyclingSVG} />,\n  reply: <ImageIcon src={replySVG} />,\n  replyOutline: <ImageIcon src={replyOutlineSVG} />,\n  report: <ImageIcon src={reportSVG} />,\n  research: <ImageIcon src={researchSVG} />,\n  revenue: <ImageIcon src={revenueSVG} />,\n  search: <ImageIcon src={searchSVG} />,\n  serviceEmail: <ImageIcon src={serviceEmailSVG} />,\n  show: <ImageIcon src={eyeSVG} />,\n  sliders: <ImageIcon src={slidersSVG} />,\n  socialMedia: <ImageIcon src={socialMediaSVG} />,\n  star: <ImageIcon src={starSVG} />,\n  starActive: <ImageIcon src={starActiveSVG} />,\n  starterKits: <ImageIcon src={starterKitsSVG} />,\n  step: <ImageIcon src={stepSVG} />,\n  supporter: <ImageIcon src={supporterSVG} />,\n  thunderbolt: <ImageIcon src={thunderboltSVG} />,\n  thunderboltGrey: <ImageIcon src={thunderboltGreySVG} />,\n  update: <ImageIcon src={updateSVG} />,\n  useful: <ImageIcon src={usefulSVG} />,\n  utilities: <ImageIcon src={utilitiesSVG} />,\n  verified: <ImageIcon src={verifiedSVG} />,\n  volunteer: <ImageIcon src={volunteerSVG} />,\n  visitorsAppointment: <ImageIcon src={visitorsAppointmentSVG} />,\n  visitorsClosed: <ImageIcon src={visitorsClosedSVG} />,\n  visitorsOpen: <ImageIcon src={visitorsOpenSVG} />,\n  website: <ImageIcon src={websiteSVG} />,\n  success: <ImageIcon src={successSVG} />,\n  error: <ImageIcon src={errorSVG} />,\n  warning: <ImageIcon src={warningSVG} />,\n  info: <ImageIcon src={infoSVG} />,\n  loading: <ImageIcon src={loadingSVG} />,\n};\n"
  },
  {
    "path": "packages/components/src/Icon/types.ts",
    "content": "import type { JSX } from 'react';\n\nexport type availableGlyphs =\n  | 'account-circle'\n  | 'account'\n  | 'add'\n  | 'approved'\n  | 'arrow-back'\n  | 'arrow-down'\n  | 'arrow-forward'\n  | 'arrow-full-down'\n  | 'arrow-full-up'\n  | 'attention'\n  | 'bazar'\n  | 'category'\n  | 'check'\n  | 'chevron-down'\n  | 'chevron-left'\n  | 'chevron-right'\n  | 'chevron-up'\n  | 'close'\n  | 'close-modal'\n  | 'comment'\n  | 'comment-outline'\n  | 'construction'\n  | 'contact'\n  | 'discord'\n  | 'discussion'\n  | 'declined'\n  | 'delete'\n  | 'difficulty'\n  | 'donate'\n  | 'doubleTick'\n  | 'download'\n  | 'download-cloud'\n  | 'edit'\n  | 'email'\n  | 'email-outline'\n  | 'employee'\n  | 'external-url'\n  | 'facebook'\n  | 'filter'\n  | 'flag-unknown'\n  | 'food'\n  | 'version 5'\n  | 'guides'\n  | 'hide'\n  | 'hyperlink'\n  | 'image'\n  | 'impact'\n  | 'information'\n  | 'instagram'\n  | 'landscape'\n  | 'location-on'\n  | 'lock'\n  | 'machine'\n  | 'machines'\n  | 'mail-outline'\n  | 'map'\n  | 'megaphone'\n  | 'megaphone-active'\n  | 'megaphone-inactive'\n  | 'menu'\n  | 'more-vert'\n  | 'moulds'\n  | 'notifications'\n  | 'other'\n  | 'patreon'\n  | 'pdf'\n  | 'plastic'\n  | 'profile'\n  | 'products'\n  | 'recycling'\n  | 'reply'\n  | 'reply-outline'\n  | 'report'\n  | 'research'\n  | 'revenue'\n  | 'service-email'\n  | 'show'\n  | 'slack'\n  | 'sliders'\n  | 'social-media'\n  | 'star'\n  | 'star-active'\n  | 'step'\n  | 'supporter'\n  | 'thunderbolt'\n  | 'thunderbolt-grey'\n  | 'time'\n  | 'turned-in'\n  | 'update'\n  | 'upload'\n  | 'utilities'\n  | 'useful'\n  | 'verified'\n  | 'volunteer'\n  | 'website'\n  | 'search'\n  | 'starter kits'\n  | 'globe'\n  | 'gps-location'\n  | 'copy-link'\n  | 'double-arrow-right'\n  | 'double-arrow-left'\n  | 'paginationSingleLeft'\n  | 'paginationSingleRight'\n  | 'success'\n  | 'error'\n  | 'warning'\n  | 'info'\n  | 'loading';\n\nexport type IGlyphs = { [k in availableGlyphs]: JSX.Element };\n"
  },
  {
    "path": "packages/components/src/IconCountWithTooltip/IconCountWithTooltip.stories.tsx",
    "content": "import { IconCountWithTooltip } from './IconCountWithTooltip';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/IconCountWithTooltip',\n  component: IconCountWithTooltip,\n} as Meta<typeof IconCountWithTooltip>;\n\nexport const Default: StoryFn<typeof IconCountWithTooltip> = () => (\n  <IconCountWithTooltip count={345} icon=\"show\" text=\"Number of Views\" />\n);\n\nexport const LargeCount: StoryFn<typeof IconCountWithTooltip> = () => (\n  <IconCountWithTooltip count={1500} icon=\"show\" text=\"Number of Views\" />\n);\n\nexport const VeryLargeCount: StoryFn<typeof IconCountWithTooltip> = () => (\n  <IconCountWithTooltip count={2099999} icon=\"show\" text=\"Number of Views\" />\n);\n"
  },
  {
    "path": "packages/components/src/IconCountWithTooltip/IconCountWithTooltip.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { IconCountWithTooltip } from './IconCountWithTooltip';\n\ndescribe('IconCountWithTooltip', () => {\n  it('validates the component behaviour', () => {\n    const { getByText } = render(\n      <IconCountWithTooltip count={345} icon=\"show\" text=\"Number of Views\" />,\n    );\n\n    expect(getByText('345')).toBeInTheDocument();\n  });\n\n  it('displays the correct count format', () => {\n    const { getByText, rerender } = render(\n      <IconCountWithTooltip count={1500} icon=\"show\" text=\"Number of Views\" />,\n    );\n\n    expect(getByText('1.5K')).toBeInTheDocument();\n\n    rerender(<IconCountWithTooltip count={2099999} icon=\"show\" text=\"Number of Views\" />);\n\n    expect(getByText('2.1M')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/IconCountWithTooltip/IconCountWithTooltip.tsx",
    "content": "import { useId } from 'react';\nimport { Text } from 'theme-ui';\n\nimport { Icon } from '../Icon/Icon';\nimport type { availableGlyphs } from '../Icon/types';\nimport { Tooltip } from '../Tooltip/Tooltip';\n\nexport interface IconCountWithTooltipProps {\n  count: number;\n  dataCy?: string;\n  icon: availableGlyphs;\n  text: string;\n}\n\nfunction shortFormatNumber(num: number): string {\n  const units = [\n    { value: 1000000, suffix: 'M' },\n    { value: 1000, suffix: 'K' },\n  ];\n\n  for (const { value, suffix } of units) {\n    if (num >= value) {\n      return (num / value).toFixed(1).replace(/\\.0$/, '') + suffix;\n    }\n  }\n\n  return num.toString();\n}\n\nexport const IconCountWithTooltip = (props: IconCountWithTooltipProps) => {\n  const { count, dataCy, icon, text } = props;\n  const id = useId();\n  const countText = shortFormatNumber(count);\n\n  return (\n    <>\n      <Text\n        data-cy={dataCy}\n        data-tooltip-id={id}\n        data-tooltip-content={text}\n        color=\"black\"\n        sx={{\n          display: 'flex',\n          position: 'relative',\n          alignItems: 'center',\n          fontSize: [1, 2, 2],\n        }}\n      >\n        {countText}\n        <Icon glyph={icon} ml={1} />\n      </Text>\n      <Tooltip id={id} />\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ImageGallery/ImageGallery.stories.tsx",
    "content": "import { ImageGallery } from './ImageGallery';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { IImageGalleryItem, ImageGalleryProps } from './ImageGallery';\n\nconst imageUrls = [\n  {\n    full: 'https://picsum.photos/id/29/1500/1000',\n    thumb: 'https://picsum.photos/id/29/150/150',\n  },\n  {\n    full: 'https://picsum.photos/id/50/4000/3000',\n    thumb: 'https://picsum.photos/id/50/150/150',\n  },\n  {\n    full: 'https://picsum.photos/id/110/800/1200',\n    thumb: 'https://picsum.photos/id/110/150/150',\n  },\n  {\n    full: 'https://picsum.photos/id/2/1500/1500',\n    thumb: 'https://picsum.photos/id/2/150/150',\n  },\n];\n\n// eslint-disable-next-line storybook/prefer-pascal-case\nexport const testImages: IImageGalleryItem[] = imageUrls.map((elt, i) => {\n  return {\n    downloadUrl: elt.full,\n    contentType: 'image/jpeg',\n    fullPath: 'cat.jpg',\n    name: 'cat' + i,\n    type: 'image/jpeg',\n    size: 115000,\n    thumbnailUrl: elt.thumb,\n    timeCreated: new Date().toISOString(),\n    updated: new Date().toISOString(),\n  };\n});\n\nexport default {\n  title: 'Layout/ImageGallery',\n  component: ImageGallery,\n} as Meta<typeof ImageGallery>;\n\nexport const Default: StoryFn<typeof ImageGallery> = (props: Omit<ImageGalleryProps, 'images'>) => {\n  return <ImageGallery images={testImages} {...props} />;\n};\n\nexport const NoThumbnails: StoryFn<typeof ImageGallery> = (\n  props: Omit<ImageGalleryProps, 'images'>,\n) => {\n  return <ImageGallery images={testImages} {...props} hideThumbnails />;\n};\n\nexport const HideThumbnailForSingleImage: StoryFn<typeof ImageGallery> = (\n  props: Omit<ImageGalleryProps, 'images'>,\n) => {\n  return <ImageGallery images={[testImages[0]]} {...props} />;\n};\n\nexport const ShowNextPrevButtons: StoryFn<typeof ImageGallery> = (\n  props: Omit<ImageGalleryProps, 'images'>,\n) => {\n  return <ImageGallery images={testImages} {...props} hideThumbnails showNextPrevButton={true} />;\n};\n\nexport const DoNotShowNextPrevButtons: StoryFn<typeof ImageGallery> = (\n  props: Omit<ImageGalleryProps, 'images'>,\n) => {\n  return (\n    <ImageGallery images={[testImages[0]]} {...props} hideThumbnails showNextPrevButton={true} />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ImageGallery/ImageGallery.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { act, waitFor } from '@testing-library/react';\nimport { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { ImageGallery } from './ImageGallery';\nimport { testImages } from './ImageGallery.stories';\n\ndescribe('ImageGallery', () => {\n  beforeAll(() => {\n    Object.defineProperty(window, 'matchMedia', {\n      writable: true,\n      value: vi.fn().mockImplementation((query) => ({\n        matches: false,\n        media: query,\n        onchange: null,\n        addListener: vi.fn(), // Deprecated\n        removeListener: vi.fn(), // Deprecated\n        addEventListener: vi.fn(),\n        removeEventListener: vi.fn(),\n        dispatchEvent: vi.fn(),\n      })),\n    });\n  });\n\n  beforeEach(() => {\n    // Clean up any existing PhotoSwipe instance\n    if ((global.window as any).pswp) {\n      (global.window as any).pswp.destroy();\n      delete (global.window as any).pswp;\n    }\n  });\n\n  it('handles empty image prop', () => {\n    const { container } = render(<ImageGallery images={undefined as any} />);\n\n    expect(container).toBeEmptyDOMElement();\n  });\n\n  it('renders correct image after clicking in its thumbnail', async () => {\n    const { getByTestId, getAllByTestId } = render(<ImageGallery images={testImages} />);\n    const mainImage = getByTestId('active-image');\n    expect(mainImage).toBeInTheDocument();\n\n    const thumbnails = getAllByTestId('thumbnail');\n    const firstThumbnail = thumbnails[0];\n\n    act(() => {\n      firstThumbnail.click();\n    });\n\n    await waitFor(() => {\n      expect(mainImage.getAttribute('src')).toEqual(testImages[0].downloadUrl);\n    });\n\n    const thirdThumbnail = thumbnails[2];\n    act(() => {\n      thirdThumbnail.click();\n    });\n\n    await waitFor(() => {\n      expect(mainImage.getAttribute('src')).toEqual(testImages[2].downloadUrl);\n    });\n  });\n\n  it('displays correct image in lightbox after clicking on the main image', async () => {\n    const { findByRole, getByTestId } = render(\n      <ImageGallery\n        images={testImages}\n        photoSwipeOptions={{\n          // Forces a viewport size so that the images can be loaded\n          getViewportSizeFn: function () {\n            return {\n              x: 200,\n              y: 200,\n            };\n          },\n        }}\n      />,\n    );\n\n    const mainImage = getByTestId('active-image');\n    expect(mainImage).toBeInTheDocument();\n\n    mainImage.click();\n\n    const lightboxDialog = await findByRole('dialog');\n    expect(lightboxDialog).toBeInTheDocument();\n\n    const group = await findByRole('group', { hidden: false });\n    expect(group).toBeInTheDocument();\n\n    // PhotoSwipe doesn't expose images with proper accessibility roles, so we query directly\n    // Wait for the actual image (not placeholder) to be loaded\n    await waitFor(() => {\n      const image = group.querySelector('img.pswp__img:not(.pswp__img--placeholder)');\n      expect(image).toBeInTheDocument();\n      expect(image?.getAttribute('src')).toEqual(mainImage.getAttribute('src'));\n    });\n  });\n\n  it('switches images in the lightbox after clicking on the next and previous arrows', async () => {\n    const { findByRole, getByTestId, getByLabelText, getAllByRole } = render(\n      <ImageGallery\n        images={testImages}\n        photoSwipeOptions={{\n          // Forces a viewport size so that the images can be loaded\n          getViewportSizeFn: function () {\n            return {\n              x: 200,\n              y: 200,\n            };\n          },\n        }}\n      />,\n    );\n\n    const mainImage = getByTestId('active-image');\n    expect(mainImage).toBeInTheDocument();\n\n    mainImage.click();\n\n    const lightboxDialog = await findByRole('dialog');\n    expect(lightboxDialog).toBeInTheDocument();\n\n    // Find the active slide (aria-hidden=\"false\")\n    await waitFor(() => {\n      const groups = getAllByRole('group', { hidden: false });\n      const activeGroup = groups.find((g) => g.getAttribute('aria-hidden') === 'false');\n      expect(activeGroup).toBeInTheDocument();\n\n      const image = activeGroup?.querySelector('img.pswp__img:not(.pswp__img--placeholder)');\n      expect(image).toBeInTheDocument();\n      expect(image?.getAttribute('src')).toEqual(mainImage.getAttribute('src'));\n    });\n\n    // Clicks on the next button\n    const nextImageButton = getByLabelText('Next');\n    act(() => {\n      nextImageButton.click();\n    });\n\n    // Verifies that the image shown in the lightbox is the next image\n    await waitFor(() => {\n      const groups = getAllByRole('group', { hidden: false });\n      const activeGroup = groups.find((g) => g.getAttribute('aria-hidden') === 'false');\n      expect(activeGroup).toBeInTheDocument();\n\n      const image = activeGroup?.querySelector('img.pswp__img:not(.pswp__img--placeholder)');\n      expect(image).toBeInTheDocument();\n      expect(image?.getAttribute('src')).toEqual(testImages[1].downloadUrl);\n    });\n\n    // Clicks on the previous button\n    const previousImageButton = getByLabelText('Previous');\n    act(() => {\n      previousImageButton.click();\n    });\n\n    // Verifies that the image shown in the lightbox is the previous image\n    await waitFor(() => {\n      const groups = getAllByRole('group', { hidden: false });\n      const activeGroup = groups.find((g) => g.getAttribute('aria-hidden') === 'false');\n      expect(activeGroup).toBeInTheDocument();\n\n      const image = activeGroup?.querySelector('img.pswp__img:not(.pswp__img--placeholder)');\n      expect(image).toBeInTheDocument();\n      expect(image?.getAttribute('src')).toEqual(testImages[0].downloadUrl);\n    });\n  });\n\n  it('hides thumbnail for single image', () => {\n    const { getAllByTestId } = render(<ImageGallery images={[testImages[0]]} />);\n\n    expect(() => {\n      getAllByTestId('thumbnail');\n    }).toThrow();\n  });\n\n  it('supports no thumbnail option', () => {\n    const { getAllByTestId } = render(<ImageGallery images={testImages} hideThumbnails />);\n\n    expect(() => {\n      getAllByTestId('thumbnail');\n    }).toThrow();\n  });\n\n  it('supports show next/previous buttons', () => {\n    const { getByRole } = render(\n      <ImageGallery images={testImages} hideThumbnails showNextPrevButton={true} />,\n    );\n\n    const nextBtn = getByRole('button', { name: 'Next image' });\n    const previousBtn = getByRole('button', { name: 'Previous image' });\n\n    expect(nextBtn).toBeInTheDocument();\n    expect(previousBtn).toBeInTheDocument();\n  });\n\n  it('does not support show next/previous buttons because only one image', () => {\n    const { queryByRole } = render(\n      <ImageGallery images={[testImages[0]]} hideThumbnails showNextPrevButton={true} />,\n    );\n\n    const nextBtn = queryByRole('button', { name: 'Next image' });\n    const previousBtn = queryByRole('button', { name: 'Previous image' });\n\n    expect(nextBtn).not.toBeInTheDocument();\n    expect(previousBtn).not.toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/ImageGallery/ImageGallery.tsx",
    "content": "import styled from '@emotion/styled';\nimport type { PhotoSwipeOptions } from 'photoswipe/lightbox';\nimport { useLayoutEffect, useRef, useState } from 'react';\nimport { Flex, Image as ThemeImage } from 'theme-ui';\nimport { Arrow } from '../ArrowIcon/ArrowIcon';\nimport { usePhotoSwipeLightbox } from '../hooks/usePhotoSwipeLightbox';\nimport { ImageGalleryThumbnail } from '../ImageGalleryThumbnail/ImageGalleryThumbnail';\n\nimport 'photoswipe/style.css';\n\nexport interface IImageGalleryItem {\n  downloadUrl: string;\n  thumbnailUrl: string;\n  contentType?: string | null;\n  fullPath: string;\n  name: string;\n  type: string;\n  size: number;\n  timeCreated: string;\n  updated: string;\n  alt?: string;\n}\n\nexport interface ImageGalleryProps {\n  images: IImageGalleryItem[];\n  allowPortrait?: boolean;\n  photoSwipeOptions?: PhotoSwipeOptions;\n  hideThumbnails?: boolean;\n  showNextPrevButton?: boolean;\n}\n\ninterface IState {\n  activeImageIndex: number;\n  showLightbox: boolean;\n  activeImageAspectRatio: number | null;\n}\n\nconst NavButton = styled('button')`\n  background: transparent;\n  border: 0;\n  position: absolute;\n  top: 0;\n  bottom: 0;\n  height: 100%;\n  cursor: pointer;\n`;\n\n// Container that reserves space to prevent layout shift\nconst ImageContainer = styled('div')<{\n  $flexibleAspectRatio: boolean;\n  $allowPortrait: boolean;\n}>`\n  width: 100%;\n  position: relative;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n\n  ${(props) => {\n    if (!props.$allowPortrait) {\n      return `\n        min-height: 300px;\n        @media (min-width: 768px) {\n          min-height: 450px;\n        }\n        aspect-ratio: 16/9;\n        background: #f5f5f5;\n      `;\n    }\n\n    // Research/Questions with wide image: flexible, no aspect-ratio\n    if (props.$flexibleAspectRatio) {\n      return `\n        min-height: 200px;\n        @media (min-width: 768px) {\n          min-height: 300px;\n        }\n      `;\n    }\n\n    // Research/Questions with normal image: use aspect-ratio\n    return `\n      min-height: 200px;\n      @media (min-width: 768px) {\n        min-height: 300px;\n      }\n      aspect-ratio: 16/9;\n      background: #f5f5f5;\n    `;\n  }}\n`;\n\nexport const ImageGallery = (props: ImageGalleryProps) => {\n  // Initialize with actual images from props, not empty array\n  const filteredImages = (props.images || []).filter((img) => img !== null);\n\n  const [state, setState] = useState<IState>({\n    activeImageIndex: 0,\n    showLightbox: false,\n    activeImageAspectRatio: null,\n  });\n\n  const imageRef = useRef<HTMLImageElement>(null);\n  const activeImageIndex = state.activeImageIndex;\n  const activeImage = filteredImages[activeImageIndex];\n  const imageNumber = filteredImages.length;\n  const showThumbnails = !props.hideThumbnails && filteredImages.length > 1;\n  const showNextPrevButton = !!props.showNextPrevButton && filteredImages.length > 1;\n\n  const { open } = usePhotoSwipeLightbox({\n    images: filteredImages.map((img) => ({\n      src: img.downloadUrl,\n      alt: img.alt,\n    })),\n    photoSwipeOptions: props.photoSwipeOptions,\n  });\n\n  const setActive = (imageIndex: number) => {\n    setState((prevState) => ({\n      ...prevState,\n      activeImageIndex: imageIndex,\n      activeImageAspectRatio: null,\n    }));\n  };\n\n  const setActiveImgLoaded = () => {\n    setState((prevState) => ({\n      ...prevState,\n    }));\n  };\n\n  const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {\n    const img = e.currentTarget;\n    const aspectRatio = img.naturalWidth / img.naturalHeight;\n    setState((prevState) => ({\n      ...prevState,\n      showActiveImgLoading: false,\n      activeImageAspectRatio: aspectRatio,\n    }));\n  };\n\n  const handleImageError = () => {\n    // Also clear loading state on error\n    setActiveImgLoaded();\n  };\n\n  useLayoutEffect(() => {\n    if (typeof window === 'undefined') return;\n\n    const img = imageRef.current;\n    if (img && img.complete && img.naturalWidth > 0) {\n      const aspectRatio = img.naturalWidth / img.naturalHeight;\n      setState((prevState) => ({\n        ...prevState,\n        showActiveImgLoading: false,\n        activeImageAspectRatio: aspectRatio,\n      }));\n    }\n  }, [activeImage?.downloadUrl]);\n\n  const triggerLightbox = (): void => {\n    if (typeof window === 'undefined') return;\n    open(state.activeImageIndex);\n  };\n\n  // Early return if no images - but this should rarely happen now\n  if (!filteredImages.length) {\n    return null;\n  }\n\n  // Detect images wider than 16:9 and remove aspect ratio constraint to prevent letterboxing\n  const ASPECT_RATIO_16_9 = 16 / 9;\n  const isWideImage = state.activeImageAspectRatio\n    ? state.activeImageAspectRatio > ASPECT_RATIO_16_9\n    : false;\n  const useFlexibleAspectRatio = isWideImage && !!props.allowPortrait;\n  const allowPortrait = !!props.allowPortrait;\n\n  return (\n    <Flex sx={{ flexDirection: 'column' }}>\n      <ImageContainer $flexibleAspectRatio={useFlexibleAspectRatio} $allowPortrait={allowPortrait}>\n        <ThemeImage\n          ref={imageRef}\n          data-cy=\"active-image\"\n          data-testid=\"active-image\"\n          sx={{\n            width: '100%',\n            height: !allowPortrait || !useFlexibleAspectRatio ? '100%' : 'auto',\n            cursor: 'pointer',\n            objectFit: allowPortrait ? 'contain' : 'cover',\n            transition: 'opacity 0.2s ease-in-out',\n          }}\n          src={activeImage.downloadUrl}\n          onClick={triggerLightbox}\n          onLoad={handleImageLoad}\n          onError={handleImageError}\n          alt={activeImage.alt ?? activeImage.name}\n          crossOrigin=\"\"\n        />\n        {showNextPrevButton ? (\n          <>\n            <NavButton\n              aria-label={'Next image'}\n              style={{\n                right: 0,\n                zIndex: 2,\n              }}\n              onClick={() =>\n                setActive(activeImageIndex + 1 < imageNumber ? activeImageIndex + 1 : 0)\n              }\n            >\n              <Arrow direction=\"right\" sx={{ marginRight: '10px' }} />\n            </NavButton>\n            <NavButton\n              aria-label={'Previous image'}\n              style={{\n                left: 0,\n                zIndex: 2,\n              }}\n              onClick={() =>\n                setActive(activeImageIndex - 1 >= 0 ? activeImageIndex - 1 : imageNumber - 1)\n              }\n            >\n              <Arrow direction=\"left\" sx={{ marginLeft: '10px' }} />\n            </NavButton>\n          </>\n        ) : null}\n      </ImageContainer>\n      {showThumbnails && (\n        <Flex sx={{ width: '100%', flexWrap: 'wrap' }} mx={[2, 2, '-5px']}>\n          {filteredImages.map((image, index: number) => (\n            <ImageGalleryThumbnail\n              activeImageIndex={activeImageIndex}\n              allowPortrait={props.allowPortrait ?? false}\n              setActiveIndex={setActive}\n              key={image.thumbnailUrl}\n              alt={image.alt}\n              index={index}\n              name={image.name}\n              thumbnailUrl={image.thumbnailUrl}\n            />\n          ))}\n        </Flex>\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ImageGalleryThumbnail/ImageGalleryThumbnail.stories.tsx",
    "content": "import { ImageGalleryThumbnail } from './ImageGalleryThumbnail';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { ImageGalleryThumbnailProps } from './ImageGalleryThumbnail';\n\nexport default {\n  title: 'Layout/ImageGallery/ImageGalleryThumbnail',\n  component: ImageGalleryThumbnail,\n} as Meta<typeof ImageGalleryThumbnail>;\n\nexport const Default: StoryFn<typeof ImageGalleryThumbnail> = (\n  props: ImageGalleryThumbnailProps,\n) => {\n  return (\n    <ImageGalleryThumbnail\n      {...props}\n      activeImageIndex={0}\n      allowPortrait={false}\n      alt=\"alt\"\n      name=\"name\"\n      index={0}\n      setActiveIndex={() => {}}\n      thumbnailUrl=\"https://picsum.photos/id/29/150/150\"\n    />\n  );\n};\n\nexport const AllowPortrait: StoryFn<typeof ImageGalleryThumbnail> = (\n  props: ImageGalleryThumbnailProps,\n) => {\n  return (\n    <ImageGalleryThumbnail\n      {...props}\n      activeImageIndex={0}\n      allowPortrait={true}\n      alt=\"alt\"\n      name=\"name\"\n      index={0}\n      setActiveIndex={() => {}}\n      thumbnailUrl=\"https://picsum.photos/id/29/150/150\"\n    />\n  );\n};\n\nexport const DisallowPortrait: StoryFn<typeof ImageGalleryThumbnail> = (\n  props: ImageGalleryThumbnailProps,\n) => {\n  return (\n    <ImageGalleryThumbnail\n      {...props}\n      activeImageIndex={0}\n      allowPortrait={false}\n      alt=\"alt\"\n      name=\"name\"\n      index={0}\n      setActiveIndex={() => {}}\n      thumbnailUrl=\"https://picsum.photos/id/29/150/150\"\n    />\n  );\n};\n\nexport const ImageIsActive: StoryFn<typeof ImageGalleryThumbnail> = (\n  props: ImageGalleryThumbnailProps,\n) => {\n  return (\n    <ImageGalleryThumbnail\n      {...props}\n      activeImageIndex={0}\n      allowPortrait={false}\n      alt=\"alt\"\n      name=\"name\"\n      index={0}\n      setActiveIndex={() => {}}\n      thumbnailUrl=\"https://picsum.photos/id/29/150/150\"\n    />\n  );\n};\n\nexport const ImageIsNotActive: StoryFn<typeof ImageGalleryThumbnail> = (\n  props: ImageGalleryThumbnailProps,\n) => {\n  return (\n    <ImageGalleryThumbnail\n      {...props}\n      activeImageIndex={1}\n      allowPortrait={false}\n      alt=\"alt\"\n      name=\"name\"\n      index={0}\n      setActiveIndex={() => {}}\n      thumbnailUrl=\"https://picsum.photos/id/29/150/150\"\n    />\n  );\n};\n\nexport const ThumbnailUrlInvalidAltText: StoryFn<typeof ImageGalleryThumbnail> = (\n  props: ImageGalleryThumbnailProps,\n) => {\n  return (\n    <ImageGalleryThumbnail\n      {...props}\n      activeImageIndex={1}\n      allowPortrait={false}\n      alt=\"alt\"\n      name=\"name\"\n      index={0}\n      setActiveIndex={() => {}}\n      thumbnailUrl=\"https://fastly.picsum.photos/404\"\n    />\n  );\n};\n\nexport const ThumbnailUrlInvalidNameText: StoryFn<typeof ImageGalleryThumbnail> = (\n  props: ImageGalleryThumbnailProps,\n) => {\n  return (\n    <ImageGalleryThumbnail\n      {...props}\n      activeImageIndex={1}\n      allowPortrait={false}\n      name=\"name\"\n      index={0}\n      setActiveIndex={() => {}}\n      thumbnailUrl=\"https://fastly.picsum.photos/404\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ImageGalleryThumbnail/ImageGalleryThumbnail.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { ImageGalleryThumbnail } from './ImageGalleryThumbnail';\n\ndescribe('ImageGalleryThumbnail', () => {\n  it('calls Callback, when image clicked', () => {\n    const mockFn = vi.fn();\n    const { getByTestId } = render(\n      <ImageGalleryThumbnail\n        activeImageIndex={0}\n        allowPortrait={false}\n        alt=\"alt\"\n        name=\"name\"\n        index={0}\n        setActiveIndex={mockFn}\n        thumbnailUrl=\"https://picsum.photos/id/29/150/150\"\n      />,\n    );\n    getByTestId('thumbnail').click();\n    expect(mockFn).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "packages/components/src/ImageGalleryThumbnail/ImageGalleryThumbnail.tsx",
    "content": "import styled from '@emotion/styled';\nimport React from 'react';\nimport type { CardProps } from 'theme-ui';\nimport { Box, Image as ThemeImage } from 'theme-ui';\n\nimport 'photoswipe/style.css';\n\nexport interface ImageGalleryThumbnailProps {\n  setActiveIndex: (index: number) => void;\n  allowPortrait: boolean;\n  activeImageIndex: number;\n  thumbnailUrl: string;\n  index: number;\n  alt?: string;\n  name?: string;\n}\n\nexport const ThumbCard = styled<CardProps & React.ComponentProps<any>>(Box)`\n  cursor: pointer;\n  padding: 5px;\n  overflow: hidden;\n  transition: 0.2s ease-in-out;\n  &:hover {\n    transform: translateY(-5px);\n  }\n`;\n\nexport const ImageGalleryThumbnail = (props: ImageGalleryThumbnailProps) => {\n  return (\n    <ThumbCard\n      data-cy=\"thumbnail\"\n      data-testid=\"thumbnail\"\n      mb={3}\n      mt={4}\n      opacity={props.index === props.activeImageIndex ? 1.0 : 0.5}\n      onClick={() => props.setActiveIndex(props.index)}\n    >\n      <ThemeImage\n        loading=\"lazy\"\n        src={props.thumbnailUrl}\n        alt={props.alt ?? props.name}\n        sx={{\n          width: 100,\n          height: 67,\n          objectFit: props.allowPortrait ? 'contain' : 'cover',\n          borderRadius: 1,\n          border: '1px solid offWhite',\n        }}\n        crossOrigin=\"\"\n      />\n    </ThumbCard>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ImageInput/ImageInputDeleteOverlay.tsx",
    "content": "import type { JSX } from 'react';\nimport type { BoxProps, ThemeUIStyleObject } from 'theme-ui';\nimport { Flex } from 'theme-ui';\nimport { Button } from '../Button/Button';\n\nconst alignCenterWrapperStyles: ThemeUIStyleObject = {\n  height: '100%',\n  width: '100%',\n  overflow: 'hidden',\n  justifyContent: 'center',\n  alignItems: 'center',\n};\n\nconst UploadImageOverlay = (props: BoxProps): JSX.Element => (\n  <Flex\n    sx={{\n      ...alignCenterWrapperStyles,\n      position: 'absolute',\n      top: 0,\n      left: 0,\n      right: 0,\n      backgroundColor: 'rgba(0, 0, 0, 0.2)',\n      opacity: 0,\n      visibility: 'hidden',\n      transition: 'opacity 300ms ease-in',\n      borderRadius: 1,\n      '.image-input__wrapper:hover &': {\n        visibility: 'visible',\n        opacity: 1,\n      },\n    }}\n  >\n    {props.children}\n  </Flex>\n);\n\ninterface IProps {\n  onClick: (event: React.MouseEvent) => void;\n}\n\nexport const ImageInputDeleteOverlay = ({ onClick }: IProps) => {\n  return (\n    <UploadImageOverlay>\n      <Button\n        data-cy=\"delete-image\"\n        data-testid=\"delete-image\"\n        small\n        variant=\"secondary\"\n        icon=\"delete\"\n        type=\"button\"\n        onClick={(event: any) => onClick(event)}\n      >\n        Delete\n      </Button>\n    </UploadImageOverlay>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ImageInput/ImageInputV2.tsx",
    "content": "import { MediaWithPublicUrl } from 'oa-shared';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { Flex, Image as ImageComponent } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { ImageInputDeleteOverlay } from './ImageInputDeleteOverlay';\nimport { isImageValid } from './isImageValid';\n\ninterface IProps {\n  onFilesChange: (fileMeta: File | undefined) => void;\n  onError?: (error: string) => void;\n  image?: MediaWithPublicUrl;\n  maxFileSize?: number;\n}\n\nconst ACCEPTED_FORMATS = '.jpeg,.jpg,.png,.gif,.svg,.webp';\nconst DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB\n\nexport const ImageInputV2 = (props: IProps) => {\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const { onFilesChange, onError, image, maxFileSize = DEFAULT_MAX_FILE_SIZE } = props;\n  const [file, setFile] = useState<File | null>(null);\n\n  const previewUrl = useMemo(() => {\n    if (file) {\n      return URL.createObjectURL(file);\n    }\n    return image?.publicUrl;\n  }, [file, image]);\n\n  // Cleanup object URL on unmount or when file changes\n  useEffect(() => {\n    return () => {\n      if (file) {\n        URL.revokeObjectURL(URL.createObjectURL(file));\n      }\n    };\n  }, [file]);\n\n  const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {\n    const selectedFile = event.target.files?.[0];\n    if (!selectedFile) {\n      return;\n    }\n\n    // Reset input to allow selecting the same file again if needed\n    event.target.value = '';\n\n    // Check file size\n    if (selectedFile.size > maxFileSize) {\n      const sizeMB = (maxFileSize / (1024 * 1024)).toFixed(0);\n      const errorMsg = `Image is too large. Maximum size is ${sizeMB}MB.`;\n      onError?.(errorMsg);\n      return;\n    }\n\n    // Validate image format and integrity\n    try {\n      await isImageValid(selectedFile);\n      setFile(selectedFile);\n      onFilesChange(selectedFile);\n    } catch {\n      const errorMsg =\n        'Invalid image file. Please upload a valid image (jpeg, jpg, png, gif, svg, or webp).';\n      onError?.(errorMsg);\n    }\n  };\n\n  const handleImageDelete = (event: React.MouseEvent) => {\n    event.stopPropagation();\n    setFile(null);\n    onFilesChange(undefined);\n    if (fileInputRef.current) {\n      fileInputRef.current.value = '';\n    }\n  };\n\n  const handleWrapperClick = () => {\n    fileInputRef.current?.click();\n  };\n\n  const hasImage = !!(file || image);\n\n  return (\n    <Flex\n      className=\"image-input__wrapper\"\n      onClick={handleWrapperClick}\n      sx={{\n        overflow: 'hidden',\n        justifyContent: 'center',\n        alignItems: 'center',\n        position: 'relative',\n        borderColor: 'background',\n        borderStyle: hasImage ? 'none' : 'solid',\n        borderRadius: 1,\n        borderWidth: '2px',\n        backgroundColor: 'white',\n        height: '100%',\n        width: '100%',\n        cursor: 'pointer',\n      }}\n    >\n      <input\n        ref={fileInputRef}\n        data-testid=\"image-input\"\n        type=\"file\"\n        accept={ACCEPTED_FORMATS}\n        onChange={handleFileSelect}\n        style={{ display: 'none' }}\n      />\n\n      {previewUrl && (\n        <ImageComponent\n          src={previewUrl}\n          sx={{ width: '100%', height: '100%', objectFit: 'cover' }}\n        />\n      )}\n\n      {!hasImage ? (\n        <Button small variant=\"outline\" icon=\"image\" type=\"button\">\n          Upload\n        </Button>\n      ) : (\n        <ImageInputDeleteOverlay onClick={handleImageDelete} />\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ImageInput/ImageInputWrapper.tsx",
    "content": "import type { JSX } from 'react';\nimport { forwardRef } from 'react';\nimport type { BoxProps } from 'theme-ui';\nimport { Flex } from 'theme-ui';\n\ninterface ITitleProps {\n  hasUploadedImg: boolean;\n  sx?: any;\n}\n\n// any export to fix: https://github.com/microsoft/TypeScript/issues/37597\nexport const ImageInputWrapper = forwardRef<HTMLElement, BoxProps & ITitleProps>(\n  (props, ref): JSX.Element => {\n    const { hasUploadedImg, sx, ...rest } = props;\n\n    return (\n      <Flex\n        className={'image-input__wrapper'}\n        ref={ref}\n        sx={{\n          overflow: 'hidden',\n          justifyContent: 'center',\n          alignItems: 'center',\n          position: 'relative',\n          borderColor: 'background',\n          borderStyle: hasUploadedImg ? 'none' : 'dashed',\n          borderRadius: 1,\n          backgroundColor: 'white',\n          height: '100%',\n          ...sx,\n        }}\n        {...rest}\n      >\n        {props.children}\n      </Flex>\n    );\n  },\n);\n\nImageInputWrapper.displayName = 'ImageInputWrapper';\n"
  },
  {
    "path": "packages/components/src/ImageInput/isImageValid.ts",
    "content": "// Basic check using the filereader api to see whether we can create a displayable image\n// If this fails then there is a problem with the file\n\nexport const isImageValid = (file: File): Promise<void> => {\n  return new Promise((resolve, reject) => {\n    if (!file) {\n      reject();\n      return;\n    }\n\n    const reader = new FileReader();\n\n    reader.onload = (e: ProgressEvent<FileReader>) => {\n      const img = document.createElement('img');\n\n      img.onload = () => {\n        // Image loaded successfully\n        img.remove();\n        resolve();\n      };\n\n      img.onerror = () => {\n        // Image failed to load (possibly corrupted)\n        img.remove();\n        reject();\n      };\n\n      img.src = e.target?.result as string;\n    };\n\n    reader.onerror = () => {\n      // Error reading file. It might be corrupted.\n      reject();\n    };\n\n    reader.readAsDataURL(file);\n  });\n};\n"
  },
  {
    "path": "packages/components/src/InformationTooltip/InformationTooltip.stories.tsx",
    "content": "import { InformationTooltip } from './InformationTooltip';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/Information',\n  component: InformationTooltip,\n} as Meta<typeof InformationTooltip>;\n\nexport const Default: StoryFn<typeof InformationTooltip> = () => (\n  <InformationTooltip\n    glyph=\"information\"\n    tooltip=\"Just a little wrapper for an icon/tooltip\"\n    size={30}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/InformationTooltip/InformationTooltip.tsx",
    "content": "import { useId } from 'react';\nimport { Tooltip } from 'react-tooltip';\nimport type { IconProps } from '../Icon/Icon';\nimport { Icon } from '../Icon/Icon';\n\nexport interface IProps extends IconProps {\n  tooltip: string;\n}\n\nexport const InformationTooltip = (props: IProps) => {\n  const id = useId();\n\n  return (\n    <>\n      <Icon {...props} data-tooltip-id={id} />\n\n      <Tooltip id={id}>\n        <p\n          dangerouslySetInnerHTML={{ __html: props.tooltip }}\n          style={{ textAlign: 'center', margin: 0 }}\n        />\n      </Tooltip>\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Input/Input.stories.tsx",
    "content": "import { Input } from 'theme-ui';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Forms/Input',\n  component: Input,\n} as Meta<typeof Input>;\n\nexport const Default: StoryFn<typeof Input> = () => <Input placeholder=\"Placeholder\" />;\n\nexport const Error: StoryFn<typeof Input> = () => <Input variant=\"error\" value=\"Invalid input\" />;\n\nexport const Outlined: StoryFn<typeof Input> = () => (\n  <Input variant=\"inputOutline\" placeholder=\"Placeholder\" />\n);\n"
  },
  {
    "path": "packages/components/src/InternalLink/InternalLink.stories.tsx",
    "content": "import { InternalLink } from './InternalLink';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/InternalLink',\n  component: InternalLink,\n} as Meta<typeof InternalLink>;\n\nexport const Default: StoryFn<typeof InternalLink> = () => (\n  <InternalLink to={`/abc/`}>Link</InternalLink>\n);\n"
  },
  {
    "path": "packages/components/src/InternalLink/InternalLink.tsx",
    "content": "import { forwardRef } from 'react';\nimport type { LinkProps as RouterLinkProps } from 'react-router';\nimport { Link as RouterLink } from 'react-router';\nimport type { LinkProps as ThemedUILinkProps } from 'theme-ui';\nimport { Link } from 'theme-ui';\n\nexport type Props = RouterLinkProps & ThemedUILinkProps;\n\nexport const InternalLink = forwardRef<HTMLButtonElement, Props>((props: Props, ref) => (\n  <Link as={RouterLink} ref={ref as any} {...props}>\n    {props.children}\n  </Link>\n));\n\nInternalLink.displayName = 'InternalLink';\n"
  },
  {
    "path": "packages/components/src/LinkifyText/LinkifyText.stories.tsx",
    "content": "import { LinkifyText } from './LinkifyText';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/LinkifyText',\n  component: LinkifyText,\n} as Meta<typeof LinkifyText>;\n\nexport const Default: StoryFn<typeof LinkifyText> = () => (\n  <LinkifyText>\n    There are some link.info hidden in this text. https://example.com if you can spot all of them.\n  </LinkifyText>\n);\n\nexport const SupportsMentions: StoryFn<typeof LinkifyText> = () => (\n  <LinkifyText>\n    In addition to a URLs, it is also possible to @mention somone. Although there are edge cases\n    where using @&#8288;a-mention should <b>not</b> be a link.\n  </LinkifyText>\n);\n"
  },
  {
    "path": "packages/components/src/LinkifyText/LinkifyText.tsx",
    "content": "import 'linkify-plugin-mention';\n\nimport styled from '@emotion/styled';\nimport Linkify from 'linkify-react';\nimport { useThemeUI } from 'theme-ui';\n\nimport { ExternalLink } from '../ExternalLink/ExternalLink';\nimport { InternalLink } from '../InternalLink/InternalLink';\n\nexport interface Props {\n  children?: React.ReactNode;\n}\n\nexport const LinkifyText = (props: Props) => {\n  const { theme } = useThemeUI() as any;\n  const StyledExternalLink = styled(ExternalLink)`\n    color: ${theme.colors.grey}!important;\n    text-decoration: underline;\n  `;\n  const StyledInternalLink = styled(InternalLink)`\n    color: ${theme.colors.grey};\n    font-weight: bold;\n  `;\n\n  const renderExternalLink = ({ attributes = {} as any, content = '' }) => {\n    const { href, ...props } = attributes;\n    return (\n      <StyledExternalLink href={href} {...props}>\n        {content}\n      </StyledExternalLink>\n    );\n  };\n  const renderInternalLink = ({ attributes = {} as any, content = '' }) => {\n    const { href, ...props } = attributes;\n    return (\n      <StyledInternalLink to={`/u${href}`} {...props}>\n        {content}\n      </StyledInternalLink>\n    );\n  };\n\n  return (\n    <Linkify\n      options={{\n        render: {\n          mention: renderInternalLink,\n          url: renderExternalLink,\n        },\n      }}\n    >\n      {props.children}\n    </Linkify>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Loader/Loader.stories.tsx",
    "content": "import { Loader } from './Loader';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/Loader',\n  component: Loader,\n} as Meta<typeof Loader>;\n\nexport const Default: StoryFn<typeof Loader> = () => <Loader />;\n\nexport const CustomMessage: StoryFn<typeof Loader> = () => (\n  <Loader label=\"Whatever you want to say! OMG!\" />\n);\n"
  },
  {
    "path": "packages/components/src/Loader/Loader.tsx",
    "content": "import { commonStyles } from 'oa-themes';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Flex, Spinner, Text } from 'theme-ui';\n\nexport interface LoaderProps {\n  label?: string;\n  sx?: ThemeUIStyleObject | undefined;\n}\n\nexport const Loader = ({ label, sx }: LoaderProps) => {\n  return (\n    <Flex sx={{ flexWrap: 'wrap', justifyContent: 'center', ...sx }}>\n      <Spinner aria-label=\"Loading...\" sx={{ color: commonStyles.colors.darkGrey }} />\n      {label && <Text sx={{ width: '100%', textAlign: 'center' }}>{label}</Text>}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Map/Map.client.tsx",
    "content": "import { forwardRef } from 'react';\nimport type { MapContainerProps } from 'react-leaflet';\nimport { MapContainer, TileLayer, useMapEvents } from 'react-leaflet';\n\nimport 'leaflet/dist/leaflet.css';\nimport './index.css';\n\nexport interface IProps {\n  setZoom: (arg: number) => void;\n  children?: React.ReactNode | React.ReactNode[];\n  center?: MapContainerProps['center'];\n  zoom?: MapContainerProps['zoom'];\n  maxZoom?: MapContainerProps['maxZoom'];\n  zoomControl?: MapContainerProps['zoomControl'];\n  style?: React.CSSProperties;\n  doubleClickZoom?: MapContainerProps['doubleClickZoom'];\n}\n\n// Component to handle map events\nfunction MapEventHandler({ setZoom }: { setZoom: (zoom: number) => void }) {\n  useMapEvents({\n    zoomend: (e) => {\n      setZoom(e.target.getZoom());\n    },\n  });\n  return null;\n}\n\n// biome-ignore lint/suspicious/noShadowRestrictedNames: this is an external library import\nexport const Map = forwardRef<L.Map, IProps>((props, ref) => {\n  const { setZoom, children, ...mapProps } = props;\n\n  return (\n    <MapContainer ref={ref} {...mapProps}>\n      <TileLayer\n        attribution='&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors &copy; <a href=\"https://carto.com/attributions\">CARTO</a>'\n        url=\"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png\"\n      />\n      <MapEventHandler setZoom={setZoom} />\n      {children}\n    </MapContainer>\n  );\n});\n\nMap.displayName = 'Map';\n"
  },
  {
    "path": "packages/components/src/Map/Map.stories.tsx",
    "content": "import { useState } from 'react';\n\nimport { Map } from './Map.client';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Map/Map',\n  component: Map,\n} as Meta<typeof Map>;\n\nexport const Default: StoryFn<typeof Map> = () => {\n  const [zoom, setZoom] = useState<number>(1);\n  return (\n    <Map\n      zoom={zoom}\n      setZoom={setZoom}\n      style={{\n        height: '450px',\n        width: '800px',\n      }}\n      center={[0, 0]}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Map/index.css",
    "content": ".markercluster-map .closed {\n  display: none;\n}\n"
  },
  {
    "path": "packages/components/src/MapCardList/MapCardList.stories.tsx",
    "content": "import { MapCardList } from './MapCardList';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { MapPin, Moderation } from 'oa-shared';\n\nexport default {\n  title: 'Map/CardList',\n  component: MapCardList,\n} as Meta<typeof MapCardList>;\n\nconst list = [\n  {\n    id: 1,\n    profile: {\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n      },\n    },\n    moderation: 'accepted' as Moderation,\n    lat: 0,\n    lng: 0,\n  },\n  {\n    id: 2,\n    moderation: 'accepted' as Moderation,\n    profile: {\n      type: {\n        id: 2,\n        name: 'collection-point',\n        displayName: 'Collection Point',\n      },\n    },\n    lat: 10,\n    lng: -38,\n  },\n  {\n    id: 3,\n    profile: {\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n      },\n    },\n    moderation: 'accepted' as Moderation,\n    lat: 102,\n    lng: 30,\n  },\n  {\n    id: 4,\n    profile: {\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n      },\n    },\n    moderation: 'accepted' as Moderation,\n    lat: 0,\n    lng: 73,\n  },\n] as MapPin[];\n\nconst onPinClick = () => undefined;\n\nexport const Default: StoryFn<typeof MapCardList> = () => {\n  return (\n    <MapCardList list={list} onPinClick={onPinClick} selectedPin={undefined} viewport=\"stories\" />\n  );\n};\n\nexport const WhenDisplayIsZero: StoryFn<typeof MapCardList> = () => {\n  return (\n    <MapCardList list={[]} onPinClick={onPinClick} selectedPin={undefined} viewport=\"stories\" />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/MapCardList/MapCardList.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { EMPTY_LIST, MapCardList } from './MapCardList';\n\nimport type { MapPin, Moderation } from 'oa-shared';\nimport type { IProps } from './MapCardList';\n\nconst defaultList = [\n  {\n    id: 1,\n    profile: {\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n      },\n    },\n    moderation: 'accepted' as Moderation,\n    lat: 0,\n    lng: 0,\n  },\n  {\n    id: 2,\n    moderation: 'accepted' as Moderation,\n    profile: {\n      type: {\n        id: 2,\n        name: 'collection-point',\n        displayName: 'Collection Point',\n      },\n    },\n    lat: 10,\n    lng: -38,\n  },\n  {\n    id: 3,\n    profile: {\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n      },\n    },\n    moderation: 'accepted' as Moderation,\n    lat: 102,\n    lng: 30,\n  },\n  {\n    id: 4,\n    profile: {\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n      },\n    },\n    moderation: 'accepted' as Moderation,\n    lat: 0,\n    lng: 73,\n  },\n] as MapPin[];\n\nconst onPinClick = () => undefined;\n\nconst defaultProps: IProps = {\n  list: defaultList,\n  onPinClick,\n  selectedPin: undefined,\n  viewport: 'stories',\n};\n\ndescribe('CardList', () => {\n  it('Shows all items when no filtering is done', () => {\n    const { getAllByTestId } = render(<MapCardList {...defaultProps} />);\n\n    expect(getAllByTestId('CardListItem').length).toBe(4);\n  });\n\n  it('Shows the no item label when filtered items is empty', () => {\n    const { getByText } = render(<MapCardList {...defaultProps} list={[]} />);\n\n    expect(getByText(EMPTY_LIST)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/MapCardList/MapCardList.tsx",
    "content": "import type { MapPin } from 'oa-shared';\nimport type { JSX } from 'react';\nimport { useEffect, useState } from 'react';\nimport { Box, Flex, Text } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { CardListItem } from '../CardListItem/CardListItem';\nimport { Icon } from '../Icon/Icon';\n\nexport interface IProps {\n  list: MapPin[];\n  onPinClick: (arg: MapPin) => void;\n  selectedPin?: MapPin | null;\n  viewport: string;\n}\n\nexport const EMPTY_LIST = 'Oh nos! Nothing to show!';\nconst ITEMS_PER_RENDER = 20;\n\nexport const MapCardList = (props: IProps) => {\n  const [renderCount, setRenderCount] = useState<number>(ITEMS_PER_RENDER);\n  const [displayItems, setDisplayItems] = useState<JSX.Element[]>([]);\n  const { list, onPinClick, selectedPin, viewport } = props;\n\n  useEffect(() => {\n    setRenderCount(ITEMS_PER_RENDER);\n  }, [list]);\n\n  useEffect(() => {\n    const toRender = list.slice(0, renderCount).map((item) => {\n      const isSelectedPin = item.id === selectedPin?.id;\n\n      return (\n        <CardListItem\n          item={item}\n          key={item.id}\n          isSelectedPin={isSelectedPin}\n          onPinClick={onPinClick}\n          viewport={viewport}\n        />\n      );\n    });\n\n    setDisplayItems(toRender);\n  }, [renderCount, list]);\n\n  const addRenderItems = () => setRenderCount((count) => count + ITEMS_PER_RENDER);\n\n  const hasMore = !(displayItems.length === list.length);\n\n  const isListEmpty = list.length === 0;\n  const results = `${list.length} result${list.length == 1 ? '' : 's'} in view`;\n\n  return (\n    <Flex data-cy={`CardList-${viewport}`} sx={{ flexDirection: 'column', gap: 2, padding: 2 }}>\n      <Flex sx={{ justifyContent: 'space-between', paddingX: 2, paddingTop: 2, fontSize: 2 }}>\n        <Text data-cy=\"list-results\">{results}</Text>\n        <Flex sx={{ alignItems: 'center', gap: 2 }}>\n          <Text>Most recently active</Text>\n          <Icon glyph=\"arrow-full-down\" />\n        </Flex>\n      </Flex>\n      {isListEmpty && EMPTY_LIST}\n      {!isListEmpty && (\n        <>\n          <Box sx={{ columnCount: [1, 2, 2, 3], columnGap: 0, '& > *': { breakInside: 'avoid' } }}>\n            {displayItems}\n          </Box>\n          {hasMore && (\n            <Flex sx={{ justifyContent: 'center' }}>\n              <Button onClick={addRenderItems}>Show more</Button>\n            </Flex>\n          )}\n        </>\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/MapFilterListItem/MapFilterListItem.tsx",
    "content": "import type { ThemeUIStyleObject } from 'theme-ui';\nimport { CardButton } from '../CardButton/CardButton';\n\ninterface IProps {\n  active: boolean;\n  onClick: () => void;\n  children: React.ReactNode;\n  filterType: string;\n  sx?: ThemeUIStyleObject | undefined;\n}\n\nexport const MapFilterListItem = (props: IProps) => {\n  const { active, onClick, children, filterType, sx } = props;\n  return (\n    <CardButton\n      data-cy={`MapFilterListItem-${filterType}${active ? '-active' : ''}`}\n      onClick={onClick}\n      extrastyles={{\n        display: 'flex',\n        maxWidth: ['100%', '49%'],\n        width: '500px',\n        flexDirection: 'row',\n        backgroundColor: 'offWhite',\n        padding: 1,\n        alignItems: 'center',\n        gap: 2,\n        ...(active\n          ? {\n              borderColor: 'green',\n              ':hover': { borderColor: 'green' },\n            }\n          : {\n              borderColor: 'offWhite',\n              ':hover': { borderColor: 'offWhite' },\n            }),\n        ...sx,\n      }}\n    >\n      {children}\n    </CardButton>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/MapWithPin/MapPin.client.tsx",
    "content": "import type { DivIcon, LatLng, Marker as LeafletMarker } from 'leaflet';\nimport L from 'leaflet';\nimport { useRef } from 'react';\nimport { Marker } from 'react-leaflet';\nimport customMarkerIcon from '../../assets/icons/map-marker.png';\n\nconst customMarker = L.icon({\n  iconUrl: customMarkerIcon,\n  iconSize: [20, 28],\n  iconAnchor: [10, 28],\n});\n\nexport interface IProps {\n  position: {\n    lat: number;\n    lng: number;\n  };\n  onDrag(latlng: LatLng): void;\n  markerIcon?: DivIcon;\n  onClick?: () => void;\n}\n\nexport const MapPin = (props: IProps) => {\n  const markerRef = useRef<LeafletMarker>(null);\n\n  const handleDrag = () => {\n    const marker = markerRef.current;\n\n    if (!marker) {\n      return;\n    }\n\n    const markerLatLng = marker.getLatLng();\n    if (props.onDrag) {\n      props.onDrag(markerLatLng);\n    }\n  };\n\n  return (\n    <Marker\n      draggable\n      eventHandlers={{\n        drag: handleDrag,\n        click: props.onClick,\n      }}\n      position={[props.position.lat, props.position.lng]}\n      ref={markerRef}\n      icon={props.markerIcon || customMarker}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/MapWithPin/MapPin.stories.tsx",
    "content": "import type { LatLng } from 'leaflet';\nimport { MapPin } from './MapPin.client';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Map/MapPin',\n  component: MapPin,\n} as Meta<typeof MapPin>;\n\nexport const Default: StoryFn<typeof MapPin> = () => {\n  const position = { lat: 0, lng: 0 };\n  return (\n    <MapPin\n      position={position}\n      onDrag={(latlng: LatLng) => {\n        position.lat = latlng.lat;\n        position.lng = latlng.lng;\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/MapWithPin/MapWithPin.client.tsx",
    "content": "import type { DivIcon, Map as LeafletMap } from 'leaflet';\nimport { useState } from 'react';\nimport { useMapEvents, ZoomControl } from 'react-leaflet';\nimport { Box, Flex } from 'theme-ui';\n// biome-ignore lint/suspicious/noShadowRestrictedNames: this is an external library import\nimport { Map } from '../Map/Map.client';\nimport { OsmGeocoding } from '../OsmGeocoding/OsmGeocoding';\nimport type { Result } from '../OsmGeocoding/types';\nimport { MapPin } from './MapPin.client';\n\nimport 'leaflet/dist/leaflet.css';\n\nexport interface Props {\n  mapRef: React.RefObject<LeafletMap | null>;\n  position: {\n    lat: number;\n    lng: number;\n  };\n  markerIcon?: DivIcon;\n  updatePosition: (position: { lat: number; lng: number }) => void;\n  center?: any;\n  zoom?: number;\n  onClickMapPin?: () => void;\n  popup?: React.ReactNode;\n}\n\n// Component to handle map click events\nfunction MapClickHandler({\n  updatePosition,\n}: {\n  updatePosition: (position: { lat: number; lng: number }) => void;\n}) {\n  useMapEvents({\n    click: (e) => {\n      updatePosition({ lat: e.latlng.lat, lng: e.latlng.lng });\n    },\n  });\n  return null;\n}\n\nexport const MapWithPin = (props: Props) => {\n  const [zoom, setZoom] = useState(props.zoom || 1);\n  const [center, setCenter] = useState(props.center || [props.position.lat, props.position.lng]);\n  const { mapRef, position, markerIcon, onClickMapPin, popup } = props;\n\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n      <Box\n        sx={{\n          position: 'absolute',\n          zIndex: 2,\n          padding: 4,\n          top: 0,\n          right: 0,\n          display: 'flex',\n          justifyContent: 'center',\n          alignItems: 'center',\n        }}\n      >\n        <Flex style={{ width: '280px' }}>\n          <OsmGeocoding\n            placeholder=\"Type your address\"\n            callback={(data: Result) => {\n              if (data.lat && data.lon) {\n                props.updatePosition({\n                  lat: Number(data.lat),\n                  lng: Number(data.lon),\n                });\n                setCenter([data.lat, data.lon]);\n                setZoom(15);\n              }\n            }}\n            acceptLanguage=\"en\"\n          />\n        </Flex>\n      </Box>\n\n      <Box\n        className=\"markercluster-map settings-page\"\n        sx={{ borderRadius: 6, overflow: 'hidden', position: 'relative' }}\n      >\n        <Map\n          ref={mapRef}\n          center={center}\n          zoom={zoom}\n          zoomControl={false}\n          setZoom={setZoom}\n          doubleClickZoom={false}\n          style={{\n            height: '360px',\n            zIndex: 1,\n          }}\n        >\n          <MapClickHandler updatePosition={props.updatePosition} />\n          <ZoomControl position=\"topleft\" />\n          <>\n            {popup}\n            {position?.lat && position.lng && (\n              <MapPin\n                position={position}\n                markerIcon={markerIcon}\n                onClick={onClickMapPin}\n                onDrag={(evt: any) => {\n                  if (evt.lat && evt.lng)\n                    props.updatePosition({\n                      lat: evt.lat,\n                      lng: evt.lng,\n                    });\n                }}\n              />\n            )}\n          </>\n        </Map>\n      </Box>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/MapWithPin/MapWithPin.stories.tsx",
    "content": "import type { Map as LeafletMap } from 'leaflet';\nimport { useRef } from 'react';\n\nimport { MapWithPin } from './MapWithPin.client';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Map/MapWithPin',\n  component: MapWithPin,\n} as Meta<typeof MapWithPin>;\n\nexport const Default: StoryFn<typeof MapWithPin> = () => {\n  const position = { lat: 0, lng: 0 };\n  const newMapRef = useRef<LeafletMap>(null);\n\n  return (\n    <MapWithPin\n      mapRef={newMapRef}\n      position={position}\n      updatePosition={(_position: { lat: number; lng: number }) => {\n        position.lat = _position.lat;\n        position.lng = _position.lng;\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/MemberBadge/MemberBadge.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\n\nimport { MemberBadge } from './MemberBadge';\n\nimport type { Meta, StoryFn, StoryObj } from '@storybook/react-vite';\nimport type { ProfileType } from 'oa-shared';\n\nexport default {\n  /* 👇 The title prop is optional.\n   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading\n   * to learn how to generate automatic titles\n   */\n  title: 'Components/MemberBadge',\n  component: MemberBadge,\n} as Meta<typeof MemberBadge>;\nconst machineBuilder: ProfileType = {\n  name: 'machine-builder',\n  description: 'A machine builder profile',\n  displayName: 'Machine Builder',\n  id: 1,\n  imageUrl: faker.image.avatar(),\n  mapPinName: 'Machine Builder',\n  order: 1,\n  smallImageUrl: faker.image.avatar(),\n  isSpace: true,\n};\nconst member: ProfileType = {\n  name: 'member',\n  description: 'A member profile',\n  displayName: 'Member',\n  id: 2,\n  imageUrl: faker.image.avatar(),\n  mapPinName: 'Member',\n  order: 1,\n  smallImageUrl: faker.image.avatar(),\n  isSpace: false,\n};\n\nexport const Basic: StoryFn<typeof MemberBadge> = () => <MemberBadge />;\n\nexport const Sizes: StoryObj<typeof MemberBadge> = {\n  render: (args) => (\n    <>\n      <MemberBadge size={args.size} />\n      <MemberBadge size={(args.size || 40) * 2} />\n      <MemberBadge size={(args.size || 40) * 3} />\n    </>\n  ),\n};\n\nexport const TypeMember: StoryFn<typeof MemberBadge> = () => (\n  <>\n    <MemberBadge size={100} profileType={member} />\n    <MemberBadge size={100} profileType={member} useLowDetailVersion />\n  </>\n);\n\nexport const TypeMachineBuilder: StoryFn<typeof MemberBadge> = () => (\n  <>\n    <MemberBadge size={100} profileType={machineBuilder} />\n    <MemberBadge size={100} profileType={machineBuilder} useLowDetailVersion />\n  </>\n);\n"
  },
  {
    "path": "packages/components/src/MemberBadge/MemberBadge.tsx",
    "content": "import type { ProfileType } from 'oa-shared';\nimport type { ImageProps, ThemeUIStyleObject } from 'theme-ui';\nimport { Image } from 'theme-ui';\nimport badge from '../../assets/icons/icon-star-active.svg';\n\nexport interface Props extends ImageProps {\n  size?: number;\n  profileType?: ProfileType;\n  useLowDetailVersion?: boolean;\n  sx?: ThemeUIStyleObject | undefined;\n}\n\nconst MINIMUM_SIZE = 40;\n\nexport const MemberBadge = (props: Props) => {\n  const { profileType, size, useLowDetailVersion, sx } = props;\n  const badgeSize = size ? size : MINIMUM_SIZE;\n\n  if (!profileType) {\n    return null;\n  }\n\n  return (\n    <Image\n      loading=\"lazy\"\n      className=\"avatar\"\n      data-cy={`MemberBadge-${profileType.name}`}\n      sx={{ width: badgeSize, borderRadius: '50%', ...sx }}\n      width={badgeSize}\n      height={badgeSize}\n      title={profileType.displayName}\n      src={\n        badgeSize > MINIMUM_SIZE && !useLowDetailVersion\n          ? profileType.imageUrl || badge\n          : profileType.smallImageUrl || badge\n      }\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/MemberHistory/MemberHistory.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { MemberHistory } from './MemberHistory';\n\nimport type { IProps } from './MemberHistory';\n\ndescribe('MemberHistory', () => {\n  it('renders member since and last active', () => {\n    const props: IProps = {\n      memberSince: new Date(2020, 0, 1),\n      lastActive: new Date(2023, 0, 1),\n    };\n    const { getByText } = render(<MemberHistory {...props} />);\n    expect(getByText('Member since 2020')).toBeInTheDocument();\n  });\n\n  it('renders only since details if given weird last active data', () => {\n    const props: IProps = {\n      memberSince: new Date(2020, 0, 1),\n      lastActive: new Date(2020, 0, 1),\n    };\n    const { getByText, queryAllByText } = render(<MemberHistory {...props} />);\n    expect(getByText('Member since 2020')).toBeInTheDocument();\n    expect(queryAllByText('Last active over 2 years ago')).toEqual([]);\n  });\n});\n"
  },
  {
    "path": "packages/components/src/MemberHistory/MemberHistory.tsx",
    "content": "import { formatDistanceToNow } from 'date-fns';\nimport { useMemo } from 'react';\nimport { Divider, Flex, Text } from 'theme-ui';\n\nexport interface IProps {\n  memberSince: Date;\n  lastActive: Date | null;\n}\n\nexport const MemberHistory = (props: IProps) => {\n  const memberSince = useMemo(() => {\n    try {\n      if (props.memberSince) {\n        return new Date(props.memberSince).getFullYear().toString();\n      }\n    } catch (error) {\n      console.error(error);\n    }\n\n    return null;\n  }, [props.memberSince]);\n\n  const lastActive = useMemo(() => {\n    try {\n      if (props.lastActive) {\n        return formatDistanceToNow(new Date(props.lastActive), {\n          addSuffix: true,\n        });\n      }\n    } catch (error) {\n      console.error(error);\n    }\n\n    return null;\n  }, [props.lastActive]);\n\n  return (\n    <Flex\n      data-cy=\"MemberHistory\"\n      sx={{\n        gap: 2,\n        flexDirection: ['column', 'column', 'row'],\n        alignItems: 'flex-start',\n        justifyContent: 'flex-start',\n      }}\n    >\n      {memberSince && (\n        <Text variant=\"quiet\" sx={{ fontSize: 1 }}>\n          Member since {memberSince}\n        </Text>\n      )}\n      {memberSince && lastActive && (\n        <Divider\n          sx={{\n            display: ['none', 'none', 'block'],\n            width: '1px',\n            height: 'auto',\n            alignSelf: 'stretch',\n            border: '2px solid #0000001A',\n            m: 0,\n          }}\n        />\n      )}\n      {lastActive && (\n        <Text variant=\"quiet\" sx={{ fontSize: 1 }}>\n          Last active {lastActive}\n        </Text>\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Modal/Modal.stories.tsx",
    "content": "import { Modal } from './Modal';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/Modal',\n  component: Modal,\n} as Meta<typeof Modal>;\n\nconst dismissed = () => alert('Dismissed');\n\nexport const Default: StoryFn<typeof Modal> = () => (\n  <Modal isOpen={true} onDismiss={dismissed}>\n    Some Content\n  </Modal>\n);\n\nexport const Collapsed: StoryFn<typeof Modal> = () => (\n  <Modal isOpen={false} onDismiss={() => {}}>\n    Collapsed\n  </Modal>\n);\n\nexport const Sized: StoryFn<typeof Modal> = () => (\n  <Modal isOpen={true} onDismiss={dismissed} height={100} width={100}>\n    Sized Modal\n  </Modal>\n);\n"
  },
  {
    "path": "packages/components/src/Modal/Modal.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Box } from 'theme-ui';\n\nexport interface Props {\n  isOpen: boolean;\n  onDismiss: () => void;\n  children: React.ReactNode;\n  width?: number;\n  height?: number;\n  sx?: ThemeUIStyleObject;\n}\n\nexport const Modal = (props: Props) => {\n  const { children, width = 300, height, isOpen, sx, onDismiss } = props;\n  const dialogRef = useRef<HTMLDialogElement>(null);\n  const mouseDownOutsideRef = useRef(false);\n\n  useEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n\n    if (isOpen) {\n      dialog.showModal();\n    } else {\n      dialog.close();\n    }\n  }, [isOpen]);\n\n  const handleMouseDown = (e: React.MouseEvent<HTMLDialogElement>) => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n\n    const rect = dialog.getBoundingClientRect();\n    mouseDownOutsideRef.current =\n      e.clientX < rect.left ||\n      e.clientX > rect.right ||\n      e.clientY < rect.top ||\n      e.clientY > rect.bottom;\n  };\n\n  const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n\n    // Only close if both mousedown and mouseup happened outside the modal\n    const rect = dialog.getBoundingClientRect();\n    const clickedOutside =\n      e.clientX < rect.left ||\n      e.clientX > rect.right ||\n      e.clientY < rect.top ||\n      e.clientY > rect.bottom;\n\n    if (mouseDownOutsideRef.current && clickedOutside) {\n      onDismiss?.();\n    }\n  };\n\n  const handleClose = () => {\n    onDismiss?.();\n  };\n\n  if (!isOpen) return null;\n\n  return (\n    <dialog\n      ref={dialogRef}\n      onMouseDown={handleMouseDown}\n      onClick={handleBackdropClick}\n      onClose={handleClose}\n      style={{\n        padding: 0,\n        border: 'none',\n        borderRadius: '10px',\n        maxWidth: '90vw',\n        maxHeight: '95vh',\n      }}\n    >\n      <Box\n        sx={{\n          padding: '16px',\n          display: 'flex',\n          flexDirection: 'column',\n          justifyContent: 'space-between',\n          width: width,\n          height: height,\n          maxWidth: 'inherit',\n          background: 'white',\n          border: '2px solid black',\n          borderRadius: '10px',\n          ...sx,\n        }}\n      >\n        {children}\n      </Box>\n    </dialog>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ModerationStatus/ModerationStatus.stories.tsx",
    "content": "import { ModerationStatus } from './ModerationStatus';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/ModerationStatus',\n  component: ModerationStatus,\n} as Meta<typeof ModerationStatus>;\n\nexport const Accepted: StoryFn<typeof ModerationStatus> = () => (\n  <ModerationStatus status=\"accepted\" />\n);\n\nexport const AwaitingModeration: StoryFn<typeof ModerationStatus> = () => (\n  <ModerationStatus status=\"awaiting-moderation\" />\n);\nexport const ImprovementsNeeded: StoryFn<typeof ModerationStatus> = () => (\n  <ModerationStatus status=\"improvements-needed\" />\n);\nexport const Rejected: StoryFn<typeof ModerationStatus> = () => (\n  <ModerationStatus status=\"rejected\" />\n);\n"
  },
  {
    "path": "packages/components/src/ModerationStatus/ModerationStatus.tsx",
    "content": "import type { Moderation } from 'oa-shared';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Text } from 'theme-ui';\n\nexport const ModerationRecord: Record<Moderation, string> = {\n  'awaiting-moderation': 'Awaiting Moderation',\n  'improvements-needed': 'Improvements Needed',\n  accepted: 'Accepted',\n  rejected: 'Rejected',\n};\n\nexport interface Props {\n  status: Moderation;\n  sx?: ThemeUIStyleObject;\n}\n\nexport const ModerationStatus = (props: Props) => {\n  const { status, sx } = props;\n\n  return (\n    <Text\n      sx={{\n        display: 'inline-block',\n        color: status === 'rejected' ? 'red' : 'black',\n        fontSize: 1,\n        whiteSpace: 'nowrap',\n        textOverflow: 'ellipsis',\n        overflow: 'hidden',\n        background: 'accent',\n        padding: 1,\n        borderRadius: 1,\n        borderBottomRightRadius: 1,\n        ...sx,\n      }}\n      data-cy={`moderationstatus-${status}`}\n    >\n      {ModerationRecord[status]}\n    </Text>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/MoreContainer/MoreContainer.stories.tsx",
    "content": "import { Flex, Heading } from 'theme-ui';\n\nimport { MoreContainer } from './MoreContainer';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/MoreContainer',\n  component: MoreContainer,\n  parameters: {\n    layout: 'padded',\n    backgrounds: {\n      default: 'twitter',\n      values: [\n        { name: 'twitter', value: '#00aced' },\n        { name: 'facebook', value: '#3b5998' },\n      ],\n    },\n  },\n} as Meta<typeof MoreContainer>;\n\nexport const Default: StoryFn<typeof MoreContainer> = () => (\n  <MoreContainer m={'0 auto'} pt={60} pb={90}>\n    <Flex\n      sx={{\n        alignItems: 'center',\n        flexDirection: 'column',\n      }}\n      mt={5}\n    >\n      <Heading>Some heading</Heading>\n      <>Some content</>\n    </Flex>\n  </MoreContainer>\n);\n"
  },
  {
    "path": "packages/components/src/MoreContainer/MoreContainer.tsx",
    "content": "import styled from '@emotion/styled';\nimport type { BoxProps } from 'theme-ui';\nimport { Box, useThemeUI } from 'theme-ui';\nimport WhiteBubble0 from '../../assets/images/white-bubble_0.svg';\nimport WhiteBubble1 from '../../assets/images/white-bubble_1.svg';\nimport WhiteBubble2 from '../../assets/images/white-bubble_2.svg';\nimport WhiteBubble3 from '../../assets/images/white-bubble_3.svg';\n\nconst MoreModalContainer = styled(Box)<{ theme: any }>`\n  position: relative;\n  max-width: 780px;\n  &:after {\n    content: '';\n    background-image: url('${WhiteBubble0}');\n    width: 100%;\n    height: 100%;\n    z-index: ${(props) => props.theme.zIndex.behind};\n    background-size: contain;\n    background-repeat: no-repeat;\n    position: absolute;\n    top: 59%;\n    transform: translate(-50%, -50%);\n    left: 50%;\n    max-width: 850px;\n    background-position: center 10%;\n  }\n  @media only screen and (min-width: ${(props) => props.theme.breakpoints[0]}) {\n    &:after {\n      background-image: url('${WhiteBubble1}');\n    }\n  }\n  @media only screen and (min-width: ${(props) => props.theme.breakpoints[1]}) {\n    &:after {\n      background-image: url('${WhiteBubble2}');\n    }\n  }\n\n  @media only screen and (min-width: ${(props) => props.theme.breakpoints[2]}) {\n    &:after {\n      background-image: url('${WhiteBubble3}');\n    }\n  }\n`;\n\nexport const MoreContainer = (props: BoxProps) => {\n  const { theme } = useThemeUI() as any;\n  return (\n    <MoreModalContainer theme={theme} {...(props as any)}>\n      {props.children}\n    </MoreModalContainer>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/NotificationItemSupabase/NotificationItemSupabase.stories.tsx",
    "content": "import { Flex, Heading } from 'theme-ui';\n\nimport { fakeDisplayNotification } from '../utils';\nimport { NotificationItemSupabase } from './NotificationItemSupabase';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/NotificationItemSupabase',\n  component: NotificationItemSupabase,\n} as Meta<typeof NotificationItemSupabase>;\n\nconst newsCommentNotification = fakeDisplayNotification();\nconst newsReplyNotification = fakeDisplayNotification({\n  contentType: 'comments',\n});\n\n// const questionCommentNotification = fakeDisplayNotification()\n// const questionReplyNotification = fakeDisplayNotification()\n// const researchCommentNotification = fakeDisplayNotification()\n// const researchReplyNotification = fakeDisplayNotification()\n\nconst markRead = () => console.log('markRead');\nconst modalDismiss = () => console.log('modalDismiss');\n\nexport const Default: StoryFn<typeof NotificationItemSupabase> = () => (\n  <Flex sx={{ gap: 2, maxWidth: '700px', flexDirection: 'column' }}>\n    <Heading>News</Heading>\n    <NotificationItemSupabase\n      markRead={markRead}\n      modalDismiss={modalDismiss}\n      notification={newsCommentNotification}\n    />\n    <NotificationItemSupabase\n      markRead={markRead}\n      modalDismiss={modalDismiss}\n      notification={newsReplyNotification}\n    />\n    {/* <Heading>Questions</Heading>\n    <NotificationItemSupabase\n      markRead={markRead}\n      modalDismiss={modalDismiss}\n      notification={questionCommentNotification}\n    />\n    <NotificationItemSupabase\n      markRead={markRead}\n      modalDismiss={modalDismiss}\n      notification={questionReplyNotification}\n    />\n    <Heading>Research</Heading>\n    <NotificationItemSupabase\n      markRead={markRead}\n      modalDismiss={modalDismiss}\n      notification={researchCommentNotification}\n    />\n    <NotificationItemSupabase\n      markRead={markRead}\n      modalDismiss={modalDismiss}\n      notification={researchReplyNotification}\n    /> */}\n  </Flex>\n);\n"
  },
  {
    "path": "packages/components/src/NotificationItemSupabase/NotificationItemSupabase.tsx",
    "content": "import type { NotificationDisplay } from 'oa-shared';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Avatar, Flex, Text } from 'theme-ui';\nimport { DisplayDate } from '../DisplayDate/DisplayDate';\nimport { Icon } from '../Icon/Icon';\nimport type { availableGlyphs } from '../Icon/types';\nimport { InternalLink } from '../InternalLink/InternalLink';\n\ninterface IProps {\n  markRead: (id: number) => void;\n  modalDismiss: () => void;\n  notification: NotificationDisplay;\n}\n\nconst commentStyling = {\n  '::before': {\n    content: 'open-quote',\n    position: 'absolute',\n    fontSize: '4em',\n    color: 'white',\n    textShadow:\n      '2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 1px 1px #000, -1px -1px #000, 1px -1px #000, -1px 1px #000',\n    transform: 'translateX(-8px) rotate(-10deg) translateY(-12px)',\n  },\n  '::after': {\n    content: 'close-quote',\n    position: 'relative',\n    bottom: 0,\n    height: 0,\n    width: '10px',\n    fontSize: '4em',\n    color: 'white',\n    textShadow:\n      '2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 1px 1px #000, -1px -1px #000, 1px -1px #000, -1px 1px #000',\n    transform: 'translateX(-8px) rotate(10deg) translateY(14px)',\n  },\n} as ThemeUIStyleObject;\n\nexport const NotificationItemSupabase = (props: IProps) => {\n  const { markRead, modalDismiss, notification } = props;\n\n  const borderStyle = {\n    background: notification.isRead ? 'background' : '#fff0b4',\n    borderColor: notification.isRead ? 'background' : 'activeYellow',\n    borderRadius: 3,\n    borderStyle: 'solid',\n    borderWidth: 2,\n    padding: 2,\n    gap: 2,\n  };\n\n  const onClick = () => {\n    markRead(notification.id);\n    modalDismiss();\n  };\n\n  const isDiscussion = notification.contentType === 'comments';\n\n  return (\n    <Flex data-cy=\"NotificationListItemSupabase\" data-testid=\"NotificationListItemSupabase\">\n      <InternalLink onClick={onClick} to={notification.link} sx={{ color: 'black', width: '100%' }}>\n        <Flex sx={borderStyle}>\n          {notification.sidebar.image ? (\n            <Avatar\n              src={notification.sidebar.image}\n              sx={{ width: 60, height: 60, objectFit: 'cover' }}\n            />\n          ) : (\n            notification.sidebar.icon && (\n              <Flex>\n                <Icon glyph={notification.sidebar.icon as availableGlyphs} size={30} />\n              </Flex>\n            )\n          )}\n          <Flex sx={{ flex: 1, flexDirection: 'column', gap: 2 }}>\n            <Flex sx={{ justifyContent: 'space-between', gap: 2 }}>\n              <Text sx={{ flex: 1 }}>\n                {notification.triggeredBy} {notification.title}\n              </Text>\n              <Text sx={{ fontSize: 1, color: 'grey', textAlign: 'right' }}>\n                <DisplayDate createdAt={notification.date} showLabel={false} />\n              </Text>\n            </Flex>\n            <Flex sx={{ ...(isDiscussion ? commentStyling : {}) }}>\n              <Text\n                sx={{\n                  background: 'softblue',\n                  border: '2px solid black',\n                  borderRadius: 5,\n                  padding: 2,\n\n                  overflow: 'hidden',\n                  textOverflow: 'ellipsis',\n                  whiteSpace: 'nowrap',\n                }}\n              >\n                {notification.body}\n              </Text>\n            </Flex>\n          </Flex>\n        </Flex>\n      </InternalLink>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/NotificationListSupabase/NotificationListSupabase.stories.tsx",
    "content": "import { fakeDisplayNotification } from '../utils';\nimport { NotificationListSupabase } from './NotificationListSupabase';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/NotificationListSupabase',\n  component: NotificationListSupabase,\n} as Meta<typeof NotificationListSupabase>;\n\nconst newsReplyNotification = fakeDisplayNotification({ isRead: false });\n\nconst questionCommentNotification = fakeDisplayNotification({ isRead: true });\n\nexport const Default: StoryFn<typeof NotificationListSupabase> = () => (\n  <NotificationListSupabase\n    isUpdatingNotifications={false}\n    markAllRead={() => console.log('markAllRead')}\n    markRead={() => console.log('markRead')}\n    modalDismiss={() => console.log('modalDismiss')}\n    notifications={[newsReplyNotification, questionCommentNotification]}\n  />\n);\n\nexport const NoNewNotifications: StoryFn<typeof NotificationListSupabase> = () => (\n  <NotificationListSupabase\n    isUpdatingNotifications={false}\n    markAllRead={() => console.log('markAllRead')}\n    markRead={() => console.log('markRead')}\n    modalDismiss={() => console.log('modalDismiss')}\n    notifications={[questionCommentNotification]}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/NotificationListSupabase/NotificationListSupabase.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { fakeDisplayNotification } from '../utils';\nimport { NotificationListSupabase } from './NotificationListSupabase';\n\ndescribe('NotificationListSupabase', () => {\n  it('Can show all notifications', () => {\n    const newsReplyNotification = fakeDisplayNotification({ isRead: false });\n    const questionCommentNotification = fakeDisplayNotification({\n      isRead: true,\n    });\n\n    const { getAllByTestId } = render(\n      <NotificationListSupabase\n        isUpdatingNotifications={false}\n        markAllRead={vi.fn()}\n        markRead={vi.fn()}\n        modalDismiss={vi.fn()}\n        notifications={[newsReplyNotification, questionCommentNotification]}\n      />,\n    );\n\n    const unreadNotifications = getAllByTestId('NotificationListItemSupabase');\n    expect(unreadNotifications).toHaveLength(1);\n  });\n});\n"
  },
  {
    "path": "packages/components/src/NotificationListSupabase/NotificationListSupabase.tsx",
    "content": "import type { NotificationDisplay } from 'oa-shared';\nimport { useState } from 'react';\nimport { Box, Flex, Heading } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { ButtonIcon } from '../ButtonIcon/ButtonIcon';\nimport { InternalLink } from '../InternalLink/InternalLink';\nimport { Loader } from '../Loader/Loader';\nimport { NotificationItemSupabase } from '../NotificationItemSupabase/NotificationItemSupabase';\n\nexport interface IProps {\n  isUpdatingNotifications: boolean;\n  markAllRead: () => void;\n  markRead: (id: number) => void;\n  modalDismiss: () => void;\n  notifications: NotificationDisplay[];\n}\n\nexport const NotificationListSupabase = (props: IProps) => {\n  const [isUnreadOnly, setIsUnreadOnly] = useState<boolean>(true);\n  const { isUpdatingNotifications, markAllRead, markRead, modalDismiss, notifications } = props;\n\n  const anyUnread = notifications.filter(({ isRead }) => !isRead).length > 0;\n  const notificationList = notifications\n    .filter(({ isRead }) => (isUnreadOnly ? !isRead : !isRead || isRead))\n    .sort((a, b) => (a.date < b.date ? 1 : -1));\n\n  return (\n    <Flex data-cy=\"NotificationListSupabase\" sx={{ flexDirection: 'column', gap: 4 }}>\n      <Flex sx={{ alignItems: 'center', justifyContent: 'space-between' }}>\n        <Heading sx={{ fontSize: 6 }}>Notifications</Heading>\n        <ButtonIcon\n          data-cy=\"NotificationListSupabase-CloseButton\"\n          icon=\"close\"\n          onClick={modalDismiss}\n          sx={{ border: 'none', paddingLeft: 2, paddingRight: 3 }}\n        />\n      </Flex>\n      <Flex\n        sx={{\n          justifyContent: 'space-between',\n          alignItems: 'center',\n          flexDirection: 'row',\n          gap: 2,\n        }}\n      >\n        <Flex sx={{ alignItems: 'center', gap: 2 }}>\n          <Flex\n            sx={{\n              border: '2px solid',\n              borderRadius: 2,\n              overflow: 'hidden',\n              backgroundColor: 'black',\n              gap: '2px',\n            }}\n          >\n            <Button\n              onClick={() => setIsUnreadOnly(true)}\n              variant=\"subtle\"\n              sx={{\n                backgroundColor: isUnreadOnly ? 'activeYellow' : 'white',\n                borderRadius: 0,\n                ':hover': {\n                  backgroundColor: isUnreadOnly ? 'activeYellow' : 'white',\n                  textDecoration: isUnreadOnly ? 'none' : 'underline',\n                },\n              }}\n            >\n              Unread\n            </Button>\n            <Button\n              data-testid=\"NotificationListSupabase-ShowAll\"\n              onClick={() => setIsUnreadOnly(false)}\n              variant=\"subtle\"\n              sx={{\n                backgroundColor: !isUnreadOnly ? 'activeYellow' : 'white',\n                borderRadius: 0,\n                ':hover': {\n                  backgroundColor: !isUnreadOnly ? 'activeYellow' : 'white',\n                  textDecoration: !isUnreadOnly ? 'none' : 'underline',\n                },\n              }}\n            >\n              All\n            </Button>\n          </Flex>\n          {anyUnread && (\n            <Button\n              data-testid=\"NotificationListSupabase-MarkAllRead\"\n              data-cy=\"NotificationListSupabase-MarkAllRead\"\n              onClick={markAllRead}\n              disabled={isUpdatingNotifications}\n              icon=\"doubleTick\"\n              variant=\"outline\"\n            >\n              Mark all read\n            </Button>\n          )}\n        </Flex>\n        <InternalLink to=\"/settings/notifications\">\n          <Button\n            icon=\"account\"\n            variant=\"outline\"\n            sx={{ alignSelf: 'flex-end' }}\n            onClick={modalDismiss}\n            showIconOnly\n          >\n            Update preferences\n          </Button>\n        </InternalLink>\n      </Flex>\n      {isUpdatingNotifications && <Loader />}\n      {!isUpdatingNotifications &&\n        notificationList.map((notification, index) => {\n          return (\n            <NotificationItemSupabase\n              key={index}\n              markRead={markRead}\n              modalDismiss={modalDismiss}\n              notification={notification}\n            />\n          );\n        })}\n      {notificationList.length === 0 && !isUpdatingNotifications && (\n        <Box\n          sx={{\n            backgroundColor: 'background',\n            borderRadius: 2,\n            padding: 4,\n            textAlign: 'center',\n          }}\n        >\n          Wow... No unread notifications!\n        </Box>\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/NotificationsModal/NotificationsModal.tsx",
    "content": "import { animated, useTransition } from '@react-spring/web';\nimport { Box, Flex } from 'theme-ui';\n\nexport interface Props {\n  isOpen: boolean;\n  children: React.ReactNode;\n}\n\nexport const NotificationsModal = (props: Props) => {\n  const { children, isOpen } = props;\n\n  const transitions = useTransition(isOpen, {\n    from: { transform: 'translateY(-100%)' },\n    enter: { transform: 'translateY(0%)' },\n    leave: { transform: 'translateY(-100%)' },\n    config: { duration: 200 },\n  });\n\n  return (\n    <>\n      {transitions(\n        (style, item) =>\n          item && (\n            <animated.div\n              style={{\n                ...style,\n                position: 'fixed',\n                top: 0,\n                left: 0,\n                width: '100%',\n                height: '100%',\n                background: 'white',\n                zIndex: 4000,\n              }}\n            >\n              <Flex\n                sx={{\n                  background: 'white',\n                  padding: 2,\n                  flexDirection: 'column',\n                  justifyContent: 'space-between',\n                  width: '800px',\n                  maxWidth: '100vw',\n                  height: '100%',\n                  maxHeight: '100%',\n                  position: 'fixed',\n                  left: '50%',\n                  top: '50%',\n                  transform: 'translate(-50%, -50%)',\n                  zIndex: 4001,\n                  overflow: 'scroll',\n                  scrollbarWidth: 'none',\n                }}\n              >\n                <Box sx={{ marginTop: 15 }}>{children}</Box>\n              </Flex>\n            </animated.div>\n          ),\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/OsmGeocoding/OsmGeocoding.stories.tsx",
    "content": "import { OsmGeocoding } from './OsmGeocoding';\nimport { OsmGeocodingResultsList } from './OsmGeocodingResultsList';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/OsmGeocoding',\n  component: OsmGeocoding,\n} as Meta<typeof OsmGeocoding>;\n\nexport const Default: StoryFn<typeof OsmGeocoding> = () => <OsmGeocoding loading={false} />;\n\nexport const Loading: StoryFn<typeof OsmGeocoding> = () => <OsmGeocoding loading />;\n\nexport const ResultsList: StoryFn<typeof OsmGeocodingResultsList> = () => (\n  <OsmGeocodingResultsList\n    results={[\n      {\n        place_id: 282375433,\n        licence: 'Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright',\n        osm_type: 'relation',\n        osm_id: 2658749,\n        boundingbox: ['53.0035627', '53.0546835', '5.6146842', '5.7246007'],\n        lat: '53.033548',\n        lon: '5.6611029',\n        display_name: 'Sneek, Súdwest-Fryslân, Frisia, Netherlands',\n        class: 'boundary',\n        type: 'administrative',\n        importance: 0.5823870580190648,\n        icon: 'https://nominatim.openstreetmap.org/ui/mapicons//poi_boundary_administrative.p.20.png',\n      },\n      {\n        place_id: 79535975,\n        licence: 'Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright',\n        osm_type: 'node',\n        osm_id: 7606839826,\n        boundingbox: ['53.0276201', '53.0376201', '5.6468895', '5.6568895'],\n        lat: '53.0326201',\n        lon: '5.6518895',\n        display_name:\n          'Sneek, Doctor Boumaweg, Sneek, Súdwest-Fryslân, Frisia, Netherlands, 8601GG, Netherlands',\n        class: 'railway',\n        type: 'station',\n        importance: 0.406301518086152,\n        icon: 'https://nominatim.openstreetmap.org/ui/mapicons//transport_train_station2.p.20.png',\n      },\n      {\n        place_id: 208556,\n        licence: 'Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright',\n        osm_type: 'node',\n        osm_id: 47984318,\n        boundingbox: ['53.1169959', '53.1170959', '5.8099395', '5.8100395'],\n        lat: '53.1170459',\n        lon: '5.8099895',\n        display_name: 'Sneek, A32, Idaerd, Leeuwarden, Frisia, Netherlands, 9007SE, Netherlands',\n        class: 'highway',\n        type: 'motorway_junction',\n        importance: 0.101,\n      },\n      {\n        place_id: 210270,\n        licence: 'Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright',\n        osm_type: 'node',\n        osm_id: 47965735,\n        boundingbox: ['53.1071958', '53.1072958', '5.8151597', '5.8152597'],\n        lat: '53.1072458',\n        lon: '5.8152097',\n        display_name: 'Sneek, A32, Idaerd, Leeuwarden, Frisia, Netherlands, 9007SH, Netherlands',\n        class: 'highway',\n        type: 'motorway_junction',\n        importance: 0.101,\n      },\n    ]}\n    callback={undefined}\n    setShowResults={() => null}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/OsmGeocoding/OsmGeocoding.tsx",
    "content": "import { useEffect, useRef, useState } from 'react';\nimport { useDebouncedCallback } from 'use-debounce';\n\nimport { SearchField } from '../SearchField/SearchField';\nimport { OsmGeocodingLoader } from './OsmGeocodingLoader';\nimport { OsmGeocodingResultsList } from './OsmGeocodingResultsList';\n\nimport type { Result } from './types';\n\nexport interface Props {\n  placeholder?: string;\n  debounceMs?: number;\n  iconUrl?: string;\n  callback?: any;\n  city?: string;\n  countrycodes?: string;\n  acceptLanguage?: string;\n  viewbox?: string;\n  loading?: boolean;\n}\n\nexport const OsmGeocoding = ({\n  placeholder = 'Search for an address',\n  debounceMs = 800,\n  callback,\n  acceptLanguage = 'en',\n  viewbox = '',\n  loading = false,\n}: Props) => {\n  const [searchValue, setSearchValue] = useState('');\n  const [results, setResults] = useState<Result[]>([]);\n  const [showResults, setShowResults] = useState(false);\n  const [showLoader, setShowLoader] = useState(loading);\n  const [queryLocationService, setQueryLocationService] = useState(false);\n  const mainContainerRef = useRef<HTMLDivElement>(null);\n\n  document.addEventListener('click', function (event) {\n    const isClickInside = mainContainerRef?.current?.contains(event.target as Node);\n    if (!isClickInside) {\n      setShowResults(false);\n    }\n  });\n\n  document.onkeyup = function (event) {\n    if (event.key === 'Escape') {\n      setShowResults(false);\n    }\n  };\n\n  function getGeocoding(address = '') {\n    if (address.length === 0) return;\n\n    setShowLoader(true);\n\n    let url = `https://nominatim.openstreetmap.org/search?format=json&q=${address}&accept-language=${acceptLanguage}`;\n\n    if (viewbox.length) {\n      url = `${url}&viewbox=${viewbox}&bounded=1`;\n    }\n\n    fetch(url, {\n      headers: new Headers({\n        'User-Agent': 'onearmy.earth Community Platform (https://platform.onearmy.earth)',\n      }),\n    })\n      .then((response) => response.json())\n      .then((data) => {\n        setResults(data);\n        setShowResults(true);\n      })\n      .catch(null)\n      .finally(() => setShowLoader(false));\n  }\n\n  const showResultsListing = !!results.length && showResults && !showLoader;\n\n  const dcb = useDebouncedCallback((search: string) => getGeocoding(search), debounceMs);\n\n  useEffect(() => {\n    if (queryLocationService) {\n      dcb(searchValue);\n    }\n  }, [searchValue, queryLocationService, dcb]);\n\n  return (\n    <div data-cy=\"osm-geocoding\" ref={mainContainerRef} style={{ width: '100%' }}>\n      <SearchField\n        autoComplete=\"off\"\n        name=\"geocoding\"\n        id=\"geocoding\"\n        dataCy=\"osm-geocoding-input\"\n        placeHolder={placeholder}\n        value={searchValue}\n        onChange={(value: string) => {\n          setQueryLocationService(true);\n          setSearchValue(value);\n        }}\n        onClear={() => {\n          setSearchValue('');\n          setQueryLocationService(false);\n        }}\n        onClickSearch={() => {\n          setQueryLocationService(true);\n          setSearchValue(searchValue);\n        }}\n        additionalStyle={{\n          background: 'white',\n          fontFamily: 'Varela Round',\n          fontSize: '14px',\n          border: '2px solid black',\n          height: '44px',\n          display: 'flex',\n          borderRadius: showResultsListing || showLoader ? '5px 5px 0 0' : '5px',\n          marginBottom: 0,\n        }}\n      />\n      {showLoader && <OsmGeocodingLoader />}\n      {showResultsListing && (\n        <OsmGeocodingResultsList\n          results={results}\n          callback={(result: Result) => {\n            if (result) {\n              setQueryLocationService(false);\n              setSearchValue(result.display_name);\n            }\n\n            callback(result);\n          }}\n          setShowResults={setShowResults}\n        />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/OsmGeocoding/OsmGeocodingLoader.tsx",
    "content": "import { Box, Text } from 'theme-ui';\n\nexport const OsmGeocodingLoader = () => {\n  return (\n    <>\n      <Box\n        sx={{\n          background: 'white',\n          position: 'relative',\n          zIndex: 1,\n          marginTop: '-2px',\n          paddingX: 2,\n          paddingY: 1,\n          border: '2px solid',\n          borderColor: 'black',\n          borderTopWidth: '1px',\n          lineHeight: 1.5,\n          borderBottomLeftRadius: 1,\n          borderBottomRightRadius: 1,\n        }}\n      >\n        <Text sx={{ fontSize: 1 }}>Fetching results from Open Street Map</Text>\n      </Box>\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/OsmGeocoding/OsmGeocodingResultsList.tsx",
    "content": "/** @jsxImportSource theme-ui */\nimport { Box } from 'theme-ui';\n\nimport type { Result } from './types';\n\nexport interface Props {\n  results: Result[];\n  callback: any;\n  setShowResults: React.Dispatch<React.SetStateAction<boolean>>;\n}\n\nexport const OsmGeocodingResultsList = (props: Props) => {\n  const { results, callback, setShowResults } = props;\n  return (\n    <Box\n      data-cy=\"osm-geocoding-results\"\n      as=\"ul\"\n      sx={{\n        background: 'white',\n        padding: 0,\n        position: 'relative',\n        zIndex: 1,\n        margin: '-2px 0 0',\n        border: `2px solid black`,\n        borderTopWidth: '1px',\n        listStyle: 'none',\n        borderRadius: 0,\n        borderBottomLeftRadius: 1,\n        borderBottomRightRadius: 1,\n      }}\n    >\n      {results.map((result: Result, index: number) => (\n        <Box\n          as=\"li\"\n          sx={{\n            paddingY: 1,\n            paddingX: 2,\n            lineHeight: 1.5,\n            '&:hover': {\n              background: 'softblue',\n              cursor: 'pointer',\n            },\n          }}\n          key={index}\n          onClick={() => {\n            setShowResults(false);\n            if (callback) {\n              callback(result);\n            }\n          }}\n        >\n          {result?.display_name}\n        </Box>\n      ))}\n    </Box>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/OsmGeocoding/types.tsx",
    "content": "export interface Result {\n  place_id: number;\n  licence: string;\n  osm_type: string;\n  osm_id: number;\n  boundingbox: string[];\n  display_name: string;\n  lat: string;\n  lon: string;\n  class: string;\n  type: string;\n  importance: number;\n  icon?: string;\n}\n"
  },
  {
    "path": "packages/components/src/Pagination/Pagination.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { fireEvent } from '@testing-library/react';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { Pagination } from './Pagination';\n\ndescribe('Pagination Component', () => {\n  const mockOnPageChange = vi.fn();\n\n  beforeEach(() => {\n    mockOnPageChange.mockClear();\n  });\n\n  it('renders correctly on the first page', () => {\n    const screen = render(<Pagination page={1} totalPages={10} onPageChange={mockOnPageChange} />);\n\n    expect(screen.getByLabelText('Enter page number')).toHaveValue(1);\n    expect(screen.getByText('of 10')).toBeInTheDocument();\n\n    expect(screen.queryByLabelText('Go to first page')).toBeNull();\n    expect(screen.queryByLabelText('Go to previous page')).toBeNull();\n    expect(screen.getByLabelText('Go to next page')).toBeInTheDocument();\n    expect(screen.getByLabelText('Go to last page')).toBeInTheDocument();\n  });\n\n  it('renders correctly on a middle page', () => {\n    const screen = render(<Pagination page={5} totalPages={10} onPageChange={mockOnPageChange} />);\n\n    expect(screen.getByLabelText('Enter page number')).toHaveValue(5);\n    expect(screen.getByLabelText('Go to first page')).toBeInTheDocument();\n    expect(screen.getByLabelText('Go to previous page')).toBeInTheDocument();\n    expect(screen.getByLabelText('Go to next page')).toBeInTheDocument();\n    expect(screen.getByLabelText('Go to last page')).toBeInTheDocument();\n  });\n\n  it('renders correctly on the last page', () => {\n    const screen = render(<Pagination page={10} totalPages={10} onPageChange={mockOnPageChange} />);\n\n    expect(screen.getByLabelText('Enter page number')).toHaveValue(10);\n    expect(screen.getByLabelText('Go to first page')).toBeInTheDocument();\n    expect(screen.getByLabelText('Go to previous page')).toBeInTheDocument();\n    expect(screen.queryByLabelText('Go to next page')).toBeNull();\n    expect(screen.queryByLabelText('Go to last page')).toBeNull();\n  });\n\n  it('calls onPageChange with the correct page number when next is clicked', () => {\n    const screen = render(<Pagination page={4} totalPages={10} onPageChange={mockOnPageChange} />);\n    const nextButton = screen.getByLabelText('Go to next page');\n    fireEvent.click(nextButton);\n    expect(mockOnPageChange).toHaveBeenCalledWith(5);\n  });\n\n  it('calls onPageChange with the correct page number when previous is clicked', () => {\n    const screen = render(<Pagination page={4} totalPages={10} onPageChange={mockOnPageChange} />);\n    const prevButton = screen.getByLabelText('Go to previous page');\n    fireEvent.click(prevButton);\n    expect(mockOnPageChange).toHaveBeenCalledWith(3);\n  });\n\n  it('calls onPageChange with the correct page number when first is clicked', () => {\n    const screen = render(<Pagination page={4} totalPages={10} onPageChange={mockOnPageChange} />);\n    const firstButton = screen.getByLabelText('Go to first page');\n    fireEvent.click(firstButton);\n    expect(mockOnPageChange).toHaveBeenCalledWith(1);\n  });\n\n  it('calls onPageChange with the correct page number when last is clicked', () => {\n    const screen = render(<Pagination page={4} totalPages={10} onPageChange={mockOnPageChange} />);\n    const lastButton = screen.getByLabelText('Go to last page');\n    fireEvent.click(lastButton);\n    expect(mockOnPageChange).toHaveBeenCalledWith(10);\n  });\n\n  it('calls onPageChange with the correct page number when input is changed', async () => {\n    vi.useFakeTimers();\n    const screen = render(<Pagination page={4} totalPages={10} onPageChange={mockOnPageChange} />);\n    const input = screen.getByLabelText('Enter page number');\n\n    fireEvent.change(input, { target: { value: '7' } });\n\n    vi.advanceTimersByTime(500);\n\n    expect(mockOnPageChange).toHaveBeenCalledWith(7);\n    vi.useRealTimers();\n  });\n\n  it('does not call onPageChange for invalid input', async () => {\n    vi.useFakeTimers();\n    const screen = render(<Pagination page={4} totalPages={10} onPageChange={mockOnPageChange} />);\n    const input = screen.getByLabelText('Enter page number');\n\n    fireEvent.change(input, { target: { value: '11' } });\n    vi.advanceTimersByTime(500);\n    expect(mockOnPageChange).not.toHaveBeenCalledWith(11);\n\n    fireEvent.change(input, { target: { value: '0' } });\n    vi.advanceTimersByTime(500);\n    expect(mockOnPageChange).not.toHaveBeenCalledWith(0);\n    vi.useRealTimers();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/Pagination/Pagination.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { Box, Flex, Input, Text } from 'theme-ui';\nimport { useDebouncedCallback } from 'use-debounce';\nimport { PaginationIcons } from '../PaginationIcons/PaginationIcons';\n\nexport interface Props {\n  page: number;\n  totalPages: number;\n  onPageChange: (pageNumber: number) => void;\n}\n\nexport const Pagination = ({ totalPages, page, onPageChange }: Props) => {\n  const [pageNumber, setPageNumber] = useState<number | undefined>(page);\n\n  const debouncedChange = useDebouncedCallback((value: number) => {\n    onPageChange(value);\n  }, 500);\n\n  const isValidPageNumber = (value: number | undefined): value is number => {\n    return value !== undefined && value >= 1 && value <= totalPages;\n  };\n\n  const handlePageChange = (value?: number) => {\n    if (isValidPageNumber(value) && value !== page) {\n      debouncedChange(value);\n    } else {\n      debouncedChange.cancel();\n    }\n  };\n\n  useEffect(() => {\n    setPageNumber(page);\n  }, [page]);\n\n  return (\n    <Flex\n      sx={{\n        justifyContent: 'center',\n        gap: 1,\n      }}\n      data-cy=\"pagination\"\n    >\n      <PaginationIcons\n        directionIcon=\"double-arrow-left\"\n        onClick={() => onPageChange(1)}\n        title=\"First Page\"\n        ariaLabel=\"Go to first page\"\n        hidden={page === 1}\n      />\n      <>\n        {page > 1 && (\n          <Text\n            sx={{\n              display: ['flex', 'none'],\n              alignItems: 'center',\n              cursor: 'pointer',\n              px: 2,\n            }}\n            hidden={page === 1}\n            onClick={() => onPageChange(page - 1)}\n          >\n            PREV\n          </Text>\n        )}\n        <Box sx={{ display: ['none', 'flex'] }}>\n          <PaginationIcons\n            directionIcon=\"paginationSingleLeft\"\n            onClick={() => onPageChange(page - 1)}\n            hidden={page === 1}\n            title=\"Previous Page\"\n            ariaLabel=\"Go to previous page\"\n          />\n        </Box>\n      </>\n\n      {totalPages !== 1 && (\n        <Flex\n          sx={{\n            justifyContent: 'center',\n            alignItems: 'center',\n            fontWeight: 'bold',\n            gap: 3,\n            flexGrow: 1,\n          }}\n        >\n          <Input\n            type=\"number\"\n            inputMode=\"numeric\"\n            min={1}\n            max={totalPages}\n            value={pageNumber}\n            onChange={(ev) => {\n              const value = parseInt(ev.target.value);\n              if (!isNaN(value)) {\n                setPageNumber(value);\n                handlePageChange(value);\n              } else {\n                handlePageChange();\n                setPageNumber(undefined);\n              }\n            }}\n            onBlur={() => {\n              if (isValidPageNumber(pageNumber) && pageNumber !== page + 1) {\n                debouncedChange.cancel();\n                onPageChange(pageNumber);\n              } else if (!pageNumber) {\n                debouncedChange.cancel();\n              } else {\n                setPageNumber(page);\n              }\n            }}\n            aria-label=\"Enter page number\"\n            sx={{\n              border: '2px solid black',\n              minWidth: '44px',\n              minHeight: '44px',\n              appearance: 'textfield',\n              MozAppearance: 'textfield',\n              '&::-webkit-outer-spin-button': {\n                WebkitAppearance: 'none',\n              },\n              '&::-webkit-inner-spin-button': {\n                WebkitAppearance: 'none',\n              },\n              justifyItems: 'center',\n              backgroundColor: 'white',\n              fontSize: 3,\n              fontFamily: \"'Varela Round',Arial,sans-serif\",\n            }}\n          />\n\n          <Text sx={{ minWidth: 'max-content' }}>{`of ${totalPages}`}</Text>\n        </Flex>\n      )}\n\n      <>\n        <Box sx={{ display: ['none', 'flex'] }}>\n          <PaginationIcons\n            directionIcon=\"paginationSingleRight\"\n            onClick={() => onPageChange(page + 1)}\n            hidden={page === totalPages}\n            title=\"Next Page\"\n            ariaLabel=\"Go to next page\"\n          />\n        </Box>\n        {page < totalPages && page >= 0 && (\n          <Text\n            sx={{\n              display: ['flex', 'none'],\n              alignItems: 'center',\n              cursor: 'pointer',\n              px: 2,\n            }}\n            hidden={page === totalPages}\n            onClick={() => onPageChange(page + 1)}\n          >\n            NEXT\n          </Text>\n        )}\n      </>\n      <PaginationIcons\n        directionIcon=\"double-arrow-right\"\n        onClick={() => onPageChange(totalPages)}\n        title=\"Last Page\"\n        ariaLabel=\"Go to last page\"\n        hidden={page === totalPages}\n      />\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/PaginationIcons/PaginationIcons.stories.tsx",
    "content": "import { PaginationIcons } from './PaginationIcons';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/PaginationIcons',\n  component: PaginationIcons,\n} as Meta<typeof PaginationIcons>;\n\nexport const ChevronLeft: StoryFn<typeof PaginationIcons> = () => (\n  <PaginationIcons\n    hidden={false}\n    title=\"Previous\"\n    directionIcon=\"paginationSingleLeft\"\n    ariaLabel='Previous page'\n    onClick={() => console.log('Previous clicked')}\n  />\n);\n\nexport const ChevronRight: StoryFn<typeof PaginationIcons> = () => (\n  <PaginationIcons\n    hidden={false}\n    title=\"Next\"\n    ariaLabel='Next page'\n    directionIcon=\"paginationSingleRight\"\n    onClick={() => console.log('Next clicked')}\n  />\n);\n\nexport const DoubleArrowLeft: StoryFn<typeof PaginationIcons> = () => (\n  <PaginationIcons\n    hidden={false}\n    title=\"First\"\n    ariaLabel='Navigate to first page'\n    directionIcon=\"double-arrow-left\"\n    onClick={() => console.log('First page clicked')}\n  />\n);\n\nexport const DoubleArrowRight: StoryFn<typeof PaginationIcons> = () => (\n  <PaginationIcons\n    hidden={false}\n    title=\"Last\"\n    ariaLabel='Navigate to last page'\n    directionIcon=\"double-arrow-right\"\n    onClick={() => console.log('Last page clicked')}\n  />\n);\n\n"
  },
  {
    "path": "packages/components/src/PaginationIcons/PaginationIcons.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { PaginationIcons } from './PaginationIcons';\n\ndescribe('PaginationIcons', () => {\n  it('renders the button with correct icon and title', () => {\n    const { getByTestId, getByTitle } = render(\n      <PaginationIcons\n        hidden={false}\n        ariaLabel='Navigate Previous page'\n        title=\"Previous\"\n        directionIcon=\"paginationSingleLeft\"\n        onClick={() => {}}\n      />,\n    );\n\n    expect(getByTestId('pagination-icon-paginationSingleLeft')).toBeInTheDocument();\n    expect(getByTitle('Previous')).toBeInTheDocument();\n  });\n\n  it('hides the button when hidden prop is true', () => {\n    const { queryByTestId } = render(\n      <PaginationIcons\n        ariaLabel=\"Navigate Previous page\"\n        hidden={true}\n        title=\"Previous\"\n        directionIcon=\"paginationSingleLeft\"\n        onClick={() => {}}\n      />,\n    );\n\n    const button = queryByTestId('pagination-icon-paginationSingleLeft');\n    expect(button).not.toBeInTheDocument();\n  });\n\n  it('displays the button when hidden prop is false', () => {\n    const { getByTestId } = render(\n      <PaginationIcons\n        ariaLabel='Navigate next page'\n        hidden={false}\n        title=\"Next\"\n        directionIcon=\"paginationSingleRight\"\n        onClick={() => {}}\n      />,\n    );\n\n    const button = getByTestId('pagination-icon-paginationSingleRight');\n    expect(button).toBeInTheDocument()\n  });\n\n  it('calls onClick handler when button is clicked', () => {\n    const handleClick = vi.fn();\n    const { getByTestId } = render(\n      <PaginationIcons\n        ariaLabel='Navigate next page'\n        hidden={false}\n        title=\"Next\"\n        directionIcon=\"paginationSingleRight\"\n        onClick={handleClick}\n      />,\n    );\n\n    getByTestId('pagination-icon-paginationSingleRight').click();\n    expect(handleClick).toHaveBeenCalledOnce();\n  });\n\n  it('renders different icon types', () => {\n    const icons: Array<\n      'paginationSingleLeft' | 'paginationSingleRight' | 'double-arrow-left' | 'double-arrow-right'\n    > = ['paginationSingleLeft', 'paginationSingleRight', 'double-arrow-left', 'double-arrow-right'];\n\n    icons.forEach((icon) => {\n      const { getByTestId } = render(\n        <PaginationIcons\n        ariaLabel='Navigate next page'\n          hidden={false}\n          title={`Icon: ${icon}`}\n          directionIcon={icon}\n          onClick={() => {}}\n        />,\n      );\n\n      expect(getByTestId(`pagination-icon-${icon}`)).toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/components/src/PaginationIcons/PaginationIcons.tsx",
    "content": "import { Button } from '../Button/Button';\n\ninterface IProps {\n  hidden?: boolean;\n  title: string;\n  ariaLabel: string;\n  directionIcon:\n    | 'paginationSingleLeft'\n    | 'paginationSingleRight'\n    | 'double-arrow-left'\n    | 'double-arrow-right';\n  onClick: () => void;\n}\n\nexport const PaginationIcons = ({ hidden, title, ariaLabel, directionIcon, onClick }: IProps) => {\n  if (hidden) return null;\n\n  return (\n    <Button\n      type=\"button\"\n      data-cy={`pagination-icon-${directionIcon}`}\n      data-testid={`pagination-icon-${directionIcon}`}\n      icon={directionIcon}\n      onClick={onClick}\n      title={title}\n      aria-label={ariaLabel}\n      sx={{\n        display: 'flex',\n        minWidth: '44px',\n        minHeight: '44px',\n        justifyContent: 'center',\n        alignItems: 'center',\n        padding: '0px',\n      }}\n      variant=\"subtle\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/PinProfile/PinProfile.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\n\nimport { PinProfile } from './PinProfile';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { MapPin, Moderation } from 'oa-shared';\n\nexport default {\n  title: 'Map/PinProfile',\n  component: PinProfile,\n} as Meta<typeof PinProfile>;\n\nexport const DefaultMember: StoryFn<typeof PinProfile> = () => {\n  const item = {\n    id: 1,\n    lat: 0,\n    lng: 0,\n    administrative: '',\n    country: 'Brazil',\n    countryCode: 'BR',\n    moderation: 'accepted' as Moderation,\n    profile: {\n      id: 1,\n      photo: {\n        publicUrl: faker.image.avatar(),\n      },\n      displayName: 'member_no1',\n      isContactable: false,\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n      },\n    },\n  } as MapPin;\n\n  return (\n    <div style={{ width: '230px', position: 'fixed' }}>\n      <PinProfile item={item} onClose={() => console.log()} />\n    </div>\n  );\n};\n\nexport const DefaultSpace: StoryFn<typeof PinProfile> = () => {\n  const item = {\n    id: 2,\n    lat: 0,\n    lng: 0,\n    moderation: 'accepted' as Moderation,\n    administrative: '',\n    country: 'United Kingdom',\n    countryCode: 'UK',\n    profile: {\n      id: 3,\n      photo: {\n        publicUrl: faker.image.avatar(),\n      },\n      about:\n        'Lorem ipsum odor amet, consectetuer adipiscing elit. Lorem ipsum odor amet, consectetuer adipiscing elit.',\n      badges: [\n        {\n          id: 1,\n          name: 'supporter',\n          displayName: 'Supporter',\n          actionUrl: 'https://www.patreon.com/one_army',\n          imageUrl: faker.image.avatar(),\n        },\n      ],\n      displayName: 'user',\n      isContactable: true,\n      type: {\n        id: 2,\n        name: 'workspace',\n        displayName: 'Workspace',\n      },\n      tags: [{ name: 'Sheetpress', id: 1 }],\n    },\n  } as MapPin;\n\n  return (\n    <div style={{ width: '230px', position: 'fixed' }}>\n      <PinProfile item={item} onClose={() => console.log()} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/PinProfile/PinProfile.tsx",
    "content": "import type { MapPin } from 'oa-shared';\nimport { Box, Flex } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { ButtonIcon } from '../ButtonIcon/ButtonIcon';\nimport { CardButton } from '../CardButton/CardButton';\nimport { CardProfile } from '../CardProfile/CardProfile';\nimport { InternalLink } from '../InternalLink/InternalLink';\n\nexport interface IProps {\n  item: MapPin;\n  onClose: () => void;\n}\n\nexport const PinProfile = ({ item, onClose }: IProps) => {\n  const isContactable = item.profile?.isContactable !== false && item.profile?.username;\n\n  return (\n    <CardButton sx={{ '&:hover': 'none' }} data-cy=\"PinProfile\">\n      <Box sx={{ position: 'absolute', right: 0 }}>\n        <Box sx={{ float: 'right', marginTop: 1, marginRight: '8px' }}>\n          <ButtonIcon\n            data-cy=\"PinProfileCloseButton\"\n            icon=\"close\"\n            onClick={() => onClose()}\n            sx={{ borderWidth: 0, height: 'auto' }}\n          />\n        </Box>\n      </Box>\n      <Box sx={{ width: '100%', height: '100%', zIndex: 2 }}>\n        <CardProfile item={item} isLink />\n\n        {isContactable && (\n          <Flex sx={{ justifyContent: 'flex-end' }}>\n            <InternalLink\n              to={`/u/${item.profile?.username}#contact`}\n              data-cy=\"PinProfileMessageLink\"\n              target=\"_blank\"\n            >\n              <Button icon=\"contact\" sx={{ margin: 1 }} small>\n                Send Message\n              </Button>\n            </InternalLink>\n          </Flex>\n        )}\n      </Box>\n    </CardButton>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ProfileBadgeContentLabel/ProfileBadgeContentLabel.stories.tsx",
    "content": "import { ProfileBadgeContentLabel } from './ProfileBadgeContentLabel';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/ProfileBadgeContentLabel',\n  component: ProfileBadgeContentLabel,\n} as Meta<typeof ProfileBadgeContentLabel>;\n\nexport const Default: StoryFn<typeof ProfileBadgeContentLabel> = () => {\n  const profileBadge = {\n    id: 1,\n    name: 'prop',\n    displayName: 'PRO',\n    imageUrl:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/icons/pro.svg',\n  };\n  return <ProfileBadgeContentLabel profileBadge={profileBadge} />;\n};\n"
  },
  {
    "path": "packages/components/src/ProfileBadgeContentLabel/ProfileBadgeContentLabel.tsx",
    "content": "import type { ProfileBadge } from 'oa-shared';\nimport { Flex, Text } from 'theme-ui';\nimport { UserBadge } from '../Username/UserBadge';\n\nexport interface Props {\n  profileBadge: ProfileBadge;\n}\n\nexport const ProfileBadgeContentLabel = ({ profileBadge }: Props) => {\n  return (\n    <Flex\n      data-cy=\"profileBadge\"\n      sx={{\n        alignItems: 'center',\n        fontSize: 1,\n        color: '#555555',\n        backgroundColor: 'softblue',\n        paddingX: 1,\n        paddingY: 1,\n        borderRadius: 1,\n        gap: 1,\n      }}\n    >\n      <UserBadge badge={profileBadge} />\n      <Text>only news</Text>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ProfileLink/ProfileLink.stories.tsx",
    "content": "import { ProfileLink } from './ProfileLink';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/ProfileLink',\n  component: ProfileLink,\n} as Meta<typeof ProfileLink>;\n\nexport const Website: StoryFn<typeof ProfileLink> = () => <ProfileLink url=\"https://example.com\" />;\n"
  },
  {
    "path": "packages/components/src/ProfileLink/ProfileLink.tsx",
    "content": "import type { ThemeUICSSObject } from 'theme-ui';\nimport { Box, Flex } from 'theme-ui';\nimport { ExternalLink } from '../ExternalLink/ExternalLink';\nimport { Icon } from '../Icon/Icon';\n\nexport interface Props {\n  url: string;\n  sx?: ThemeUICSSObject;\n}\n\nexport const ProfileLink = (props: Props) => {\n  return (\n    <Flex\n      sx={{\n        justifyContent: 'flex-start',\n        alignItems: 'center',\n        flexDirection: 'row',\n        mt: 0,\n        ...props.sx,\n      }}\n    >\n      <Box>\n        <Icon glyph=\"website\" size={22} />\n      </Box>\n      <ExternalLink marginLeft={2} color=\"black\" data-cy=\"profile-website\" href={props.url}>\n        {props.url}\n      </ExternalLink>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ProfileList/ProfileList.stories.tsx",
    "content": "import { ProfileList } from './ProfileList';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\nimport type { ProfileListItem } from 'oa-shared';\n\nexport default {\n  title: 'Components/ProfileList',\n  component: ProfileList,\n} as Meta<typeof ProfileList>;\n\nconst mockProfiles: ProfileListItem[] = [\n  {\n    id: 1,\n    username: 'test_user',\n    displayName: 'Test User',\n    photo: null,\n    country: 'USA',\n    badges: [],\n    type: null,\n  },\n  {\n    id: 2,\n    username: 'example_user',\n    displayName: 'Example User',\n    photo: null,\n    country: 'UK',\n    badges: [],\n    type: null,\n  },\n];\n\nexport const Default: StoryFn<typeof ProfileList> = () => (\n  <ProfileList profiles={mockProfiles} header=\"Profile List\" />\n);\n"
  },
  {
    "path": "packages/components/src/ProfileList/ProfileList.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { fireEvent } from '@testing-library/react';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { ProfileList } from './ProfileList';\n\nimport type { ProfileListItem } from 'oa-shared';\n\ndescribe('ProfileList', () => {\n  it('renders the header and profiles', () => {\n    const mockProfiles: ProfileListItem[] = [\n      {\n        id: 1,\n        username: 'test_user',\n        displayName: 'Test User',\n        photo: null,\n        country: 'USA',\n        badges: [],\n        type: null,\n      },\n      {\n        id: 2,\n        username: 'example_user',\n        displayName: 'Example User',\n        photo: null,\n        country: 'UK',\n        badges: [],\n        type: null,\n      },\n    ];\n    const { getByText } = render(<ProfileList profiles={mockProfiles} header=\"Test Header\" />);\n    expect(getByText('Test Header')).toBeInTheDocument();\n    expect(getByText('test_user')).toBeInTheDocument();\n    expect(getByText('example_user')).toBeInTheDocument();\n  });\n\n  it('shows no users message when profiles is empty', () => {\n    const { getByText } = render(<ProfileList profiles={[]} header=\"Empty List\" />);\n    expect(getByText('No users yet.')).toBeInTheDocument();\n  });\n\n  it('calls onClose when overlay is clicked', () => {\n    const onClose = vi.fn();\n    const { getByRole } = render(\n      <ProfileList profiles={[]} header=\"Close Test\" onClose={onClose} />,\n    );\n    // Click the close button\n    const closeButton = getByRole('button');\n    fireEvent.click(closeButton);\n    expect(onClose).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/ProfileList/ProfileList.tsx",
    "content": "import type { ProfileListItem } from 'oa-shared';\nimport { Avatar, Box, Flex, Text } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { MemberBadge } from '../MemberBadge/MemberBadge';\nimport { Username } from '../Username/Username';\n\ninterface IProps {\n  profiles: ProfileListItem[];\n  onClose?: () => void;\n  children?: React.ReactNode;\n  header: string;\n}\n\nexport const ProfileList = (props: IProps) => {\n  const { profiles, onClose, header } = props;\n\n  return (\n    <Flex\n      data-cy=\"profile-list-modal\"\n      sx={{\n        position: 'fixed',\n        inset: 0,\n        bg: 'rgba(0,0,0,0.5)',\n        alignItems: 'center',\n        justifyContent: 'center',\n        zIndex: 1000,\n      }}\n      onClick={onClose}\n    >\n      <Box\n        sx={{\n          bg: 'background',\n          borderRadius: '10px',\n          width: ['80%', '23%'],\n          height: 'auto',\n          maxHeight: ['40%', '50%'],\n          border: '2px solid',\n          overflow: 'hidden',\n          display: 'flex',\n          flexDirection: 'column',\n        }}\n        onClick={(e) => e.stopPropagation()}\n      >\n        <Flex\n          sx={{\n            justifyContent: 'space-between',\n            alignItems: 'center',\n            borderBottom: '2px solid',\n            borderColor: 'muted',\n            p: 1,\n            width: '100%',\n            position: 'relative',\n          }}\n        >\n          <Text\n            sx={{\n              fontWeight: 600,\n              fontSize: 2,\n              textAlign: 'center',\n              width: '100%',\n            }}\n          >\n            {header}\n          </Text>\n          <Button variant=\"subtle\" showIconOnly icon=\"close\" small onClick={onClose} />\n        </Flex>\n\n        <Box\n          sx={{\n            flex: '1 1 auto',\n            overflowY: 'auto',\n            pl: 3,\n          }}\n        >\n          {profiles.length === 0 ? (\n            <Text sx={{ textAlign: 'center', color: 'muted', fontSize: 1 }}>No users yet.</Text>\n          ) : (\n            <Box\n              as=\"ul\"\n              sx={{\n                listStyle: 'none',\n                m: 0,\n                p: 0,\n              }}\n            >\n              {profiles.map((profile) => (\n                <Flex\n                  as=\"li\"\n                  key={profile.id}\n                  sx={{\n                    alignItems: 'center',\n                    py: 2,\n                    gap: 2,\n                  }}\n                >\n                  {profile.photo ? (\n                    <Avatar\n                      src={profile.photo?.publicUrl}\n                      sx={{\n                        width: 40,\n                        height: 40,\n                        borderRadius: '50%',\n                        objectFit: 'cover',\n                      }}\n                      loading=\"lazy\"\n                    />\n                  ) : (\n                    <MemberBadge\n                      profileType={profile.type || undefined}\n                      sx={{ cursor: 'pointer' }}\n                    />\n                  )}\n                  <Box>\n                    <Username user={profile} />\n                  </Box>\n                </Flex>\n              ))}\n            </Box>\n          )}\n        </Box>\n      </Box>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ProfileTagsList/ProfileTagsList.stories.tsx",
    "content": "import { ProfileTagsList } from './ProfileTagsList';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/ProfileTagsList',\n  component: ProfileTagsList,\n} as Meta<typeof ProfileTagsList>;\n\nexport const Default: StoryFn<typeof ProfileTagsList> = () => (\n  <ProfileTagsList\n    tags={[\n      {\n        id: 1,\n        createdAt: new Date(),\n        name: 'Electronics',\n        profileType: 'space',\n      },\n      {\n        id: 2,\n        createdAt: new Date(),\n        name: 'Graphic Design',\n        profileType: 'member',\n      },\n    ]}\n    isSpace={false}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/ProfileTagsList/ProfileTagsList.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { ProfileTagsList } from './ProfileTagsList';\n\ndescribe('ProfileTagsList', () => {\n  it('shows the electronics tag from default arguments', () => {\n    const { getByText } = render(\n      <ProfileTagsList\n        tags={[\n          {\n            id: 1,\n            createdAt: new Date(),\n            name: 'Electronics',\n            profileType: 'space',\n          },\n          {\n            id: 2,\n            createdAt: new Date(),\n            name: 'Graphic Design',\n            profileType: 'member',\n          },\n        ]}\n        isSpace={false}\n      />,\n    );\n\n    expect(getByText('Electronics')).toBeInTheDocument();\n  });\n\n  it('shows nothing when no tags or visitor info present', () => {\n    const { getByTestId } = render(<ProfileTagsList tags={[]} isSpace />);\n\n    expect(getByTestId('ProfileTagsList')).toBeEmptyDOMElement();\n  });\n\n  it('shows open when open for visitors', () => {\n    const { getByText } = render(\n      <ProfileTagsList tags={[]} visitorPolicy={{ policy: 'open' }} isSpace />,\n    );\n\n    expect(getByText('Open to visitors', { exact: false })).toBeInTheDocument();\n  });\n\n  it('shows appointment when visits by appointment', () => {\n    const { getByText } = render(\n      <ProfileTagsList tags={[]} visitorPolicy={{ policy: 'appointment' }} isSpace />,\n    );\n\n    expect(getByText('Visitors after appointment', { exact: false })).toBeInTheDocument();\n  });\n\n  it('triggers callback when clicking closed visitor tag', () => {\n    const callback = vi.fn();\n    const { getByText } = render(\n      <ProfileTagsList\n        tags={[]}\n        visitorPolicy={{ policy: 'closed' }}\n        showVisitorModal={callback}\n        isSpace\n      />,\n    );\n\n    const visitorTag = getByText('Visits currently not possible', {\n      exact: false,\n    });\n    expect(visitorTag).toBeInTheDocument();\n    visitorTag.click();\n\n    expect(callback).toBeCalled();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/ProfileTagsList/ProfileTagsList.tsx",
    "content": "import type { Profile, ProfileTag } from 'oa-shared';\nimport type { ComponentProps } from 'react';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Flex, Text } from 'theme-ui';\nimport { visitorDisplayData } from '../VisitorModal/VisitorModal';\n\nexport interface IProps {\n  tags: ProfileTag[] | null;\n  visitorPolicy?: Profile['visitorPolicy'];\n  isSpace: boolean;\n  showVisitorModal?: () => void;\n  sx?: ThemeUIStyleObject;\n  large?: boolean;\n}\n\nconst DEFAULT_COLOR = '#999999';\n\ntype TagProps = ComponentProps<typeof Text> & {\n  label: string;\n  large: IProps['large'];\n  color?: string;\n  dataCy?: string;\n};\n\nconst Tag = ({ color, dataCy, label, large, onClick }: TagProps) => {\n  const sizing = large\n    ? {\n        fontSize: 2,\n        paddingX: 2,\n        paddingY: '10px',\n      }\n    : {\n        fontSize: 1,\n        paddingX: '7.5px',\n        paddingY: '5px',\n      };\n  return (\n    <Text\n      data-cy={dataCy}\n      sx={{\n        borderRadius: 99,\n        border: '1px solid',\n        borderColor: color,\n        backgroundColor: `${color}20`,\n        color: color,\n        ...sizing,\n        // Correction for misalignment due to \\u24D8\n        ...(large && !onClick ? { paddingTop: '12px' } : {}),\n        ':hover': onClick\n          ? {\n              cursor: 'pointer',\n            }\n          : {},\n      }}\n      onClick={onClick}\n    >\n      {label}\n    </Text>\n  );\n};\n\nconst policyColors = new Map([\n  ['open', '#116503'],\n  ['appointment', '#005471'],\n  ['closed', DEFAULT_COLOR],\n]);\n\nexport const ProfileTagsList = (props: IProps) => {\n  const { tags, visitorPolicy, isSpace, showVisitorModal, sx, large } = props;\n  const tagList = tags || [];\n\n  return (\n    <Flex\n      data-cy=\"ProfileTagsList\"\n      data-testid=\"ProfileTagsList\"\n      sx={{ gap: 1, flexWrap: 'wrap', ...sx }}\n    >\n      {tagList.map(({ name }, index) => (\n        <Tag key={index} color={DEFAULT_COLOR} label={name} large={large} />\n      ))}\n      {visitorPolicy && isSpace && (\n        <Tag\n          dataCy=\"tag-openToVisitors\"\n          color={policyColors.get(visitorPolicy.policy)}\n          label={`${visitorDisplayData.get(visitorPolicy.policy)?.label} \\u24D8`}\n          onClick={() => {\n            showVisitorModal && showVisitorModal();\n          }}\n          large={true}\n        />\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ResearchEditorOverview/ResearchEditorOverview.stories.tsx",
    "content": "import { ResearchEditorOverview } from './ResearchEditorOverview';\nimport type { Meta, StoryObj } from '@storybook/react-vite';\n\nconst meta: Meta<typeof ResearchEditorOverview> = {\n  title: 'Layout/ResearchEditorOverview',\n  component: ResearchEditorOverview,\n};\nexport default meta;\n\ntype Story = StoryObj<typeof ResearchEditorOverview>;\n\nexport const Default: Story = {\n  args: {\n    updates: [\n      { isActive: true, title: 'Update 1', id: 1, isDraft: false },\n      { isActive: false, title: 'Update 2', id: 2, isDraft: false },\n      { isActive: false, title: 'Update 3', id: 3, isDraft: false },\n    ],\n    researchSlug: 'abc',\n    showBackToResearchButton: true,\n    showCreateUpdateButton: true,\n  },\n};\n\nexport const ShowBackToResearchButton: Story = {\n  args: {\n    ...Default.args,\n    showBackToResearchButton: true,\n  },\n};\n\nexport const ShowCreateUpdateButton: Story = {\n  args: {\n    ...Default.args,\n    showCreateUpdateButton: true,\n  },\n};\n\nexport const DraftItem: Story = {\n  args: {\n    ...Default.args,\n    updates: [\n      { isActive: false, title: 'Update 1', id: 1, isDraft: true },\n    ],\n  },\n};"
  },
  {
    "path": "packages/components/src/ResearchEditorOverview/ResearchEditorOverview.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { ResearchEditorOverview } from './ResearchEditorOverview';\n\nimport type { ResearchEditorOverviewProps } from './ResearchEditorOverview';\n\nconst defaultProps: ResearchEditorOverviewProps = {\n  updates: [\n    {\n      isActive: true,\n      title: 'Update 1',\n      id: 1,\n      isDraft: false,\n    },\n    {\n      isActive: false,\n      title: 'Update 2',\n      id: 2,\n      isDraft: false,\n    },\n    {\n      isActive: false,\n      title: 'Update 3',\n      id: 3,\n      isDraft: false,\n    },\n  ],\n  researchSlug: 'abc',\n  showBackToResearchButton: true,\n  showCreateUpdateButton: true,\n};\n\ndescribe('ResearchEditorOverview', () => {\n  it('renders correctly', () => {\n    const { container } = render(<ResearchEditorOverview {...defaultProps} />);\n\n    expect(container).toMatchSnapshot();\n  });\n\n  it('links back to research', () => {\n    const { getByText } = render(\n      <ResearchEditorOverview {...defaultProps} showBackToResearchButton={true} />,\n    );\n\n    expect(getByText('Back to research')).toBeInTheDocument();\n  });\n\n  it('links to create update', () => {\n    const { getByText } = render(\n      <ResearchEditorOverview {...defaultProps} showCreateUpdateButton={true} />,\n    );\n\n    expect(getByText('Create update')).toBeInTheDocument();\n  });\n\n  it('handles empty updates', () => {\n    const { container } = render(<ResearchEditorOverview {...defaultProps} updates={[]} />);\n\n    expect(container).toMatchSnapshot();\n  });\n\n  it('handles falsey updates', () => {\n    const { container } = render(\n      <ResearchEditorOverview {...defaultProps} updates={null as any} />,\n    );\n\n    expect(container).toMatchSnapshot();\n  });\n\n  it('handles malformed update item', () => {\n    const { getByText } = render(\n      <ResearchEditorOverview\n        {...defaultProps}\n        updates={\n          [\n            {\n              isActive: true,\n              title: 'Update title',\n              slug: 'a-slug',\n            },\n          ] as any\n        }\n      />,\n    );\n\n    expect(getByText('Update title')).toBeInTheDocument();\n  });\n\n  it('displays a Draft label', () => {\n    const { getByText } = render(\n      <ResearchEditorOverview\n        {...defaultProps}\n        updates={[\n          {\n            isActive: false,\n            title: 'Update 1',\n            id: 1,\n            isDraft: true,\n          },\n        ]}\n      />,\n    );\n\n    expect(getByText('Draft')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/ResearchEditorOverview/ResearchEditorOverview.tsx",
    "content": "import { Box, Card, Heading, Text } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { InternalLink } from '../InternalLink/InternalLink';\n\nexport type ResearchEditorOverviewUpdate = {\n  isActive: boolean;\n  title: string;\n  isDraft: boolean;\n  id: number | null;\n};\n\nexport interface ResearchEditorOverviewProps {\n  updates: ResearchEditorOverviewUpdate[];\n  researchSlug: string;\n  newItemTitle?: string;\n  showCreateUpdateButton?: boolean;\n  showBackToResearchButton?: boolean;\n}\n\nexport const ResearchEditorOverview = (props: ResearchEditorOverviewProps) => {\n  const { updates, researchSlug, showCreateUpdateButton, showBackToResearchButton } = props;\n  return (\n    <Card sx={{ padding: 4 }}>\n      <Heading as=\"h2\" mb={3} variant=\"small\">\n        Research overview\n      </Heading>\n      {updates?.length ? (\n        <Box as=\"ul\" sx={{ margin: 0, marginBottom: 4, padding: 0, paddingLeft: 3 }}>\n          {updates.map((update, index) => (\n            <Box as=\"li\" key={index} sx={{ marginBottom: 1 }}>\n              <Text variant={'quiet'}>\n                {update.isDraft ? (\n                  <Text\n                    sx={{\n                      display: 'inline-block',\n                      verticalAlign: 'middle',\n                      color: 'black',\n                      fontSize: 1,\n                      whiteSpace: 'nowrap',\n                      textOverflow: 'ellipsis',\n                      overflow: 'hidden',\n                      background: 'accent',\n                      padding: 1,\n                      borderRadius: 1,\n                      borderBottomRightRadius: 1,\n                      mr: 1,\n                    }}\n                  >\n                    Draft\n                  </Text>\n                ) : null}\n                {update.isActive ? (\n                  <strong>{update.title}</strong>\n                ) : (\n                  <>\n                    {update.title}\n                    {update.id ? (\n                      <InternalLink\n                        to={`/research/${researchSlug}/edit-update/${update.id}`}\n                        sx={{ display: 'inline-block', ml: 1 }}\n                      >\n                        Edit\n                      </InternalLink>\n                    ) : null}\n                  </>\n                )}\n              </Text>\n            </Box>\n          ))}\n        </Box>\n      ) : null}\n      {showCreateUpdateButton ? (\n        <Button small sx={{ mr: 2 }} data-cy=\"create-update\" type=\"button\">\n          <InternalLink to={`/research/${researchSlug}/new-update`} sx={{ color: 'black' }}>\n            Create update\n          </InternalLink>\n        </Button>\n      ) : null}\n\n      {showBackToResearchButton ? (\n        <Button small variant=\"outline\" type=\"button\">\n          <InternalLink to={`/research/${researchSlug}/edit`} sx={{ color: 'black' }}>\n            Back to research\n          </InternalLink>\n        </Button>\n      ) : null}\n    </Card>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/ResearchEditorOverview/__snapshots__/ResearchEditorOverview.test.tsx.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`ResearchEditorOverview > handles empty updates 1`] = `\n<div>\n  <div\n    class=\"css-nfzwtt\"\n  >\n    <h2\n      class=\"css-40p7h6\"\n    >\n      Research overview\n    </h2>\n    <button\n      class=\"css-8611sj\"\n      data-cy=\"create-update\"\n      type=\"button\"\n    >\n      <span\n        class=\"css-lwcnm9\"\n      >\n        <a\n          class=\"css-1taq3td\"\n          data-discover=\"true\"\n          href=\"/research/abc/new-update\"\n        >\n          Create update\n        </a>\n      </span>\n    </button>\n    <button\n      class=\"css-m21utd\"\n      type=\"button\"\n    >\n      <span\n        class=\"css-lwcnm9\"\n      >\n        <a\n          class=\"css-1taq3td\"\n          data-discover=\"true\"\n          href=\"/research/abc/edit\"\n        >\n          Back to research\n        </a>\n      </span>\n    </button>\n  </div>\n</div>\n`;\n\nexports[`ResearchEditorOverview > handles falsey updates 1`] = `\n<div>\n  <div\n    class=\"css-nfzwtt\"\n  >\n    <h2\n      class=\"css-40p7h6\"\n    >\n      Research overview\n    </h2>\n    <button\n      class=\"css-8611sj\"\n      data-cy=\"create-update\"\n      type=\"button\"\n    >\n      <span\n        class=\"css-lwcnm9\"\n      >\n        <a\n          class=\"css-1taq3td\"\n          data-discover=\"true\"\n          href=\"/research/abc/new-update\"\n        >\n          Create update\n        </a>\n      </span>\n    </button>\n    <button\n      class=\"css-m21utd\"\n      type=\"button\"\n    >\n      <span\n        class=\"css-lwcnm9\"\n      >\n        <a\n          class=\"css-1taq3td\"\n          data-discover=\"true\"\n          href=\"/research/abc/edit\"\n        >\n          Back to research\n        </a>\n      </span>\n    </button>\n  </div>\n</div>\n`;\n\nexports[`ResearchEditorOverview > renders correctly 1`] = `\n<div>\n  <div\n    class=\"css-nfzwtt\"\n  >\n    <h2\n      class=\"css-40p7h6\"\n    >\n      Research overview\n    </h2>\n    <ul\n      class=\"css-nyjgu9\"\n    >\n      <li\n        class=\"css-15o3y0m\"\n      >\n        <span\n          class=\"css-18k9290\"\n        >\n          <strong>\n            Update 1\n          </strong>\n        </span>\n      </li>\n      <li\n        class=\"css-15o3y0m\"\n      >\n        <span\n          class=\"css-18k9290\"\n        >\n          Update 2\n          <a\n            class=\"css-1cgzupl\"\n            data-discover=\"true\"\n            href=\"/research/abc/edit-update/2\"\n          >\n            Edit\n          </a>\n        </span>\n      </li>\n      <li\n        class=\"css-15o3y0m\"\n      >\n        <span\n          class=\"css-18k9290\"\n        >\n          Update 3\n          <a\n            class=\"css-1cgzupl\"\n            data-discover=\"true\"\n            href=\"/research/abc/edit-update/3\"\n          >\n            Edit\n          </a>\n        </span>\n      </li>\n    </ul>\n    <button\n      class=\"css-8611sj\"\n      data-cy=\"create-update\"\n      type=\"button\"\n    >\n      <span\n        class=\"css-lwcnm9\"\n      >\n        <a\n          class=\"css-1taq3td\"\n          data-discover=\"true\"\n          href=\"/research/abc/new-update\"\n        >\n          Create update\n        </a>\n      </span>\n    </button>\n    <button\n      class=\"css-m21utd\"\n      type=\"button\"\n    >\n      <span\n        class=\"css-lwcnm9\"\n      >\n        <a\n          class=\"css-1taq3td\"\n          data-discover=\"true\"\n          href=\"/research/abc/edit\"\n        >\n          Back to research\n        </a>\n      </span>\n    </button>\n  </div>\n</div>\n`;\n"
  },
  {
    "path": "packages/components/src/ReturnPathLink/ReturnPathLink.tsx",
    "content": "import type { RefAttributes } from 'react';\nimport type { LinkProps } from 'react-router';\nimport { Link, useLocation } from 'react-router';\n\ntype IProps = LinkProps & RefAttributes<HTMLAnchorElement>;\n\nexport const ReturnPathLink = (props: IProps) => {\n  const location = useLocation();\n  const to = `${props.to}?returnUrl=${encodeURIComponent(location?.pathname)}`;\n\n  return (\n    <Link {...props} to={to}>\n      {props.children}\n    </Link>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/SearchField/SearchField.stories.tsx",
    "content": "import { useState } from 'react';\n\nimport { SearchField } from './SearchField';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Forms/SearchField',\n  component: SearchField,\n} as Meta<typeof SearchField>;\n\nexport const Default: StoryFn<typeof SearchField> = () => {\n  const [searchValue, setSearchValue] = useState<string>('');\n\n  return (\n    <SearchField\n      dataCy=\"default-search-box\"\n      placeHolder=\"Default search\"\n      value={searchValue}\n      onChange={(value: string) => setSearchValue(value)}\n      onClear={() => setSearchValue('')}\n      onClickSearch={() => {}}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/SearchField/SearchField.tsx",
    "content": "import type { ThemeUIStyleObject } from 'theme-ui';\nimport { Box, Input } from 'theme-ui';\nimport { Icon } from '../Icon/Icon';\n\nexport type Props = {\n  autoComplete?: string;\n  autoFocus?: boolean;\n  name?: string;\n  id?: string;\n  placeHolder?: string;\n  dataCy: string;\n  value: string;\n  onChange: (value: string) => void;\n  onClear: () => void;\n  onClickSearch: () => void;\n  onBack?: () => void;\n  additionalStyle?: ThemeUIStyleObject;\n  isExpanded?: boolean;\n};\n\nexport const SearchField = (props: Props) => {\n  const {\n    autoComplete = 'on',\n    autoFocus,\n    isExpanded,\n    name = 'rand-name',\n    id = 'rand-id',\n    dataCy,\n    placeHolder,\n    value,\n    onChange,\n    onClear,\n    onClickSearch,\n    onBack,\n    additionalStyle = {},\n  } = props;\n\n  return (\n    <Box\n      sx={{\n        position: 'relative',\n        width: '100%',\n        display: 'flex',\n        alignItems: 'center',\n      }}\n    >\n      <Input\n        autoComplete={autoComplete}\n        autoFocus={autoFocus}\n        name={name}\n        id={id}\n        variant=\"inputOutline\"\n        type=\"search\"\n        data-cy={dataCy}\n        placeholder={placeHolder === undefined ? 'Search' : placeHolder}\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        sx={{\n          ...(isExpanded && { paddingLeft: 7 }),\n          ...(isExpanded && { height: '44px' }),\n          paddingRight: 11,\n          '::-webkit-search-cancel-button': {\n            display: 'none',\n          },\n          '::-ms-clear': {\n            display: 'none',\n          },\n          ...additionalStyle,\n        }}\n      />\n      <Box sx={{ left: 2, position: 'absolute', display: 'flex', alignItems: 'center' }}>\n        {isExpanded && onBack && (\n          <Icon\n            sx={{\n              display: 'flex',\n              alignItems: 'center',\n              marginRight: 1,\n            }}\n            glyph=\"arrow-back\"\n            onClick={onBack}\n            size=\"17\"\n          />\n        )}\n      </Box>\n      <Box\n        sx={{\n          right: 2,\n          position: 'absolute',\n          display: 'flex',\n          alignItems: 'center',\n        }}\n      >\n        {value && (\n          <Icon\n            sx={{\n              display: 'flex',\n              alignItems: 'center',\n              marginRight: 1,\n            }}\n            glyph=\"close\"\n            onClick={onClear}\n            size=\"17\"\n          />\n        )}\n        <Icon\n          sx={{\n            display: 'flex',\n            alignItems: 'center',\n          }}\n          glyph=\"search\"\n          onClick={onClickSearch}\n          size=\"19\"\n        />\n      </Box>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Select/DropdownIndicator.tsx",
    "content": "import type { DropdownIndicatorProps } from 'react-select';\nimport { components } from 'react-select';\nimport { Image } from 'theme-ui';\nimport IconArrowDown from '../../assets/icons/icon-arrow-down.svg';\n\n// https://github.com/JedWatson/react-select/issues/685#issuecomment-420213835\nexport const DropdownIndicator = (props: DropdownIndicatorProps) => {\n  return (\n    <components.DropdownIndicator {...props}>\n      <Image loading=\"lazy\" src={IconArrowDown} style={{ width: 12 }} />\n    </components.DropdownIndicator>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Select/Option.tsx",
    "content": "import type { OptionProps } from 'react-select';\nimport { components } from 'react-select';\nimport { Flex, Text } from 'theme-ui';\n\n// https://github.com/JedWatson/react-select/issues/685#issuecomment-420213835\nexport const Option = (props: OptionProps) => {\n  const option: any = props.data;\n  if (option.imageElement) {\n    return (\n      <components.Option {...props}>\n        <>\n          <Flex\n            sx={{ alignItems: 'center', justifyContent: 'space-between' }}\n            mt=\"5px\"\n            key={option.label}\n          >\n            <Flex sx={{ alignItems: 'center' }}>\n              {option.imageElement}\n              <Text sx={{ fontSize: 2 }} ml=\"10px\">\n                {option.label}\n                {option.number && ` (${option.number})`}\n              </Text>\n            </Flex>\n          </Flex>\n        </>\n      </components.Option>\n    );\n  }\n\n  return <components.Option {...props}>{props.label}</components.Option>;\n};\n"
  },
  {
    "path": "packages/components/src/Select/Select.stories.tsx",
    "content": "import { useState } from 'react';\n\nimport { Select } from './Select';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Forms/Select',\n  component: Select,\n} as Meta<typeof Select>;\n\nexport const Default: StoryFn<typeof Select> = () => {\n  return (\n    <Select\n      placeholder=\"A placeholder value\"\n      options={[\n        {\n          value: 'value-one',\n          label: 'Value 1',\n        },\n        {\n          value: 'value-two',\n          label: 'Value 2',\n        },\n      ]}\n    />\n  );\n};\n\nexport const Clearable: StoryFn<typeof Select> = () => {\n  const [value, setValue] = useState();\n  return (\n    <Select\n      value={value}\n      onChange={setValue}\n      placeholder=\"A placeholder value\"\n      isClearable={true}\n      options={[\n        {\n          value: 'value-one',\n          label: 'Value 1',\n        },\n        {\n          value: 'value-two',\n          label: 'Value 2',\n        },\n      ]}\n    />\n  );\n};\n\nexport const MultipleSelect: StoryFn<typeof Select> = () => {\n  const [value, setValue] = useState({\n    value: 'value-three',\n    label: 'Value 3',\n  });\n\n  return (\n    <Select\n      value={value}\n      onChange={setValue}\n      isMulti={true}\n      placeholder=\"A placeholder value\"\n      options={[\n        {\n          value: 'value-one',\n          label: 'Value 1',\n        },\n        {\n          value: 'value-two',\n          label: 'Value 2',\n        },\n        {\n          value: 'value-three',\n          label: 'Value 3',\n        },\n      ]}\n    />\n  );\n};\n\nexport const FormSelect: StoryFn<typeof Select> = () => {\n  const [value, setValue] = useState();\n  return (\n    <Select\n      value={value}\n      onChange={setValue}\n      isMulti={true}\n      placeholder=\"A placeholder value\"\n      options={[\n        {\n          value: 'value-one',\n          label: 'Value 1',\n        },\n        {\n          value: 'value-two',\n          label: 'Value 2',\n        },\n      ]}\n    />\n  );\n};\n\nexport const SelectWithIcons: StoryFn<typeof Select> = () => {\n  const [value, setValue] = useState();\n  return (\n    <Select\n      variant=\"icons\"\n      value={value}\n      onChange={setValue}\n      isMulti={true}\n      placeholder=\"A placeholder value\"\n      options={[\n        {\n          label: '',\n          options: [\n            {\n              imageElement: '',\n              value: 'verified',\n              label: 'Verified',\n            },\n          ],\n        },\n        {\n          label: 'All Workspaces',\n          options: [\n            {\n              imageElement: '',\n              value: 'verified',\n              label: 'Verified',\n            },\n          ],\n        },\n        {\n          label: 'Others',\n          options: [\n            {\n              imageElement: '',\n              value: 'verified',\n              label: 'Verified',\n            },\n          ],\n        },\n      ]}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Select/Select.tsx",
    "content": "import { useId } from 'react';\nimport type { OptionsOrGroups, Props as ReactSelectProps, StylesConfig } from 'react-select';\nimport ReactSelect from 'react-select';\nimport { useThemeUI } from 'theme-ui';\nimport { DropdownIndicator } from './DropdownIndicator';\nimport { Option } from './Option';\n\ntype IOption = {\n  label: string;\n  value: string;\n};\n\nexport interface Props extends ReactSelectProps {\n  options: OptionsOrGroups<any, any>;\n  value?: any;\n  onChange?: (arg: any) => void;\n  placeholder?: string;\n  isMulti?: boolean;\n  isClearable?: boolean;\n  getOptionLabel?: any;\n  getOptionValue?: any;\n  defaultValue?: IOption;\n  variant?: 'form' | 'formError' | 'icons' | 'tabs';\n  useAlternateBackground?: boolean; // this could be a specific color passed in but keeping it simple for now\n}\n\nexport const Select = (props: Props) => {\n  const { theme } = useThemeUI() as any;\n  const uuid = useId();\n\n  const SelectStyles: Partial<StylesConfig> = {\n    container: (provided) => ({\n      ...provided,\n      fontSize: theme.fontSizes[1] + 'px',\n      fontFamily: '\"Varela Round\", Arial, sans-serif',\n    }),\n    control: (provided) => ({\n      ...provided,\n      border: '2px solid ' + theme.colors.softblue,\n      backgroundColor: theme.colors.background,\n      minHeight: '40px',\n      cursor: 'pointer',\n      boxShadow: 'none',\n      ':focus': {\n        border: '2px solid ' + theme.colors.blue,\n        outline: 'none',\n      },\n      ':hover': {\n        border: '2px solid ' + theme.colors.blue,\n      },\n    }),\n\n    option: (provided, { data, isFocused, isDisabled }: any) => ({\n      ...provided,\n      backgroundColor: isFocused ? theme.colors.white : theme.colors.background,\n      boxShadow: 'none',\n      cursor: 'pointer',\n      color: !isDisabled ? data.color || theme.colors.black : theme.colors.lightgrey,\n    }),\n\n    menu: (provided) => ({\n      ...provided,\n      border: '2px solid ' + theme.colors.softblue,\n      boxShadow: 'none',\n      backgroundColor: theme.colors.background,\n      ':hover': {\n        border: '2px solid ' + theme.colors.softblue,\n      },\n    }),\n\n    multiValue: (provided, { data }: { data: any }) => ({\n      ...provided,\n      borderRadius: data.color ? 99 : 4,\n      backgroundColor: data.color ? `${data.color}20` : theme.colors.white,\n      padding: '2px',\n      border: '2px solid ',\n      borderColor: data.color || theme.colors.softgrey,\n      color: data.color || theme.colors.grey,\n    }),\n\n    multiValueLabel: (provided, { data }: { data: any }) => ({\n      ...provided,\n      color: data.color || theme.colors.grey,\n    }),\n\n    multiValueRemove: (provided, { data }: { data: any }) => ({\n      ...provided,\n      borderRadius: data.color ? 99 : 4,\n      color: data.color || theme.colors.grey,\n      ':hover': {\n        backgroundColor: data.color || theme.colors.grey,\n        color: 'white',\n      },\n    }),\n\n    indicatorSeparator: (provided) => ({\n      ...provided,\n      display: 'none',\n    }),\n\n    dropdownIndicator: (provided, state) => ({\n      ...provided,\n      ':hover': {\n        opacity: state.isFocused ? 1 : 0.5,\n      },\n      opacity: state.isFocused ? 1 : 0.3,\n    }),\n  };\n\n  const SelectStylesError: Partial<StylesConfig> = {\n    ...SelectStyles,\n    control: (provided) => ({\n      ...provided,\n      border: '2px solid ' + theme.colors.red,\n      ':focus': {\n        border: '2px solid ' + theme.colors.red,\n      },\n      ':hover': {\n        border: '2px solid ' + theme.colors.red,\n      },\n    }),\n    menu: (provided) => ({\n      ...provided,\n      border: '2px solid ' + theme.colors.red,\n      ':hover': {\n        border: '2px solid ' + theme.colors.red,\n      },\n    }),\n  };\n\n  const FilterStyles: Partial<StylesConfig> = {\n    container: (provided) => ({\n      ...provided,\n      fontSize: theme.fontSizes[2] + 'px',\n      fontFamily: '\"Varela Round\", Arial, sans-serif',\n      border: '2px solid ' + theme.colors.black,\n      borderRadius: '5px',\n      color: theme.colors.black,\n    }),\n    control: (provided) => ({\n      ...provided,\n      backgroundColor: props.useAlternateBackground ? theme.colors.softblue : theme.colors.white,\n      minHeight: '40px',\n      cursor: 'pointer',\n      boxShadow: 'none',\n      ':hover': {\n        border: '2px solid ' + theme.colors.blue,\n      },\n      ':focus': {\n        border: '2px solid ' + theme.colors.blue,\n      },\n    }),\n    placeholder: (provided) => ({\n      ...provided,\n      color: theme.colors.black,\n    }),\n    option: (provided, state) => ({\n      ...provided,\n      color: theme.colors.black,\n      backgroundColor: state.isFocused ? theme.colors.softblue : theme.colors.white,\n      cursor: 'pointer',\n      boxShadow: 'none',\n    }),\n\n    menu: (provided) => ({\n      ...provided,\n      border: '2px solid ' + theme.colors.black,\n      boxShadow: 'none',\n      backgroundColor: theme.colors.white,\n      zIndex: 3,\n      ':hover': {\n        border: '2px solid ' + theme.colors.black,\n      },\n    }),\n\n    multiValue: (provided) => ({\n      ...provided,\n      backgroundColor: theme.colors.softblue,\n      padding: '2px',\n      border: '2px solid ' + theme.colors.black,\n      color: theme.colors.grey,\n    }),\n\n    indicatorSeparator: (provided) => ({\n      ...provided,\n      display: 'none',\n    }),\n\n    valueContainer: (base) => ({\n      ...base,\n      flexWrap: 'nowrap',\n      overflow: 'auto',\n    }),\n  };\n\n  const options: OptionsOrGroups<any, any> | undefined = props.options || [];\n\n  const styleVariant = {\n    default: FilterStyles,\n    form: SelectStyles,\n    formError: SelectStylesError,\n    icons: FilterStyles,\n    tabs: FilterStyles,\n  };\n\n  return (\n    <ReactSelect\n      classNamePrefix=\"data-cy\"\n      instanceId={uuid}\n      components={{ DropdownIndicator, Option }}\n      defaultValue={props.defaultValue}\n      getOptionLabel={props.getOptionLabel && props.getOptionLabel}\n      getOptionValue={props.getOptionValue && props.getOptionValue}\n      isClearable={!!props.isClearable}\n      isMulti={!!props.isMulti}\n      placeholder={props.placeholder}\n      styles={styleVariant[props.variant || 'default']}\n      options={options}\n      onChange={(v) => props.onChange && props.onChange(v)}\n      value={props.value}\n      onInputChange={props.onInputChange}\n      isOptionDisabled={props.isOptionDisabled}\n      noOptionsMessage={props.noOptionsMessage}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/SiteFooter/SiteFooter.stories.tsx",
    "content": "import { SiteFooter } from './SiteFooter';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  /* 👇 The title prop is optional.\n   * See https://storybook.js.org/docs/react/configure/overview#configure-story-loading\n   * to learn how to generate automatic titles\n   */\n  title: 'Layout/SiteFooter',\n  component: SiteFooter,\n} as Meta<typeof SiteFooter>;\n\nexport const Default: StoryFn<typeof SiteFooter> = () => <SiteFooter siteName=\"Precious Plastic\" />;\n"
  },
  {
    "path": "packages/components/src/SiteFooter/SiteFooter.tsx",
    "content": "import styled from '@emotion/styled';\nimport { Flex, Text } from 'theme-ui';\n\nimport { ExternalLink } from '../ExternalLink/ExternalLink';\nimport { Icon } from '../Icon/Icon';\n\ntype SiteFooterProps = {\n  siteName: string;\n};\n\nconst discordButtonWidth = 310;\n\nconst Anchor = styled(ExternalLink)`\n  color: #fff;\n  text-decoration: underline;\n  white-space: nowrap;\n`;\n\nconst FooterContainer = styled(Flex)`\n  color: #fff;\n  display: flex;\n  flex-direction: column;\n  margin-top: 45px;\n  line-heigh: 1.5;\n  padding: 45px ${(props) => (props.theme as any).space[4]}px;\n  position: relative;\n  text-align: center;\n\n  @media only screen and (min-width: ${(props) => (props.theme as any).breakpoints[1]}) and (max-width: ${(\n    props,\n  ) => (props.theme as any).breakpoints[2]}) {\n    align-items: flex-start;\n    padding-top: 35px;\n    padding-bottom: 35px;\n    padding-left: 65px;\n    padding-right: ${discordButtonWidth}px;\n    text-align: left;\n  }\n\n  @media only screen and (min-width: ${(props) => (props.theme as any).breakpoints[2]}) {\n    flex-direction: row;\n    padding-right: ${discordButtonWidth}px;\n    text-align: left;\n  }\n`;\n\nconst OneArmyIcon = styled(Icon)`\n  @media only screen and (min-width: ${(props) => (props.theme as any).breakpoints[1]}) and (max-width: ${(\n    props,\n  ) => (props.theme as any).breakpoints[2]}) {\n    position: absolute;\n    top: 45px;\n    left: 30px;\n  }\n`;\n\nexport const SiteFooter = ({ siteName }: SiteFooterProps) => {\n  return (\n    <FooterContainer\n      bg=\"#27272c\"\n      sx={{ alignItems: 'center' }}\n      style={{\n        marginTop: '45px',\n      }}\n    >\n      <OneArmyIcon glyph=\"star-active\" mb={[3, 3, 0]} />\n      <Text ml={[0, 0, 0, 3]} mr={1}>\n        {siteName} is a project by <Anchor href=\"https://onearmy.earth/\">One Army</Anchor>.\n      </Text>\n\n      <Text mt={[2, 2, 0]}>\n        Please <Anchor href=\"https://www.patreon.com/one_army\">sponsor the work</Anchor> or{' '}\n        <Anchor href=\"https://platform.onearmy.earth/\">help us build the software</Anchor>.\n      </Text>\n    </FooterContainer>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/TabbedContent/TabbedContent.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\nimport { Box } from 'theme-ui';\n\nimport { Tab, TabPanel, Tabs, TabsList } from './TabbedContent';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/TabbedContent',\n} as Meta;\n\nexport const Default: StoryFn = () => {\n  return (\n    <Box\n      sx={{\n        background: 'white',\n        maxWidth: '1000px',\n        margin: '0 auto',\n        padding: 6,\n      }}\n    >\n      <Tabs defaultValue={0}>\n        <TabsList>\n          <Tab>Tab #1</Tab>\n          <Tab>Tab #2</Tab>\n          <Tab>Tab #3</Tab>\n          <Tab>Tab #4</Tab>\n          <Tab>Tab #5</Tab>\n        </TabsList>\n\n        <TabPanel>\n          <p>Tab Panel #1</p>\n          <p>{faker.lorem.paragraphs(3)}</p>\n        </TabPanel>\n        <TabPanel>\n          <p>Tab Panel #2</p>\n          <p>{faker.lorem.paragraphs(3)}</p>\n        </TabPanel>\n        <TabPanel>\n          <p>Tab Panel #3</p>\n          <p>{faker.lorem.paragraphs(3)}</p>\n        </TabPanel>\n        <TabPanel>\n          <p>Tab Panel #4</p>\n          <p>{faker.lorem.paragraphs(3)}</p>\n        </TabPanel>\n        <TabPanel>\n          <p>Tab Panel #5</p>\n          <p>{faker.lorem.paragraphs(3)}</p>\n        </TabPanel>\n      </Tabs>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/TabbedContent/TabbedContent.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { act } from '@testing-library/react';\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { Default } from './TabbedContent.stories';\n\nimport type { JSX } from 'react';\n\nconst DefaultComponent = Default as unknown as () => JSX.Element;\n\ndescribe('TabbedContent', () => {\n  it('basic interaction', () => {\n    const wrapper = render(<DefaultComponent />);\n\n    expect(wrapper.getByText('Tab #1')).toBeVisible();\n\n    expect(() => wrapper.getByText('Tab Panel #2')).toThrow();\n  });\n\n  it('switches between tabs', () => {\n    const wrapper = render(<DefaultComponent />);\n\n    act(() => {\n      wrapper.getByText('Tab #2').click();\n    });\n\n    expect(wrapper.getByText('Tab #1')).toBeVisible();\n\n    expect(() => wrapper.getByText('Tab Panel #1')).toThrow();\n    expect(wrapper.getByText('Tab Panel #2')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/TabbedContent/TabbedContent.tsx",
    "content": "import styled from '@emotion/styled';\nimport { Tab as MuiTab } from '@mui/base/Tab';\nimport { TabPanel as MuiTabPanel } from '@mui/base/TabPanel';\nimport { Tabs } from '@mui/base/Tabs';\nimport { TabsList } from '@mui/base/TabsList';\n\nexport const Tab = styled(MuiTab)`\n  background-color: transparent;\n  border: none;\n  border-top-left-radius: ${(p) => p.theme.space[2]}px;\n  border-top-right-radius: ${(p) => p.theme.space[2]}px;\n  box-shadow: 0 2px 0px 0px transparent;\n  cursor: pointer;\n  font-family: ${(p) => p.theme.text?.heading.fontFamily};\n  font-size: ${(p) => p.theme.fontSizes?.[2]}px;\n  padding: ${(p) => p.theme.space[2]}px ${(p) => p.theme.space[3]}px;\n  margin-right: ${(p) => p.theme.space[3]}px;\n  color: ${(p) => p.theme.colors?.grey};\n\n  &:hover {\n    text-decoration: underline;\n  }\n\n  @media (min-width: 52em) {\n    &:first-of-type {\n      margin-left: 0;\n      position: relative;\n    }\n  }\n\n  @media (max-width: 52em) {\n    border: 1px solid ${(p) => p.theme.colors?.grey};\n    border-radius: ${(p) => p.theme.space[2]}px;\n    margin-bottom: ${(p) => p.theme.space[2]}px;\n  }\n\n  &.base--selected {\n    background-color: ${(p) => p.theme.colors.background};\n    text-decoration: none;\n    border: none;\n    color: ${(p) => p.theme.colors?.black};\n\n    &:after {\n      content: '';\n      display: block;\n      position: absolute;\n      left: 0;\n      top: 100%;\n      height: ${(p) => p.theme.space[3]}px;\n      width: ${(p) => p.theme.space[3]}px;\n      background-color: ${(p) => p.theme.colors.background};\n    }\n  }\n`;\n\nexport const TabPanel = styled(MuiTabPanel)`\n  background-color: ${(p) => p.theme.colors.background};\n  border-radius: ${(p) => p.theme.space[2]}px;\n  padding: ${(p) => p.theme.space[3]}px;\n`;\n\nexport { Tabs, TabsList };\n"
  },
  {
    "path": "packages/components/src/Tag/Tag.stories.tsx",
    "content": "import { Tag } from './Tag';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/Tag',\n  component: Tag,\n} as Meta<typeof Tag>;\n\nexport const Default: StoryFn<typeof Tag> = () => (\n  <Tag\n    tag={{\n      label: 'Label',\n    }}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/Tag/Tag.tsx",
    "content": "import type { ThemeUIStyleObject } from 'theme-ui';\nimport { Text } from 'theme-ui';\n\nexport interface ITag {\n  label: string;\n}\n\nexport interface Props {\n  tag: ITag;\n  sx?: ThemeUIStyleObject | undefined;\n}\n\nexport const Tag = ({ tag, sx }: Props) => {\n  if (!tag || !tag.label) return null;\n\n  return (\n    <Text\n      sx={{\n        fontSize: 1,\n        color: 'blue',\n        ...sx,\n        '::before': {\n          content: '\"#\"',\n        },\n      }}\n    >\n      {tag.label}\n    </Text>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/TagList/TagList.stories.tsx",
    "content": "import { TagList } from './TagList';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/TagList',\n  component: TagList,\n} as Meta<typeof TagList>;\n\nexport const Default: StoryFn<typeof TagList> = () => (\n  <TagList\n    tags={[\n      {\n        label: 'Tag 1',\n      },\n      {\n        label: 'Tag 2',\n      },\n    ]}\n  />\n);\n"
  },
  {
    "path": "packages/components/src/TagList/TagList.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { TagList } from './TagList';\n\ndescribe('TagList', () => {\n  it('renders multiple tags', () => {\n    const tagList = [{ label: 'The best tag' }, { label: 'The second best tag' }];\n\n    const { getByText } = render(<TagList tags={tagList} />);\n\n    expect(getByText(tagList[0].label)).toBeInTheDocument();\n    expect(getByText(tagList[1].label)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/TagList/TagList.tsx",
    "content": "import { Flex } from 'theme-ui';\nimport type { ITag } from '../Tag/Tag';\nimport { Tag } from '../Tag/Tag';\n\nexport interface TagListProps {}\n\nexport interface IProps {\n  tags: ITag[];\n}\n\nexport const TagList = ({ tags }: IProps) => {\n  return (\n    <Flex sx={{ gap: 1 }} data-cy=\"tag-list\">\n      {tags\n        .filter((tag) => !!tag)\n        .map((tag) => (\n          <Tag key={tag.label} tag={tag} />\n        ))}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Text/Text.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\nimport { Text } from 'theme-ui';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/Text',\n  component: Text,\n} as Meta<typeof Text>;\n\nexport const Default: StoryFn<typeof Text> = () => <Text>{faker.lorem.paragraphs(3)}</Text>;\n\nexport const Quiet: StoryFn<typeof Text> = () => (\n  <Text variant=\"quiet\">{faker.lorem.paragraphs(3)}</Text>\n);\n"
  },
  {
    "path": "packages/components/src/TextNotification/TextNotification.stories.tsx",
    "content": "import { useState } from 'react';\n\nimport { TextNotification } from './TextNotification';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/TextNotification',\n  component: TextNotification,\n} as Meta<typeof TextNotification>;\n\nexport const Success: StoryFn<typeof TextNotification> = () => (\n  <TextNotification variant=\"success\" isVisible={true}>\n    A short snappy notification\n  </TextNotification>\n);\n\nexport const SuccessDismissable: StoryFn<typeof TextNotification> = () => {\n  const [visible, setVisibility] = useState(true);\n  return (\n    <TextNotification variant=\"success\" isVisible={visible} onDismiss={setVisibility}>\n      A short snappy notification\n    </TextNotification>\n  );\n};\n\nexport const Error: StoryFn<typeof TextNotification> = () => (\n  <TextNotification variant=\"failure\" isVisible={true}>\n    A short snappy notification\n  </TextNotification>\n);\n"
  },
  {
    "path": "packages/components/src/TextNotification/TextNotification.tsx",
    "content": "import { keyframes } from '@emotion/react';\nimport { Alert, Close } from 'theme-ui';\n\nconst fadeIn = keyframes({\n  from: { opacity: 0, transform: 'translateY(-50%)' },\n  to: { opacity: 1 },\n});\n\nexport interface TextNotificationProps {\n  children: any;\n  variant: 'success' | 'failure';\n  isVisible: boolean;\n  onDismiss?: any | null;\n}\n\nexport const TextNotification = (props: TextNotificationProps) => {\n  if (!props.isVisible) {\n    return null;\n  }\n\n  return (\n    <Alert\n      variant={props.variant}\n      data-cy={`TextNotification: ${props.variant}`}\n      sx={{\n        width: '100%',\n        animation: `${fadeIn} ease-out 400ms both 200ms`,\n      }}\n    >\n      {props.children}\n      {props.onDismiss && (\n        <Close\n          sx={{\n            position: 'absolute',\n            top: '50%',\n            right: 2,\n            transform: 'translateY(-50%)',\n            cursor: 'pointer',\n          }}\n          onClick={() => props.onDismiss(false)}\n        />\n      )}\n    </Alert>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Textarea/Textarea.stories.tsx",
    "content": "import { Textarea } from 'theme-ui';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Forms/Textarea',\n  component: Textarea,\n} as Meta<typeof Textarea>;\n\nexport const Default: StoryFn<typeof Textarea> = () => (\n  <Textarea placeholder=\"A short placeholder\" />\n);\n"
  },
  {
    "path": "packages/components/src/Tooltip/Tooltip.stories.tsx",
    "content": "import { Button } from 'theme-ui';\n\nimport { Tooltip } from './Tooltip';\n\n// eslint-disable-next-line storybook/no-renderer-packages\nimport type { Meta, StoryFn } from '@storybook/react';\n\nexport default {\n  title: 'Components/Tooltip',\n  component: Tooltip,\n} as Meta<typeof Tooltip>;\n\nexport const Hover: StoryFn<typeof Tooltip> = () => (\n  <>\n    <Button data-tooltip-id=\"tooltip\" data-tooltip-content=\"This is a tooltip\">\n      Hover over me\n    </Button>\n    <Tooltip id=\"tooltip\" />\n  </>\n);\n"
  },
  {
    "path": "packages/components/src/Tooltip/Tooltip.tsx",
    "content": "import styled from '@emotion/styled';\nimport { Tooltip as ReactTooltip } from 'react-tooltip';\n\nconst StyledTooltip = styled(ReactTooltip)`\n  z-index: 9999 !important;\n  text-align: center;\n  border-radius: 5px !important;\n  padding: 5px 10px !important;\n`;\n\ntype TooltipProps = {\n  id: string;\n  children?: React.ReactNode;\n};\n\nexport const Tooltip = ({ children, id }: TooltipProps) => {\n  return (\n    <StyledTooltip\n      id={id}\n      openEvents={{ mouseenter: true, focus: true }}\n      closeEvents={{ mouseleave: true, blur: true }}\n    >\n      {children}\n    </StyledTooltip>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/UsefulStatsButton/UsefulButtonLite.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { BrowserRouter } from 'react-router';\nimport { fireEvent, render, screen, waitFor } from '@testing-library/react';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { UsefulButtonLite } from './UsefulButtonLite';\n\nimport type { UsefulConfig } from './UsefulButtonLite';\n\nconst mockTheme = {\n  colors: {\n    background: '#f4f6f7',\n    silver: '#c0c0c0',\n    softblue: '#e6f3ff',\n  },\n};\n\nconst mockNavigate = vi.fn();\nvi.mock('react-router', async (importOriginal) => {\n  const actual = await importOriginal();\n  const actualObj = typeof actual === 'object' && actual !== null ? actual : {};\n  return {\n    ...actualObj,\n    useNavigate: () => mockNavigate,\n  };\n});\n\nvi.mock('theme-ui', async () => {\n  const actual = await import('theme-ui');\n  const actualObj = typeof actual === 'object' && actual !== null ? actual : {};\n  return {\n    ...actualObj,\n    useThemeUI: () => ({ theme: mockTheme }),\n    Flex: ({ children, sx, ...props }: any) => (\n      <div style={sx} {...props}>\n        {children}\n      </div>\n    ),\n    Box: ({ children, sx, ...props }: any) => (\n      <div style={sx} {...props}>\n        {children}\n      </div>\n    ),\n    Text: ({ children, sx, ...props }: any) => (\n      <span style={sx} {...props}>\n        {children}\n      </span>\n    ),\n  };\n});\n\nvi.mock('../Icon/Icon', async () => {\n  const actual = await import('theme-ui');\n  const actualObj = typeof actual === 'object' && actual !== null ? actual : {};\n  return {\n    ...actualObj,\n    Icon: ({ filter, sx, ...props }: any) => (\n      <img\n        {...props}\n        style={{\n          filter,\n          ...sx,\n        }}\n      />\n    ),\n  };\n});\n\nconst TestWrapper = ({ children }: { children: React.ReactNode }) => <BrowserRouter>{children}</BrowserRouter>;\n\ndescribe('UsefulButtonLite', () => {\n  const mockOnUsefulClick = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockOnUsefulClick.mockResolvedValue(undefined);\n    vi.spyOn(Math, 'random').mockReturnValue(0.5);\n  });\n\n  const defaultProps: UsefulConfig = {\n    hasUserVotedUseful: false,\n    votedUsefulCount: 5,\n    isLoggedIn: true,\n    onUsefulClick: mockOnUsefulClick,\n  };\n\n  it('does not show count when votedUsefulCount is 0', () => {\n    const props: UsefulConfig = {\n      ...defaultProps,\n      votedUsefulCount: 0,\n    };\n\n    render(\n      <TestWrapper>\n        <UsefulButtonLite {...props} />\n      </TestWrapper>,\n    );\n\n    expect(screen.queryByText('0')).not.toBeInTheDocument();\n    expect(screen.getByRole('button')).toBeInTheDocument();\n  });\n\n  it('shows the icon as gray when hasUserVotedUseful is false', () => {\n    const props: UsefulConfig = {\n      ...defaultProps,\n      hasUserVotedUseful: false,\n    };\n\n    render(\n      <TestWrapper>\n        <UsefulButtonLite {...props} />\n      </TestWrapper>,\n    );\n\n    const icon = screen.getByRole('img', { hidden: true });\n    expect(icon).toHaveStyle('filter: grayscale(1)');\n  });\n\n  it('shows the icon as coloured when hasUserVotedUseful is true', () => {\n    const props: UsefulConfig = {\n      ...defaultProps,\n      hasUserVotedUseful: true,\n    };\n\n    render(\n      <TestWrapper>\n        <UsefulButtonLite {...props} />\n      </TestWrapper>,\n    );\n\n    const icon = screen.getByRole('img', { hidden: true });\n    expect(icon).not.toHaveStyle('filter: grayscale(1)');\n  });\n\n  it('increases votedUsefulCount by 1 when user clicks and has not already voted', async () => {\n    const props: UsefulConfig = {\n      ...defaultProps,\n      hasUserVotedUseful: false,\n      votedUsefulCount: 3,\n    };\n\n    render(\n      <TestWrapper>\n        <UsefulButtonLite {...props} />\n      </TestWrapper>,\n    );\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    await waitFor(() => {\n      expect(mockOnUsefulClick).toHaveBeenCalledWith('add', 'Comment');\n      expect(mockOnUsefulClick).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  it('decreases votedUsefulCount by 1 when user clicks and has already voted', async () => {\n    const props: UsefulConfig = {\n      ...defaultProps,\n      hasUserVotedUseful: true,\n      votedUsefulCount: 5,\n    };\n\n    render(\n      <TestWrapper>\n        <UsefulButtonLite {...props} />\n      </TestWrapper>,\n    );\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    await waitFor(() => {\n      expect(mockOnUsefulClick).toHaveBeenCalledWith('delete', 'Comment');\n      expect(mockOnUsefulClick).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  it('disables button during click handling', async () => {\n    // Make the onUsefulClick return a promise that we can control\n    const slowPromise = new Promise((resolve) => setTimeout(resolve, 100));\n    mockOnUsefulClick.mockReturnValue(slowPromise);\n\n    render(\n      <TestWrapper>\n        <UsefulButtonLite {...defaultProps} />\n      </TestWrapper>,\n    );\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    expect(button).toBeDisabled();\n\n    await waitFor(() => {\n      expect(button).not.toBeDisabled();\n    });\n  });\n\n  it('redirects to sign-in when user is not logged in', () => {\n    const props: UsefulConfig = {\n      ...defaultProps,\n      isLoggedIn: false,\n    };\n\n    Object.defineProperty(window, 'location', {\n      value: {\n        pathname: '/test-path',\n      },\n      writable: true,\n    });\n\n    render(\n      <TestWrapper>\n        <UsefulButtonLite {...props} />\n      </TestWrapper>,\n    );\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    expect(mockNavigate).toHaveBeenCalledWith('/sign-in?returnUrl=%2Ftest-path');\n    expect(mockOnUsefulClick).not.toHaveBeenCalled();\n  });\n\n  it('handles errors gracefully during onUsefulClick', async () => {\n    mockOnUsefulClick.mockRejectedValue(new Error('Network error'));\n\n    render(\n      <TestWrapper>\n        <UsefulButtonLite {...defaultProps} />\n      </TestWrapper>,\n    );\n\n    const button = screen.getByRole('button');\n    fireEvent.click(button);\n\n    await waitFor(() => {\n      expect(mockOnUsefulClick).toHaveBeenCalled();\n      expect(button).not.toBeDisabled();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/components/src/UsefulStatsButton/UsefulButtonLite.tsx",
    "content": "import { useId, useState } from 'react';\nimport { useNavigate } from 'react-router';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Flex, Text, useThemeUI } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { Icon } from '../Icon/Icon';\n\nexport interface UsefulConfig {\n  hasUserVotedUseful: boolean;\n  votedUsefulCount: number;\n  isLoggedIn: boolean;\n  onUsefulClick: (vote: 'add' | 'delete', eventCategory?: string) => Promise<void>;\n  sx?: ThemeUIStyleObject;\n}\n\nexport const UsefulButtonLite = ({\n  hasUserVotedUseful,\n  votedUsefulCount,\n  isLoggedIn,\n  sx,\n  onUsefulClick,\n}: UsefulConfig) => {\n  const { theme } = useThemeUI() as any;\n  const navigate = useNavigate();\n  const uuid = useId();\n  const [disabled, setDisabled] = useState<boolean>();\n  const usefulAction = hasUserVotedUseful ? 'delete' : 'add';\n  const handleUsefulClick = async () => {\n    setDisabled(true);\n    try {\n      await onUsefulClick(usefulAction, 'Comment');\n    } catch (_) {\n      // handle error or ignore\n    }\n    setDisabled(false);\n  };\n\n  const backgroundColor =\n    !votedUsefulCount || votedUsefulCount === 0 ? 'transparent' : theme.colors.background;\n\n  return (\n    <Flex sx={{ alignSelf: 'flex-end', position: 'relative', alignItems: 'center' }}>\n      {votedUsefulCount > 0 && (\n        <Flex\n          sx={{\n            position: 'absolute',\n            left: -6,\n            backgroundColor,\n            lineHeight: '1rem',\n            minWidth: '2.75rem',\n            width: 'fit-content',\n            height: '1.5rem',\n            borderTopLeftRadius: 1,\n            borderBottomLeftRadius: 1,\n            justifyContent: 'flex-start',\n            alignItems: 'center',\n            padding: '4px 4px 4px 8px',\n            fontSize: '14px',\n            zIndex: 1,\n          }}\n        >\n          <Text>{votedUsefulCount ?? 0}</Text>\n        </Flex>\n      )}\n      <Button\n        type=\"button\"\n        data-tooltip-id={uuid}\n        data-tooltip-content={isLoggedIn ? '' : 'Login to add your vote'}\n        data-cy={isLoggedIn ? 'vote-useful' : 'vote-useful-redirect'}\n        title=\"Mark as useful\"\n        onClick={() =>\n          isLoggedIn\n            ? handleUsefulClick()\n            : navigate('/sign-in?returnUrl=' + encodeURIComponent(location.pathname))\n        }\n        disabled={disabled}\n        variant=\"outline\"\n        sx={{\n          position: 'relative',\n          fontSize: 1,\n          border: 'none',\n          padding: 1,\n          paddingRight: 8,\n          paddingLeft: 8,\n          display: 'flex',\n          alignItems: 'center',\n          width: '2rem',\n          height: '2rem',\n          gap: 1,\n          zIndex: 2,\n          ...sx,\n        }}\n      >\n        <Flex sx={{ alignItems: 'center' }}>\n          <Icon\n            glyph=\"star-active\"\n            size={24}\n            filter={hasUserVotedUseful ? 'unset' : 'grayscale(1)'}\n          />\n        </Flex>\n      </Button>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/UsefulStatsButton/UsefulStatsButton.stories.tsx",
    "content": "import { useState } from 'react';\n\nimport { UsefulStatsButton } from './UsefulStatsButton';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/UsefulStatsButton',\n  component: UsefulStatsButton,\n} as Meta<typeof UsefulStatsButton>;\n\nexport const LoggedOutWithCount: StoryFn<typeof UsefulStatsButton> = () => (\n  <UsefulStatsButton\n    isLoggedIn={false}\n    hasUserVotedUseful={false}\n    onUsefulClick={() => Promise.resolve()}\n  />\n);\n\nexport const LoggedInWithCount: StoryFn<typeof UsefulStatsButton> = () => {\n  const [voted, setVoted] = useState(false);\n\n  const clickVote = async () => {\n    await new Promise<void>((resolve) => setTimeout(() => resolve(), 2000));\n    setVoted((val) => !val);\n  };\n\n  return (\n    <UsefulStatsButton hasUserVotedUseful={voted} isLoggedIn={true} onUsefulClick={clickVote} />\n  );\n};\n\nexport const CurrentUserHasVoted: StoryFn<typeof UsefulStatsButton> = () => {\n  const [voted, setVoted] = useState(true);\n\n  const clickVote = async () => {\n    await new Promise<void>((resolve) => setTimeout(() => resolve(), 2000));\n    setVoted((val) => !val);\n  };\n\n  return (\n    <UsefulStatsButton hasUserVotedUseful={voted} isLoggedIn={true} onUsefulClick={clickVote} />\n  );\n};\n"
  },
  {
    "path": "packages/components/src/UsefulStatsButton/UsefulStatsButton.tsx",
    "content": "import { useId, useState } from 'react';\nimport { useNavigate } from 'react-router';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Text, useThemeUI } from 'theme-ui';\nimport { Button } from '../Button/Button';\nimport { Tooltip } from '../Tooltip/Tooltip';\n\nexport interface IProps {\n  hasUserVotedUseful: boolean;\n  isLoggedIn: boolean;\n  onUsefulClick: () => Promise<void>;\n  sx?: ThemeUIStyleObject;\n}\n\nexport const UsefulStatsButton = (props: IProps) => {\n  const { theme } = useThemeUI() as any;\n  const navigate = useNavigate();\n  const uuid = useId();\n\n  const [disabled, setDisabled] = useState<boolean>();\n\n  const handleUsefulClick = async () => {\n    setDisabled(true);\n    try {\n      await props.onUsefulClick();\n    } catch (_) {\n      // do nothing\n    }\n    setDisabled(false);\n  };\n\n  return (\n    <>\n      <Button\n        type=\"button\"\n        data-tooltip-id={uuid}\n        data-tooltip-content={props.isLoggedIn ? '' : 'Login to add your vote'}\n        data-cy={props.isLoggedIn ? 'vote-useful' : 'vote-useful-redirect'}\n        onClick={() =>\n          props.isLoggedIn\n            ? handleUsefulClick()\n            : navigate('/sign-in?returnUrl=' + encodeURIComponent(location.pathname))\n        }\n        disabled={disabled}\n        sx={{\n          fontSize: 2,\n          backgroundColor: theme.colors.white,\n          py: 0,\n          '&:hover': {\n            backgroundColor: theme.colors.softblue,\n          },\n          ...props.sx,\n        }}\n        icon={'star-active'}\n        iconFilter={props.hasUserVotedUseful ? 'unset' : 'grayscale(1)'}\n      >\n        <Text\n          py={2}\n          sx={{\n            display: 'inline-block',\n          }}\n        >\n          {props.hasUserVotedUseful ? 'Marked as useful' : 'Mark as useful'}\n        </Text>\n      </Button>\n      <Tooltip id={uuid} />\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/UserEngagementWrapper/UserEngagementWrapper.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\nimport { Box, Button } from 'theme-ui';\n\nimport { ArticleCallToActionSupabase, UsefulStatsButton } from '..';\nimport { UserEngagementWrapper } from './UserEngagementWrapper';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Layout/UserEngagementWrapper',\n  component: UserEngagementWrapper,\n} as Meta<typeof UserEngagementWrapper>;\n\nexport const Default: StoryFn<typeof UserEngagementWrapper> = () => (\n  <Box sx={{ maxWidth: '1000px', margin: '0 auto' }}>\n    <UserEngagementWrapper>\n      <Box sx={{ margin: 3 }}>\n        <ArticleCallToActionSupabase\n          author={{\n            username: 'library._createdBy',\n            country: 'US',\n            displayName: 'display name',\n            badges: [\n              {\n                id: 1,\n                name: 'pro',\n                displayName: 'PRO',\n                imageUrl: faker.image.avatar(),\n              },\n              {\n                id: 2,\n                name: 'supporter',\n                displayName: 'Supporter',\n                actionUrl: faker.internet.url(),\n                imageUrl: faker.image.avatar(),\n              },\n            ],\n            id: 1,\n            photo: null,\n          }}\n        >\n          <Button sx={{ fontSize: 2 }} onClick={() => null}>\n            Leave a comment\n          </Button>\n          <UsefulStatsButton\n            hasUserVotedUseful={false}\n            isLoggedIn={false}\n            onUsefulClick={() => new Promise(() => {})}\n          />\n        </ArticleCallToActionSupabase>\n      </Box>\n    </UserEngagementWrapper>\n  </Box>\n);\n"
  },
  {
    "path": "packages/components/src/UserEngagementWrapper/UserEngagementWrapper.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { Default } from './UserEngagementWrapper.stories';\n\nimport type { JSX } from 'react';\n\ndescribe('UserEngagementWrapper', () => {\n  it('renders the children', () => {\n    const DefaultComponent = Default as unknown as () => JSX.Element;\n    const { getByText } = render(<DefaultComponent />);\n\n    expect(getByText('Mark as useful')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/UserEngagementWrapper/UserEngagementWrapper.tsx",
    "content": "import { Flex } from 'theme-ui';\n\nimport whiteBubble from '../../assets/images/white-bubble_1.svg';\n\nexport interface Props {\n  children: React.ReactNode;\n}\n\nexport const UserEngagementWrapper = ({ children }: Props) => {\n  return (\n    <Flex\n      sx={{\n        backgroundImage: `url(\"${whiteBubble}\")`,\n        backgroundPosition: 'top center',\n        backgroundRepeat: 'no-repeat',\n        backgroundSize: ['150% auto', '125% auto', '80% auto'],\n        flexDirection: 'column',\n        marginTop: [1, 2, 4],\n        paddingBottom: [1, 1, 8],\n        paddingTop: [4, 5, 6],\n      }}\n    >\n      {children}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/UserStatistics/UserStatistics.stories.tsx",
    "content": "import { UserStatistics } from './UserStatistics';\nimport type { Meta, StoryObj } from '@storybook/react-vite';\n\nconst meta: Meta<typeof UserStatistics> = {\n  title: 'Layout/UserStatistics',\n  component: UserStatistics,\n};\nexport default meta;\n\ntype Story = StoryObj<typeof UserStatistics>;\n\nexport const Default: Story = {\n  args: {\n    profile: {\n      country: 'Greenland',\n      id: 1,\n      badges: [\n        {\n          id: 1,\n          displayName: 'PRO',\n          name: 'pro',\n          imageUrl: '',\n        },\n        {\n          id: 2,\n          displayName: 'Supporter',\n          name: 'supporter',\n          actionUrl: 'https://www.patreon.com/one_army',\n          imageUrl: '',\n        },\n      ],\n      totalViews: 23,\n      username: 'Test User',\n    },\n    pin: {\n      country: 'Greenland',\n    },\n    libraryCount: 10,\n    usefulCount: 20,\n    researchCount: 2,\n  },\n};"
  },
  {
    "path": "packages/components/src/UserStatistics/UserStatistics.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { UserStatistics } from './UserStatistics';\n\nimport type { UserStatisticsProps } from './UserStatistics';\n\ndescribe('UserStatistics', () => {\n  const defaultProps: UserStatisticsProps = {\n    profile: {\n      id: 1,\n      username: 'Test User',\n      badges: [],\n      totalViews: 100,\n      country: 'Greenland',\n    },\n    pin: { country: 'Greenland' },\n    libraryCount: 10,\n    usefulCount: 20,\n    researchCount: 2,\n    questionCount: 5,\n    showViews: true,\n  };\n\n  it('renders correctly', () => {\n    const { container } = render(<UserStatistics {...defaultProps} />);\n\n    expect(container).toBeTruthy();\n  });\n\n  it('renders location link when country and userName are provided', () => {\n    const { getByTestId } = render(<UserStatistics {...defaultProps} />);\n    const locationLink = getByTestId('location-link');\n\n    expect(locationLink).toHaveTextContent('Greenland');\n  });\n\n  it('renders project link when on library stats', () => {\n    const { getByTestId } = render(<UserStatistics {...defaultProps} />);\n    const libraryLink = getByTestId('library-link');\n\n    expect(libraryLink.getAttribute('href')).toBe('/library?q=Test User');\n  });\n\n  it('renders research link when on research stats', () => {\n    const { getByTestId } = render(<UserStatistics {...defaultProps} />);\n    const researchLink = getByTestId('research-link');\n\n    expect(researchLink.getAttribute('href')).toBe('/research?q=Test User');\n  });\n\n  it('renders useful count when usefulCount is provided', () => {\n    const { getByTestId } = render(<UserStatistics {...defaultProps} />);\n    const usefulCount = getByTestId('useful-stat');\n\n    expect(usefulCount).toHaveTextContent('Useful: 20');\n  });\n\n  it('renders library count when libraryCount is provided', () => {\n    const { getByTestId } = render(<UserStatistics {...defaultProps} />);\n    const libraryCount = getByTestId('library-stat');\n\n    expect(libraryCount).toHaveTextContent(/^Library: 10$/);\n  });\n\n  it('renders research count when researchCount is provided', () => {\n    const { getByTestId } = render(<UserStatistics {...defaultProps} />);\n    const researchCount = getByTestId('research-stat');\n\n    expect(researchCount).toHaveTextContent(/^Research: 2$/);\n  });\n\n  it('renders questions link when questionCount is provided', () => {\n    const { getByTestId } = render(<UserStatistics {...defaultProps} />);\n    const questionsLink = getByTestId('questions-link');\n\n    expect(questionsLink.getAttribute('href')).toBe('/questions');\n  });\n\n  it('renders questions count when questionCount is provided', () => {\n    const { getByTestId } = render(<UserStatistics {...defaultProps} />);\n    const questionsCount = getByTestId('questions-stat');\n\n    expect(questionsCount).toHaveTextContent(/^Questions: 5$/);\n  });\n\n  it('renders profile views when showViews is true', () => {\n    const { getByTestId } = render(<UserStatistics {...defaultProps} />);\n    const viewsStat = getByTestId('profile-views-stat');\n\n    expect(viewsStat).toHaveTextContent('Views: 100');\n  });\n\n  it('does not render profile views when showViews is false', () => {\n    const { queryByTestId } = render(<UserStatistics {...defaultProps} showViews={false} />);\n    const viewsStat = queryByTestId('profile-views-stat');\n\n    expect(viewsStat).not.toBeInTheDocument();\n  });\n\n  it('renders badges when provided', () => {\n    const propsWithBadges: UserStatisticsProps = {\n      ...defaultProps,\n      profile: {\n        ...defaultProps.profile,\n        badges: [\n          {\n            id: 1,\n            name: 'supporter',\n            displayName: 'Supporter',\n            imageUrl: '/badge.png',\n          },\n        ],\n      },\n    };\n    const { getByTestId } = render(<UserStatistics {...propsWithBadges} />);\n    const badge = getByTestId('badge_supporter');\n\n    expect(badge).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/UserStatistics/UserStatistics.tsx",
    "content": "import type { MapPin, Profile } from 'oa-shared';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Box, Card, Flex, Image, Text } from 'theme-ui';\nimport ForumIcon from '../../assets/icons/icon-forum.svg';\nimport HowToCountIcon from '../../assets/icons/icon-library.svg';\nimport ResearchIcon from '../../assets/icons/icon-research.svg';\nimport starActiveSVG from '../../assets/icons/icon-star-active.svg';\nimport { ElWithBeforeIcon } from '../ElWithBeforeIcon/ElWithBeforeIcon';\nimport { ExternalLink } from '../ExternalLink/ExternalLink';\nimport { Icon } from '../Icon/Icon';\nimport { InternalLink } from '../InternalLink/InternalLink';\n\nexport interface UserStatisticsProps {\n  profile: Pick<Profile, 'id' | 'username' | 'badges' | 'totalViews' | 'country'>;\n  pin?: Pick<MapPin, 'country'>;\n  libraryCount: number;\n  usefulCount: number;\n  researchCount: number;\n  questionCount: number;\n  showViews: boolean;\n  sx?: ThemeUIStyleObject | undefined;\n}\n\nexport const UserStatistics = (props: UserStatisticsProps) => {\n  if (isEmpty({ ...props })) {\n    return null;\n  }\n\n  return (\n    <Card\n      sx={{\n        backgroundColor: 'background',\n        border: 0,\n        padding: 1,\n        ...props.sx,\n      }}\n    >\n      <Flex\n        sx={{\n          gap: 4,\n          flexDirection: ['row', 'column', 'column'],\n          alignItems: ['center', 'flex-start', 'flex-start'],\n          justifyContent: ['center', 'flex-start', 'flex-start'],\n        }}\n      >\n        <Flex sx={{ gap: 4, flexDirection: 'column' }}>\n          {props.pin && props.profile.username && (\n            <InternalLink\n              to={'/map#' + props.profile.username}\n              sx={{ color: 'black', ':hover': { textDecoration: 'underline' } }}\n              data-testid=\"location-link\"\n            >\n              <Flex sx={{ alignItems: 'center', gap: 2 }}>\n                <Icon glyph=\"map\" size={22} />\n                <Text>Location: {props.pin.country || 'View on Map'}</Text>\n              </Flex>\n            </InternalLink>\n          )}\n          {props?.profile.badges?.map((badge) => (\n            <Flex\n              key={badge.id}\n              sx={{ alignItems: 'center', gap: 1 }}\n              data-testid={`badge_${badge.name}`}\n            >\n              <Image width={20} height={20} src={badge.imageUrl} />\n              <Box>\n                {badge.actionUrl ? (\n                  <ExternalLink href={badge.actionUrl} target=\"_blank\">\n                    <Text sx={{ color: 'black' }}>{badge.displayName}</Text>\n                  </ExternalLink>\n                ) : (\n                  <Text sx={{ color: 'black' }}>{badge.displayName}</Text>\n                )}\n              </Box>\n            </Flex>\n          ))}\n\n          {props.usefulCount > 0 && (\n            <Flex data-testid=\"useful-stat\">\n              <ElWithBeforeIcon icon={starActiveSVG} />\n              {`Useful: ${props.usefulCount}`}\n            </Flex>\n          )}\n\n          {props.libraryCount > 0 && props.profile.username && (\n            <InternalLink\n              to={'/library?q=' + props.profile.username}\n              sx={{ color: 'black', ':hover': { textDecoration: 'underline' } }}\n              data-testid=\"library-link\"\n            >\n              <Flex data-testid=\"library-stat\">\n                <ElWithBeforeIcon icon={HowToCountIcon} />\n                {`Library: ${props.libraryCount}`}\n              </Flex>\n            </InternalLink>\n          )}\n\n          {props.researchCount > 0 && props.profile.username && (\n            <InternalLink\n              to={'/research?q=' + props.profile.username}\n              sx={{ color: 'black', ':hover': { textDecoration: 'underline' } }}\n              data-testid=\"research-link\"\n            >\n              <Flex data-testid=\"research-stat\">\n                <ElWithBeforeIcon icon={ResearchIcon} />\n                {`Research: ${props.researchCount}`}\n              </Flex>\n            </InternalLink>\n          )}\n\n          {props.questionCount > 0 && (\n            <InternalLink\n              to={'/questions'}\n              sx={{ color: 'black', ':hover': { textDecoration: 'underline' } }}\n              data-testid=\"questions-link\"\n            >\n              <Flex data-testid=\"questions-stat\">\n                <ElWithBeforeIcon icon={ForumIcon} />\n                {`Questions: ${props.questionCount}`}\n              </Flex>\n            </InternalLink>\n          )}\n\n          {props.showViews && props.profile.totalViews > 0 && (\n            <Flex data-testid=\"profile-views-stat\">\n              <Icon glyph=\"show\" size={22} />\n              <Box ml={1}>{`Views: ${props.profile.totalViews}`}</Box>\n            </Flex>\n          )}\n        </Flex>\n      </Flex>\n    </Card>\n  );\n};\n\nconst isEmpty = (props: UserStatisticsProps & { pin?: Pick<MapPin, 'country'> }) =>\n  !props.pin &&\n  !props.profile.badges?.length &&\n  !props.profile.country &&\n  !props.libraryCount &&\n  !props.researchCount &&\n  !props.profile.totalViews &&\n  !props.questionCount &&\n  !props.usefulCount;\n"
  },
  {
    "path": "packages/components/src/Username/DisplayName.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\nimport { DisplayName } from './DisplayName';\nimport type { Meta } from '@storybook/react-vite';\nimport type { Author } from 'oa-shared';\n\nexport default {\n  title: 'Components/DisplayName',\n  component: DisplayName,\n} as Meta<typeof DisplayName>;\n\nexport const Default = {\n  args: {\n    user: {\n      username: 'cool-maker',\n      displayName: 'Cool Maker',\n      country: 'de',\n    } as Author,\n  },\n};\n\nexport const WithBadge = {\n  args: {\n    user: {\n      username: 'pro-user',\n      displayName: 'Pro User',\n      country: 'nl',\n      badges: [\n        {\n          id: 1,\n          name: 'pro',\n          displayName: 'PRO',\n          imageUrl: faker.image.avatar(),\n        },\n      ],\n    } as Author,\n  },\n};\n\nexport const WithMultipleBadges = {\n  args: {\n    user: {\n      username: 'super-user',\n      displayName: 'Super User',\n      country: 'pt',\n      badges: [\n        {\n          id: 1,\n          name: 'pro',\n          displayName: 'PRO',\n          imageUrl: faker.image.avatar(),\n        },\n        {\n          id: 2,\n          name: 'supporter',\n          displayName: 'Supporter',\n          actionUrl: faker.internet.url(),\n          imageUrl: faker.image.avatar(),\n        },\n      ],\n    } as Author,\n  },\n};\n\nexport const UsernameOnly = {\n  args: {\n    user: {\n      username: 'just-a-username',\n    } as Author,\n  },\n};\n\nexport const NotALink = {\n  args: {\n    user: {\n      username: 'static-user',\n      displayName: 'Static User',\n      country: 'fr',\n    } as Author,\n    isLink: false,\n  },\n};"
  },
  {
    "path": "packages/components/src/Username/DisplayName.tsx",
    "content": "import { countryToAlpha2 } from 'country-to-iso';\nimport type { Author } from 'oa-shared';\nimport type { HTMLAttributeAnchorTarget } from 'react';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Flex, Text } from 'theme-ui';\nimport { FlagIcon } from '../FlagIcon/FlagIcon';\nimport { InternalLink } from '../InternalLink/InternalLink';\nimport { UserBadge } from './UserBadge';\n\nexport interface DisplayNameProps {\n  user: Partial<Author>;\n  sx?: ThemeUIStyleObject;\n  isLink?: boolean;\n  target?: HTMLAttributeAnchorTarget;\n}\n\nexport const DisplayName = ({ user, sx, target, isLink = true }: DisplayNameProps) => {\n  const { username, displayName, country, badges } = user;\n  const countryCode = country ? countryToAlpha2(country) : null;\n\n  const DisplayNameBody = (\n    <Flex\n      data-cy=\"DisplayName\"\n      sx={{ fontFamily: 'body', gap: 1, alignItems: 'center', minWidth: 0 }}\n    >\n      <Text\n        sx={{\n          color: 'black',\n          overflow: 'hidden',\n          whiteSpace: 'nowrap',\n          textOverflow: 'ellipsis',\n        }}\n        title={displayName || username || ''}\n      >\n        {displayName || username}\n      </Text>\n\n      {badges &&\n        badges.map((badge) => {\n          return <UserBadge key={badge.id} badge={badge} />;\n        })}\n\n      {countryCode && <FlagIcon countryCode={countryCode} />}\n    </Flex>\n  );\n\n  if (!isLink || !username) {\n    return DisplayNameBody;\n  }\n\n  return (\n    <InternalLink\n      to={`/u/${username}`}\n      target={target || '_self'}\n      sx={{\n        border: '1px solid transparent',\n        display: 'inline-flex',\n        minWidth: 0,\n        overflow: 'hidden',\n        paddingX: 1,\n        paddingY: '3px',\n        borderRadius: 1,\n        marginLeft: -1,\n        color: 'black',\n        fontSize: 2,\n        transition: '80ms ease-out all',\n        '&:focus': {\n          borderColor: '#20B7EB',\n          background: 'softblue',\n          outline: 'none',\n          color: 'bluetag',\n        },\n        '&:hover': {\n          borderColor: '#20B7EB',\n          background: 'softblue',\n          textcolor: 'bluetag',\n        },\n        ...(sx || {}),\n      }}\n    >\n      {DisplayNameBody}\n    </InternalLink>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Username/UserBadge.tsx",
    "content": "import type { ProfileBadge } from 'oa-shared';\nimport { useId } from 'react';\nimport { Tooltip } from 'react-tooltip';\nimport { Image } from 'theme-ui';\n\ninterface IProps {\n  badge: ProfileBadge;\n}\n\nexport const UserBadge = ({ badge }: IProps) => {\n  const uuid = useId();\n\n  return (\n    <>\n      <Image\n        src={badge.imageUrl}\n        sx={{ ml: 1, height: 16, width: 16, flexShrink: 0 }}\n        data-testid={`Username: ${badge.name} badge`}\n        data-tooltip-id={uuid}\n        data-tooltip-content={badge.displayName}\n      />\n      <Tooltip id={uuid} />\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/Username/Username.stories.tsx",
    "content": "import { faker } from '@faker-js/faker';\n\nimport { Username } from './Username';\n\nimport type { Meta } from '@storybook/react-vite';\nimport type { Author } from 'oa-shared';\n\nexport default {\n  title: 'Components/Username',\n  component: Username,\n} as Meta<typeof Username>;\n\nexport const NoBadge = {\n  args: {\n    user: {\n      country: 'pt',\n      username: 'a-username',\n    } as Author,\n  },\n};\n\nexport const OneBadge = {\n  args: {\n    user: {\n      username: 'a-username',\n      country: 'pt',\n      badges: [\n        {\n          id: 1,\n          name: 'pro',\n          displayName: 'PRO',\n          imageUrl: faker.image.avatar(),\n        },\n      ],\n    } as Author,\n  },\n};\n\nexport const TwoBadges = {\n  args: {\n    user: {\n      country: 'pt',\n      username: 'a-username',\n      badges: [\n        {\n          id: 1,\n          name: 'pro',\n          displayName: 'PRO',\n          imageUrl: faker.image.avatar(),\n        },\n        {\n          id: 2,\n          name: 'supporter',\n          displayName: 'Supporter',\n          actionUrl: faker.internet.url(),\n          imageUrl: faker.image.avatar(),\n        },\n      ],\n    } as Author,\n  },\n};\n\nexport const WithoutFlag = {\n  args: {\n    user: {\n      username: 'a-username',\n    } as Author,\n  },\n};\n\nexport const InvalidCountryCode = {\n  args: {\n    user: {\n      username: 'a-username',\n      country: 'zz',\n    } as Author,\n  },\n};\n\nexport const InlineStyles = {\n  args: {\n    user: {\n      username: 'a-username',\n    } as Author,\n    sx: {\n      outline: '10px solid red',\n    },\n  },\n};\n"
  },
  {
    "path": "packages/components/src/Username/Username.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { Username } from './Username';\nimport {\n  InvalidCountryCode,\n  // OneBadge,\n  // TwoBadges,\n  WithoutFlag,\n} from './Username.stories';\n\ndescribe('Username', () => {\n  it('shows an unknown flag for empty value', () => {\n    const { getByTestId } = render(<Username {...WithoutFlag.args} />);\n\n    expect(getByTestId('Username: unknown flag')).toBeInTheDocument();\n  });\n\n  it('shows an unknown flag for an invalid country code', () => {\n    const { getByTestId } = render(<Username {...InvalidCountryCode.args} />);\n\n    expect(getByTestId('Username: unknown flag')).toBeInTheDocument();\n  });\n\n  // it('shows one badge', () => {\n  //   const { getByTestId } = render(<Username {...OneBadge.args} />)\n  //   expect(\n  //     getByTestId(`Username: ${OneBadge.args.user.badges[0].name} badge`),\n  //   ).toBeInTheDocument()\n  // })\n\n  // it('shows two badges', () => {\n  //   const { getByTestId } = render(<Username {...TwoBadges.args} />)\n  //   expect(\n  //     getByTestId(`Username: ${OneBadge.args.user.badges[0].name} badge`),\n  //   ).toBeInTheDocument()\n  //   expect(\n  //     getByTestId(`Username: ${OneBadge.args.user.badges[1].name} badge`),\n  //   ).toBeInTheDocument()\n  // })\n});\n"
  },
  {
    "path": "packages/components/src/Username/Username.tsx",
    "content": "import { countryToAlpha2 } from 'country-to-iso';\nimport type { Author } from 'oa-shared';\nimport type { HTMLAttributeAnchorTarget } from 'react';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Flex, Text } from 'theme-ui';\nimport flagUnknownSVG from '../../assets/icons/flag-unknown.svg';\nimport { FlagIcon } from '../FlagIcon/FlagIcon';\nimport { InternalLink } from '../InternalLink/InternalLink';\nimport { UserBadge } from './UserBadge';\n\nexport interface IProps {\n  user: Partial<Author>;\n  sx?: ThemeUIStyleObject;\n  isLink?: boolean;\n  target?: HTMLAttributeAnchorTarget;\n}\n\nconst getCountryCode = (country: string | undefined) => {\n  if (!country) {\n    return null;\n  }\n  return countryToAlpha2(country);\n};\n\nexport const Username = (props: IProps) => {\n  const { user, sx = {}, target = '_self', isLink = true } = props;\n  const { username, badges } = user;\n\n  if (!username) {\n    return null;\n  }\n\n  const countryCode = user.country ? getCountryCode(user.country) : null;\n\n  const UserNameBody = (\n    <Flex data-cy=\"Username\" sx={{ fontFamily: 'body', gap: 1, alignItems: 'center', minWidth: 0 }}>\n      {countryCode ? (\n        <Flex data-testid=\"Username: known flag\">\n          <FlagIcon countryCode={countryCode} />\n        </Flex>\n      ) : (\n        <Flex\n          data-testid=\"Username: unknown flag\"\n          sx={{\n            backgroundImage: `url(\"${flagUnknownSVG}\")`,\n            backgroundSize: 'cover',\n            borderRadius: '3px',\n            height: '14px',\n            width: '21px !important',\n            justifyContent: 'center',\n            alignItems: 'center',\n            lineHeight: 0,\n            overflow: 'hidden',\n          }}\n        ></Flex>\n      )}\n\n      <Text\n        sx={{\n          color: 'black',\n          overflow: 'hidden',\n          whiteSpace: 'nowrap',\n          textOverflow: 'ellipsis',\n          maxWidth: '100%',\n        }}\n        title={username}\n      >\n        {username}\n      </Text>\n\n      {badges &&\n        badges.map((badge) => {\n          return <UserBadge key={badge.id} badge={badge} />;\n        })}\n    </Flex>\n  );\n\n  if (!isLink) {\n    return UserNameBody;\n  }\n\n  return (\n    <InternalLink\n      to={`/u/${username}`}\n      target={target}\n      sx={{\n        border: '1px solid transparent',\n        display: 'inline-flex',\n        minWidth: 0,\n        paddingX: 1,\n        paddingY: '3px',\n        borderRadius: 1,\n        marginLeft: -1,\n        color: 'black',\n        fontSize: 2,\n        maxWidth: '100%',\n        transition: '80ms ease-out all',\n        '&:focus': {\n          borderColor: '#20B7EB',\n          background: 'softblue',\n          outline: 'none',\n          color: 'bluetag',\n        },\n        '&:hover': {\n          borderColor: '#20B7EB',\n          background: 'softblue',\n          textcolor: 'bluetag',\n        },\n        ...sx,\n      }}\n    >\n      {UserNameBody}\n    </InternalLink>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/VerticalList/VerticalList.client.tsx",
    "content": "// As much as possible taken directly from https://asmyshlyaev177.github.io/react-horizontal-scrolling-menu/?path=/story/examples-simple--simple\n// Generally only edited for readability\n\nimport styled from '@emotion/styled';\nimport React, { Children, cloneElement, isValidElement, useContext } from 'react';\nimport type { publicApiType } from 'react-horizontal-scrolling-menu';\nimport { ScrollMenu, VisibilityContext } from 'react-horizontal-scrolling-menu';\nimport { Box } from 'theme-ui';\nimport { Arrow } from '../ArrowIcon/ArrowIcon';\n\nimport 'react-horizontal-scrolling-menu/dist/styles.css';\n\nexport interface IProps {\n  children: React.ReactNode[];\n  dataCy?: string;\n}\n\nexport const LeftArrow = () => {\n  const visibility = useContext<publicApiType>(VisibilityContext);\n  const disabled = visibility.useLeftArrowVisible();\n  const onClick = () => visibility.scrollToItem(visibility.getPrevElement(), 'smooth', 'start');\n\n  return (\n    <Arrow disabled={disabled} direction=\"left\" sx={{ marginLeft: '10px' }} onClick={onClick} />\n  );\n};\n\nexport const RightArrow = () => {\n  const visibility = useContext<publicApiType>(VisibilityContext);\n  const disabled = visibility.useRightArrowVisible();\n  const onClick = () => visibility.scrollToItem(visibility.getNextElement(), 'smooth', 'end');\n\n  return (\n    <Arrow disabled={disabled} direction=\"right\" sx={{ marginRight: '10px' }} onClick={onClick} />\n  );\n};\n\nexport const VerticalList = ({ children, dataCy }: IProps) => {\n  const childrenWithIds: any = Children.map(children, (child, index) => {\n    if (isValidElement(child) && !(child.props as any).itemID) {\n      return cloneElement(child, { itemID: `item-${index}` } as any);\n    }\n    return child;\n  })?.filter((x) => !!x);\n\n  return (\n    <Box data-cy={dataCy} sx={{ alignSelf: 'center', maxWidth: '100%' }}>\n      <NoScrollbar>\n        <ScrollMenu LeftArrow={LeftArrow} RightArrow={RightArrow} onWheel={onWheel}>\n          {childrenWithIds}\n        </ScrollMenu>\n      </NoScrollbar>\n    </Box>\n  );\n};\n\nconst NoScrollbar = styled('div')({\n  '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {\n    display: 'none',\n  },\n\n  '& .react-horizontal-scrolling-menu--scroll-container': {\n    display: 'flex',\n    scrollbarWidth: 'none',\n    msOverflowStyle: 'none',\n  },\n});\n\nfunction onWheel(apiObj: publicApiType, ev: React.WheelEvent): void {\n  // NOTE: no good standard way to distinguish touchpad scrolling gestures\n  // but can assume that gesture will affect X axis, mouse scroll only Y axis\n  // of if deltaY too small probably is it touchpad\n  const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;\n\n  if (isThouchpad) {\n    ev.stopPropagation();\n    return;\n  }\n\n  if (ev.deltaY < 0) {\n    apiObj.scrollNext();\n  } else {\n    apiObj.scrollPrev();\n  }\n}\n"
  },
  {
    "path": "packages/components/src/VerticalList/VerticalList.stories.tsx",
    "content": "import { Box } from 'theme-ui';\n\nimport { VerticalList } from './VerticalList.client';\n\nimport type { Meta, StoryFn } from '@storybook/react-vite';\n\nexport default {\n  title: 'Components/VerticalList',\n  component: VerticalList,\n} as Meta<typeof VerticalList>;\n\nexport const Default: StoryFn<typeof VerticalList> = () => {\n  const items = ['hello', 'world!', '...', 'Yeah,', 'you!'];\n  return (\n    <div style={{ width: '500px' }}>\n      <VerticalList>\n        {items.map((item, index) => (\n          <Box\n            key={index}\n            sx={{\n              width: '200px',\n              height: '200px',\n              border: '2px solid #000',\n            }}\n          >\n            {item}\n          </Box>\n        ))}\n      </VerticalList>\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/VideoPlayer/VideoPlayer.stories.tsx",
    "content": "import { VideoPlayer } from './VideoPlayer';\nimport type { Meta, StoryObj } from '@storybook/react-vite';\n\nconst meta: Meta<typeof VideoPlayer> = {\n  title: 'Components/VideoPlayer',\n  component: VideoPlayer,\n};\nexport default meta;\n\ntype Story = StoryObj<typeof VideoPlayer>;\n\nexport const Youtube: Story = {\n  args: {\n    videoUrl: 'https://www.youtube.com/watch?v=anqfVCLRQHE',\n  },\n};\n\nexport const Vimeo: Story = {\n  args: {\n    videoUrl: 'https://vimeo.com/492811707',\n  },\n};"
  },
  {
    "path": "packages/components/src/VideoPlayer/VideoPlayer.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { VideoPlayer } from './VideoPlayer';\n\ndescribe('VideoPlayer', () => {\n  it('uses lazy load mechanism', () => {\n    const { getByTitle } = render(\n      <VideoPlayer videoUrl=\"https://www.youtube.com/watch?v=anqfVCLRQHE\" />,\n    );\n\n    expect(() => getByTitle('YEAR ONE. Everything we built on our abandoned land')).toThrow();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/VideoPlayer/VideoPlayer.tsx",
    "content": "import ReactPlayer from 'react-player';\nimport { Box } from 'theme-ui';\n\nexport interface Props {\n  videoUrl: string;\n}\n\nexport const VideoPlayer = ({ videoUrl }: Props) => {\n  return (\n    <Box data-testid=\"VideoPlayer\">\n      <ReactPlayer width=\"auto\" controls url={videoUrl} />\n    </Box>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/VisitorModal/VisitorModal.tsx",
    "content": "import type { Profile, UserVisitorPreferencePolicy } from 'oa-shared';\nimport { Flex, Text } from 'theme-ui';\nimport { iconMap } from '../Icon/svgs';\nimport { Modal } from '../Modal/Modal';\nimport type { DisplayData, HideProp } from './props';\nimport { VisitorModalFooter } from './VisitorModalFooter';\nimport { VisitorModalHeader } from './VisitorModalHeader';\n\nexport const visitorDisplayData = new Map<UserVisitorPreferencePolicy, DisplayData>([\n  [\n    'open',\n    {\n      icon: iconMap.visitorsOpen,\n      label: 'Open to visitors',\n      default: 'This space welcomes visitors.',\n    },\n  ],\n  [\n    'appointment',\n    {\n      icon: iconMap.visitorsAppointment,\n      label: 'Visitors after appointment',\n      default:\n        'This space prefers an appointment before visiting it. See their contact options, or get in touch directly!',\n    },\n  ],\n  [\n    'closed',\n    {\n      icon: iconMap.visitorsClosed,\n      label: 'Visits currently not possible',\n      default: 'It is not possible to come and visit this space.',\n    },\n  ],\n]);\n\nexport type VisitorModalProps = HideProp & {\n  show: boolean;\n  user: Profile;\n};\n\nexport const VisitorModal = ({ show, hide, user }: VisitorModalProps) => {\n  const { displayName, visitorPolicy, isContactable } = user;\n\n  const displayData = visitorPolicy && visitorDisplayData.get(visitorPolicy.policy);\n\n  if (!displayData) {\n    return null;\n  }\n\n  return (\n    <Modal isOpen={show} onDismiss={hide} width={450} sx={{ padding: '0 !important' }}>\n      <VisitorModalHeader data={displayData} hide={hide} />\n      <Flex data-cy=\"VisitorModal\" sx={{ flexDirection: 'column', padding: '16px' }}>\n        {visitorPolicy.details && <>Details from {displayName}:</>}\n        <Text variant=\"quiet\">{visitorPolicy.details || displayData.default}</Text>\n      </Flex>\n      {visitorPolicy.policy !== 'closed' && isContactable && <VisitorModalFooter hide={hide} />}\n    </Modal>\n  );\n};\n"
  },
  {
    "path": "packages/components/src/VisitorModal/VisitorModalFooter.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { act } from '@testing-library/react';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { VisitorModalFooter } from './VisitorModalFooter';\n\ndescribe('VisitorModalFooter', () => {\n  it('shows the contact button', () => {\n    const { getByText } = render(<VisitorModalFooter hide={() => {}} />);\n\n    expect(getByText('Contact the space')).toBeInTheDocument();\n  });\n\n  it('passes the \"contact\" target to the hide function on click', () => {\n    const hideTrigger = vi.fn();\n    const { getByText } = render(<VisitorModalFooter hide={hideTrigger} />);\n\n    act(() => {\n      getByText('Contact the space').click();\n    });\n\n    expect(hideTrigger).toHaveBeenCalledWith('contact');\n  });\n});\n"
  },
  {
    "path": "packages/components/src/VisitorModal/VisitorModalFooter.tsx",
    "content": "import { commonStyles } from 'oa-themes';\nimport { Flex } from 'theme-ui';\n\nimport { Button } from '../Button/Button';\nimport { Icon } from '../Icon/Icon';\n\nimport type { HideProp } from './props';\n\nconst ContactSpaceButton = ({ hide }: HideProp) => (\n  <Button\n    sx={{ margin: 1, width: '100%', justifyContent: 'center' }}\n    onClick={() => hide('contact')}\n  >\n    {/* Not using native button icon to allow centralization together with text */}\n    <Flex sx={{ gap: 1, alignItems: 'center' }}>\n      <Icon glyph=\"contact\" />\n      Contact the space\n    </Flex>\n  </Button>\n);\n\nexport const VisitorModalFooter = ({ hide }: HideProp) => (\n  <Flex\n    sx={{\n      padding: 2,\n      borderTop: '1px solid',\n      borderColor: commonStyles.colors.darkGrey,\n    }}\n  >\n    <ContactSpaceButton hide={hide} />\n  </Flex>\n);\n"
  },
  {
    "path": "packages/components/src/VisitorModal/VisitorModalHeader.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { act } from '@testing-library/react';\nimport { Flex } from 'theme-ui';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { render } from '../test/utils';\nimport { VisitorModalHeader } from './VisitorModalHeader';\n\nimport type { DisplayData } from './props';\n\ndescribe('VisitorHeaderFooter', () => {\n  const data: DisplayData = {\n    icon: <Flex>icon</Flex>,\n    label: 'visitor policy label',\n    default: 'policy default text',\n  };\n\n  it('shows the data icon and label', () => {\n    const { getByText } = render(<VisitorModalHeader data={data} hide={() => {}} />);\n\n    expect(getByText('icon')).toBeInTheDocument();\n    expect(getByText('visitor policy label')).toBeInTheDocument();\n  });\n\n  it('passes the \"contact\" target to the hide function on click', () => {\n    const hideTrigger = vi.fn();\n    const { getByTestId } = render(<VisitorModalHeader data={data} hide={hideTrigger} />);\n\n    act(() => {\n      getByTestId('VisitorModal-CloseButton').click();\n    });\n\n    expect(hideTrigger).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "packages/components/src/VisitorModal/VisitorModalHeader.tsx",
    "content": "import { commonStyles } from 'oa-themes';\nimport { Flex } from 'theme-ui';\n\nimport { ButtonIcon } from '../ButtonIcon/ButtonIcon';\n\nimport type { DisplayData, HideProp } from './props';\n\ntype HeaderProps = HideProp & { data: DisplayData };\n\nexport const VisitorModalHeader = ({ hide, data }: HeaderProps) => (\n  <Flex\n    sx={{\n      borderBottom: '1px solid',\n      borderColor: commonStyles.colors.darkGrey,\n      gap: 2,\n      justifyContent: 'space-between',\n      padding: 0,\n      alignItems: 'anchor-center',\n      paddingLeft: 2,\n    }}\n  >\n    <Flex sx={{ alignItems: 'center', columnGap: 1 }}>\n      {data.icon}\n      {data.label}\n    </Flex>\n    <ButtonIcon\n      data-testid=\"VisitorModal-CloseButton\"\n      icon=\"close\"\n      onClick={() => hide()}\n      sx={{ border: 'none', paddingLeft: 2, paddingRight: 3 }}\n    />\n  </Flex>\n);\n"
  },
  {
    "path": "packages/components/src/VisitorModal/props.ts",
    "content": "import type { ReactElement } from 'react';\n\nexport interface DisplayData {\n  icon: ReactElement;\n  label: string;\n  default: string;\n}\n\nexport interface HideProp {\n  hide: (target?: string) => void;\n}\n"
  },
  {
    "path": "packages/components/src/__mocks__/AuthWrapper.mock.tsx",
    "content": "export const AuthWrapper = (props: any) => {\n  console.log(`MockAuthWrapper:`, props);\n  return <>{props.children}</>;\n};\n"
  },
  {
    "path": "packages/components/src/hooks/useImageLightbox.tsx",
    "content": "import type { PhotoSwipeOptions } from 'photoswipe/lightbox';\nimport { useEffect } from 'react';\nimport { usePhotoSwipeLightbox } from './usePhotoSwipeLightbox';\n\ninterface Props {\n  images: { src: string; alt?: string }[];\n  photoSwipeOptions?: PhotoSwipeOptions;\n}\n\nexport const useImageLightbox = ({ images, photoSwipeOptions }: Props) => {\n  const { open } = usePhotoSwipeLightbox({\n    images,\n    photoSwipeOptions,\n  });\n\n  useEffect(() => {\n    const imageElements: HTMLImageElement[] = [];\n\n    for (const img of images) {\n      const found = document.querySelector(`img[src=\"${img.src}\"]`);\n      if (found) {\n        imageElements.push(found as HTMLImageElement);\n      }\n    }\n\n    if (imageElements.length === 0) {\n      return;\n    }\n\n    const cleanupFns: (() => void)[] = [];\n\n    imageElements.forEach((img, index) => {\n      img.style.cursor = 'pointer';\n      const handleClick = (e: MouseEvent) => {\n        e.preventDefault();\n        open(index);\n      };\n\n      img.addEventListener('click', handleClick);\n      cleanupFns.push(() => img.removeEventListener('click', handleClick));\n    });\n\n    return () => {\n      cleanupFns.forEach((fn) => fn());\n      imageElements.forEach((img) => {\n        img.style.cursor = '';\n      });\n    };\n  }, [images, open]);\n};\n"
  },
  {
    "path": "packages/components/src/hooks/usePhotoSwipeLightbox.ts",
    "content": "import type { PhotoSwipeOptions } from 'photoswipe/lightbox';\nimport PhotoSwipeLightbox from 'photoswipe/lightbox';\nimport { useCallback, useEffect, useRef } from 'react';\n\nimport 'photoswipe/style.css';\n\nexport interface UsePhotoSwipeLightboxProps {\n  images: { src: string; alt?: string }[];\n  photoSwipeOptions?: PhotoSwipeOptions;\n}\n\nexport const usePhotoSwipeLightbox = ({\n  images,\n  photoSwipeOptions,\n}: UsePhotoSwipeLightboxProps) => {\n  const lightboxRef = useRef<PhotoSwipeLightbox | null>(null);\n\n  useEffect(() => {\n    if (typeof window === 'undefined' || !images.length) {\n      return;\n    }\n\n    // Initializes the Photoswipe lightbox to use the provided images\n    lightboxRef.current = new PhotoSwipeLightbox({\n      dataSource: images.map((image) => ({\n        src: image.src,\n        alt: image.alt,\n      })),\n      pswpModule: () => import('photoswipe'),\n      ...(photoSwipeOptions ?? {}),\n    });\n\n    // Before opening the lightbox, calculates the image sizes and\n    // refreshes lightbox slide to adapt to these updated dimensions\n    lightboxRef.current.on('beforeOpen', () => {\n      const photoswipe = lightboxRef.current?.pswp;\n      const dataSource = photoswipe?.options?.dataSource;\n\n      if (Array.isArray(dataSource)) {\n        dataSource.forEach((source, index) => {\n          const img = new Image();\n          img.onload = () => {\n            source.width = img.naturalWidth;\n            source.height = img.naturalHeight;\n            photoswipe?.refreshSlideContent(index);\n          };\n          img.src = source.src as string;\n        });\n      }\n    });\n\n    lightboxRef.current.init();\n\n    return () => {\n      lightboxRef.current?.destroy();\n      lightboxRef.current = null;\n    };\n  }, [images]);\n\n  // Prevent native hash scrolling and handle it ourselves\n  useEffect(() => {\n    if (typeof window === 'undefined') return;\n\n    let originalHash = '';\n\n    const preventNativeHashScroll = () => {\n      if (window.location.hash) {\n        originalHash = window.location.hash;\n        // Remove hash completely to prevent any native scrolling\n        window.history.replaceState(null, '', window.location.pathname + window.location.search);\n\n        // Prevent any scroll restoration\n        if ('scrollRestoration' in window.history) {\n          window.history.scrollRestoration = 'manual';\n        }\n      }\n    };\n\n    // Restore hash after component mounts\n    const restoreHash = () => {\n      if (originalHash) {\n        setTimeout(() => {\n          window.history.replaceState(\n            null,\n            '',\n            window.location.pathname + window.location.search + originalHash,\n          );\n        }, 50);\n      }\n    };\n\n    preventNativeHashScroll();\n    restoreHash();\n\n    return () => {\n      // Restore scroll restoration on cleanup\n      if ('scrollRestoration' in window.history) {\n        window.history.scrollRestoration = 'auto';\n      }\n    };\n  }, []);\n\n  const open = useCallback((index: number) => {\n    if (typeof window === 'undefined') {\n      return;\n    }\n    if (!lightboxRef.current) {\n      return;\n    }\n    lightboxRef.current.loadAndOpen(index);\n  }, []);\n\n  return {\n    open,\n    lightboxRef,\n  };\n};\n"
  },
  {
    "path": "packages/components/src/index.ts",
    "content": "export { Accordion } from './Accordion/Accordion';\nexport { ActionSet } from './ActionSet/ActionSet';\nexport { ArticleCallToActionSupabase } from './ArticleCallToActionSupabase/ArticleCallToActionSupabase';\nexport { AuthorDisplay } from './AuthorDisplay/AuthorDisplay';\nexport { Banner } from './Banner/Banner';\nexport { BlockedRoute } from './BlockedRoute/BlockedRoute';\nexport { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';\nexport { Button } from './Button/Button';\nexport { ButtonIcon } from './ButtonIcon/ButtonIcon';\nexport { ButtonShowReplies } from './ButtonShowReplies/ButtonShowReplies';\nexport { CardButton } from './CardButton/CardButton';\nexport { CardListItem } from './CardListItem/CardListItem';\nexport { CardProfile } from './CardProfile/CardProfile';\nexport { Category } from './Category/Category';\nexport { CategoryHorizonalList } from './CategoryHorizonalList/CategoryHorizonalList';\nexport { CommentAvatar } from './CommentAvatar/CommentAvatar';\nexport { CommentDisplay } from './CommentDisplay/CommentDisplay';\nexport { CommentsTitle } from './CommentsTitle/CommentsTitle';\nexport { ConfirmModal } from './ConfirmModal/ConfirmModal';\nexport { ContentStatistics } from './ContentStatistics/ContentStatistics';\nexport type { IStatistic } from './ContentStatistics/types';\nexport { CreateComment } from './CreateComment/CreateComment';\nexport { CreateReply } from './CreateReply/CreateReply';\nexport type { PublishedAction } from './DisplayDate/DisplayDate';\nexport { DisplayDate } from './DisplayDate/DisplayDate';\n// export { DisplayMarkdownStylingWrapper } from './DisplayMarkdown/DisplayMarkdownStylingWrapper'\nexport { DonationRequestModal } from './DonationRequestModal/DonationRequestModal';\nexport { DownloadButton } from './DownloadButton/DownloadButton';\nexport { DownloadCounter } from './DownloadCounter/DownloadCounter';\nexport { DownloadStaticFile } from './DownloadStaticFile/DownloadStaticFile';\nexport { EditComment } from './EditComment/EditComment';\nexport { ElWithBeforeIcon } from './ElWithBeforeIcon/ElWithBeforeIcon';\nexport { ExternalLink } from './ExternalLink/ExternalLink';\nexport { FieldCheckbox } from './FieldCheckbox/FieldCheckbox';\nexport { FieldInput } from './FieldInput/FieldInput';\nexport { FieldMarkdown } from './FieldMarkdown/FieldMarkdown';\nexport { FieldTextarea } from './FieldTextarea/FieldTextarea';\nexport { FlagIcon } from './FlagIcon/FlagIcon';\nexport { FollowButton } from './FollowButton/FollowButton';\nexport { FollowIcon } from './FollowIcon/FollowIcon';\nexport { GlobalStyles } from './GlobalStyles/GlobalStyles';\nexport type { GridFormFields } from './GridForm/GridForm';\nexport { GridForm } from './GridForm/GridForm';\nexport { Guidelines } from './Guidelines/Guidelines';\nexport { HeroBanner } from './HeroBanner/HeroBanner';\nexport { useImageLightbox } from './hooks/useImageLightbox';\nexport { Icon } from './Icon/Icon';\nexport type { availableGlyphs } from './Icon/types';\nexport { IconCountWithTooltip } from './IconCountWithTooltip/IconCountWithTooltip';\nexport { ImageGallery } from './ImageGallery/ImageGallery';\nexport { ImageInputDeleteOverlay } from './ImageInput/ImageInputDeleteOverlay';\nexport { ImageInputV2 } from './ImageInput/ImageInputV2';\nexport { InformationTooltip } from './InformationTooltip/InformationTooltip';\nexport { InternalLink } from './InternalLink/InternalLink';\nexport { LinkifyText } from './LinkifyText/LinkifyText';\nexport { Loader } from './Loader/Loader';\nexport { type IProps as MapProps, Map } from './Map/Map.client';\nexport { MapCardList } from './MapCardList/MapCardList';\nexport { MapFilterListItem } from './MapFilterListItem/MapFilterListItem';\nexport { MapWithPin } from './MapWithPin/MapWithPin.client';\nexport { MemberBadge } from './MemberBadge/MemberBadge';\nexport { MemberHistory } from './MemberHistory/MemberHistory';\nexport { Modal } from './Modal/Modal';\nexport { ModerationRecord, ModerationStatus } from './ModerationStatus/ModerationStatus';\nexport { MoreContainer } from './MoreContainer/MoreContainer';\nexport { NotificationListSupabase } from './NotificationListSupabase/NotificationListSupabase';\nexport { NotificationsModal } from './NotificationsModal/NotificationsModal';\nexport { OsmGeocoding } from './OsmGeocoding/OsmGeocoding';\nexport { Pagination } from './Pagination/Pagination';\nexport { PaginationIcons } from './PaginationIcons/PaginationIcons';\nexport { PinProfile } from './PinProfile/PinProfile';\nexport { ProfileBadgeContentLabel } from './ProfileBadgeContentLabel/ProfileBadgeContentLabel';\nexport { ProfileLink } from './ProfileLink/ProfileLink';\nexport { ProfileList } from './ProfileList/ProfileList';\nexport { ProfileTagsList } from './ProfileTagsList/ProfileTagsList';\nexport { AuthorsContext } from './providers/AuthorsContext';\nexport { ResearchEditorOverview } from './ResearchEditorOverview/ResearchEditorOverview';\nexport { ReturnPathLink } from './ReturnPathLink/ReturnPathLink';\nexport { SearchField } from './SearchField/SearchField';\nexport { Select } from './Select/Select';\nexport { SiteFooter } from './SiteFooter/SiteFooter';\nexport { Tab, TabPanel, Tabs, TabsList } from './TabbedContent/TabbedContent';\nexport { Tag } from './Tag/Tag';\nexport { TagList } from './TagList/TagList';\nexport { TextNotification } from './TextNotification/TextNotification';\nexport { Tooltip } from './Tooltip/Tooltip';\nexport { UsefulStatsButton } from './UsefulStatsButton/UsefulStatsButton';\nexport { UserEngagementWrapper } from './UserEngagementWrapper/UserEngagementWrapper';\nexport { DisplayName } from './Username/DisplayName';\nexport { UserBadge } from './Username/UserBadge';\nexport { Username } from './Username/Username';\nexport { UserStatistics } from './UserStatistics/UserStatistics';\nexport { VerticalList } from './VerticalList/VerticalList.client';\nexport { VideoPlayer } from './VideoPlayer/VideoPlayer';\nexport { VisitorModal, visitorDisplayData } from './VisitorModal/VisitorModal';\n"
  },
  {
    "path": "packages/components/src/providers/AuthorsContext.ts",
    "content": "import { createContext } from 'react';\n\ninterface AuthorsContextType {\n  authors: Array<number>;\n}\n\nexport const AuthorsContext = createContext<AuthorsContextType>({\n  authors: [],\n});\n"
  },
  {
    "path": "packages/components/src/test/setup.ts",
    "content": "// import matchers from '@testing-library/jest-dom/matchers'\nimport { cleanup } from '@testing-library/react';\nimport { afterEach } from 'vitest';\n\n// extends Vitest's expect method with methods from react-testing-library\n// expect.extend(matchers)\n\n// Mock HTMLDialogElement methods (not available in jsdom)\nHTMLDialogElement.prototype.showModal = function () {\n  this.open = true;\n};\n\nHTMLDialogElement.prototype.close = function () {\n  this.open = false;\n};\n\n// runs a cleanup after each test case (e.g. clearing jsdom)\nafterEach(() => {\n  cleanup();\n});\n"
  },
  {
    "path": "packages/components/src/test/utils.tsx",
    "content": "import type { RenderOptions, RenderResult } from '@testing-library/react';\nimport { render as testLibReact } from '@testing-library/react';\nimport { ThemeProvider } from '@theme-ui/core';\nimport { theme } from 'oa-themes';\nimport type { ReactElement } from 'react';\nimport { createRoutesStub } from 'react-router';\n\nconst customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>): RenderResult =>\n  testLibReact(ui, {\n    wrapper: ({ children }: { children: React.ReactNode }) => {\n      const RouterStub = createRoutesStub([\n        {\n          path: '',\n          Component() {\n            return <>{children}</>;\n          },\n        },\n      ]);\n\n      return (\n        <ThemeProvider theme={theme}>\n          <RouterStub />\n        </ThemeProvider>\n      );\n    },\n\n    ...options,\n  });\n\nexport { customRender as render };\n"
  },
  {
    "path": "packages/components/src/types/common.ts",
    "content": "export type User = {\n  userName: string;\n  countryCode?: string | null;\n  isSupporter?: boolean;\n  isVerified?: boolean;\n};\n"
  },
  {
    "path": "packages/components/src/utils.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport type {\n  Author,\n  Comment,\n  News,\n  NotificationDisplay,\n  PinProfile,\n  ProfileType,\n  Question,\n  ResearchItem,\n  ResearchUpdate,\n} from 'oa-shared';\n\nexport const fakeAuthorSB = (authorOverloads: Partial<Author> = {}): Author => ({\n  id: faker.number.int(),\n  country: faker.location.country(),\n  displayName: faker.person.firstName(),\n  username: faker.internet.username(),\n  photo: {\n    id: faker.string.uuid(),\n    publicUrl: faker.image.avatar(),\n  },\n  badges: [],\n  ...authorOverloads,\n});\n\nexport const createFakeCommentsSB = (numberOfComments = 2, commentOverloads = {}): Comment[] =>\n  [...Array(numberOfComments).keys()].slice(0).map(() =>\n    fakeCommentSB({\n      ...commentOverloads,\n    }),\n  );\n\nexport const fakeCommentSB = (commentOverloads: Partial<Comment> = {}): Comment => ({\n  id: faker.number.int(100),\n  createdAt: new Date(),\n  modifiedAt: new Date(),\n  createdBy: null,\n  comment: faker.lorem.text(),\n  sourceId: faker.number.int(100),\n  sourceType: 'questions',\n  parentId: faker.number.int(100),\n  deleted: false,\n  highlighted: false,\n  replies: [],\n  ...commentOverloads,\n});\n\nexport const fakeNewsSB = (newsOverloads: Partial<News> = {}): News => ({\n  id: faker.number.int(),\n  createdAt: faker.date.past(),\n  modifiedAt: null,\n  publishedAt: null,\n  author: fakeAuthorSB(),\n  category: null,\n  commentCount: faker.number.int(100),\n  deleted: false,\n  title: faker.lorem.words(8),\n  previousSlugs: [],\n  slug: 'random-slug',\n  subscriberCount: faker.number.int(20),\n  summary: null,\n  tags: [],\n  totalViews: faker.number.int(100),\n  usefulCount: faker.number.int(20),\n  body: faker.word.words(50),\n  bodyHtml: faker.word.words(50),\n  heroImage: null,\n  isDraft: false,\n  profileBadge: null,\n  ...newsOverloads,\n});\n\nexport const fakeDisplayNotification = (\n  notificationOverloads: Partial<NotificationDisplay> = {},\n): NotificationDisplay => ({\n  id: faker.number.int(),\n  isRead: faker.datatype.boolean(),\n  contentType: 'comments',\n  email: {\n    body: undefined,\n    buttonLabel: 'See the full discussion',\n    preview: 'Jeff has left a new comment',\n    subject: 'A new comment on something',\n  },\n  sidebar: {\n    icon: 'discussion',\n    image: faker.image.avatar(),\n  },\n  title: `left a comment on Title`,\n  triggeredBy: faker.internet.username(),\n  date: faker.date.past(),\n  body: faker.lorem.text(),\n  link: 'comment',\n  ...notificationOverloads,\n});\n\nexport const fakeQuestionSB = (questionOverloads: Partial<Question> = {}): Question => ({\n  id: faker.number.int(),\n  createdAt: faker.date.past(),\n  modifiedAt: null,\n  publishedAt: null,\n  author: fakeAuthorSB(),\n  category: null,\n  commentCount: faker.number.int(100),\n  description: faker.lorem.words(20),\n  deleted: false,\n  images: [],\n  title: faker.lorem.words(8),\n  previousSlugs: [],\n  slug: 'random-slug',\n  subscriberCount: faker.number.int(20),\n  tags: [],\n  totalViews: faker.number.int(100),\n  usefulCount: faker.number.int(20),\n  isDraft: false,\n  ...questionOverloads,\n});\n\nexport const fakeResearchItem = (\n  researchItemOverloads: Partial<ResearchItem> = {},\n): ResearchItem => ({\n  id: faker.number.int(),\n  createdAt: faker.date.past(),\n  modifiedAt: null,\n  publishedAt: null,\n  author: fakeAuthorSB(),\n  category: null,\n  collaborators: [],\n  collaboratorsUsernames: [],\n  commentCount: faker.number.int(100),\n  deleted: false,\n  description: faker.lorem.words(12),\n  image: null,\n  usefulCount: faker.number.int(20),\n  title: faker.lorem.words(8),\n  previousSlugs: [],\n  slug: 'random-slug',\n  subscriberCount: faker.number.int(20),\n  status: 'in-progress',\n  updateCount: 0,\n  updates: [],\n  tags: [],\n  totalViews: faker.number.int(100),\n  isDraft: false,\n  ...researchItemOverloads,\n});\n\nexport const fakeResearchUpdate = (\n  researchItemOverloads: Partial<ResearchUpdate> = {},\n): ResearchUpdate => ({\n  id: faker.number.int(),\n  createdAt: faker.date.past(),\n  modifiedAt: null,\n  publishedAt: null,\n  author: fakeAuthorSB(),\n  researchId: faker.number.int(100),\n  commentCount: faker.number.int(100),\n  deleted: false,\n  description: faker.lorem.words(12),\n  images: [],\n  files: null,\n  fileDownloadCount: faker.number.int(100),\n  isDraft: false,\n  hasFileLink: false,\n  videoUrl: null,\n  title: faker.lorem.words(8),\n  ...researchItemOverloads,\n});\n\nexport const fakeProfileType = (profileTypeOverLoads: Partial<ProfileType> = {}): ProfileType => ({\n  id: faker.number.int(),\n  description: '',\n  displayName: '',\n  imageUrl: faker.image.avatar(),\n  smallImageUrl: faker.image.avatar(),\n  mapPinName: 'Wants to get started',\n  name: 'Member',\n  order: 0,\n  isSpace: false,\n  ...profileTypeOverLoads,\n});\n\nexport const fakePinProfile = (pinProfileOverLoads: Partial<PinProfile> = {}): PinProfile => ({\n  id: faker.number.int(),\n  about: '',\n  country: faker.location.country(),\n  coverImages: null,\n  displayName: faker.person.firstName(),\n  username: faker.internet.username(),\n  photo: {\n    id: faker.string.uuid(),\n    path: faker.image.avatar(),\n    fullPath: faker.image.avatar(),\n    publicUrl: faker.image.avatar(),\n  },\n  type: fakeProfileType(),\n  badges: [],\n  visitorPolicy: null,\n  isContactable: true,\n  lastActive: new Date(),\n  ...pinProfileOverLoads,\n});\n"
  },
  {
    "path": "packages/components/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"target\": \"es2023\",\n    \"jsx\": \"react-jsx\",\n    \"module\": \"ESNext\",\n    \"rootDir\": \"./src\",\n    \"moduleResolution\": \"bundler\",\n    \"declaration\": true,\n    \"outDir\": \"./dist\",\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"strict\": true,\n    \"strictPropertyInitialization\": false,\n    \"skipLibCheck\": true,\n    \"paths\": {\n      \"oa-shared\": [\"../../shared/index.ts\"],\n      \"oa-shared/*\": [\"../../shared/*\"],\n      \"oa-themes\": [\"../themes/src/index.ts\"],\n      \"oa-themes/*\": [\"../themes/src/*\"]\n    }\n  },\n  \"include\": [\"src\", \"types\"],\n  \"references\": [{ \"path\": \"../../shared\" }, { \"path\": \"../themes\" }]\n}\n"
  },
  {
    "path": "packages/components/types/emotion.d.ts",
    "content": "import '@emotion/react';\n\nimport type { PlatformTheme } from 'oa-themes';\n\ndeclare module '@emotion/react' {\n  export interface Theme extends PlatformTheme {}\n}\n"
  },
  {
    "path": "packages/components/types/images.d.ts",
    "content": "declare module '*.svg';\ndeclare module '*.png';\ndeclare module '*.jpg';\n"
  },
  {
    "path": "packages/components/types/photoswipe.d.ts",
    "content": "declare module 'photoswipe/lightbox' {\n  import PhotoSwipeLightbox, { PhotoSwipeOptions } from 'photoswipe/dist/types/lightbox/lightbox';\n\n  export { PhotoSwipeOptions };\n  export default PhotoSwipeLightbox;\n}\n"
  },
  {
    "path": "packages/components/vite.config.ts",
    "content": "import react from '@vitejs/plugin-react';\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\nimport svgr from 'vite-plugin-svgr';\n\nimport type { ViteUserConfig } from 'vitest/config';\n\nconst vitestConfig: ViteUserConfig = {\n  test: {\n    environment: 'jsdom',\n    globals: true,\n    setupFiles: './src/test/setup.ts',\n    coverage: {\n      provider: 'v8',\n      reporter: ['text'],\n    },\n    include: ['./src/**/*.test.?(c|m)[jt]s?(x)'],\n    logHeapUsage: true,\n    sequence: {\n      hooks: 'list',\n    },\n  },\n};\nexport default defineConfig({\n  plugins: [react(), svgr()],\n  test: vitestConfig.test,\n});\n"
  },
  {
    "path": "packages/cypress/.gitignore",
    "content": "videos\ncoverage\nscreenshots\nfixtures/seed/*.rej\nbuild\n*.js\ncypress.env.json"
  },
  {
    "path": "packages/cypress/.npmrc",
    "content": "scripts-prepend-node-path=true"
  },
  {
    "path": "packages/cypress/README.md",
    "content": "## Cypress\n\n[Cypress](https://www.cypress.io/) is a next generation front end testing tool built for the modern web. We address the key pain points developers and QA engineers face when testing modern applications.\n\n[Best practices to be followed](https://docs.cypress.io/guides/references/best-practices)\n\n### Folder Structure\n\n- `scripts`: Contains scripts necessary to run the tests.\n  - `paths.ts`: Stores all the paths needed to execute the tests.\n  - `start.ts`: Script to automate the process of running end-to-end (E2E) tests using Cypress CI and Manual\n- `src`: Contains the source code for the Cypress project.\n  - `data`: Holds index.ts which is responsible for managing data.\n  - `fixtures`: Stores data, images, and files used in the tests.\n  - `integration`: Contains spec.ts files which hold the actual test scripts.\n  - `plugins`: Contains Cypress plugins.\n  - `support`: Contains support files for Cypress tests.\n    - `db`: database scripts related(seed, clear, query, delete)\n    - `commands.ts`: Defines general custom Cypress commands.\n    - `commandsUI.ts`: Defines UI-related custom Cypress commands.\n    - `customAssertions.ts`: Contains custom Cypress assertions.\n    - `hooks.ts`: Defines Cypress hooks.\n    - `index.ts`: Index file for exporting all plugins.\n    - `rules.ts`: Contains rules for Cypress.\n  - `utils`: Holds utility functions for the Cypress project.\n\n### Test data SEED\n\nThe seed data is maintained in the `/shared/mocks/data directory`.\n\n### How Tests Run\n\n- Before All: Set up the DB_PREFIX and DB seed.\n- Before Each (Global): Set the DB_PREFIX variable on the platform session storage.\n- Before Each (Local): Perform pre-set actions for the scenario.\n- Main Section of Test: Steps according to the scenario.\n- Assert Section of Test: Validation to the scenario.\n\n### Execution Steps\n\nRunning Cypress E2E Tests\n\n`bun test` to start local environment and open the cypress UI\n"
  },
  {
    "path": "packages/cypress/cypress.config.ts",
    "content": "// cypress.config.ts\nimport { defineConfig } from 'cypress';\n\nimport { SupabaseTestsService } from './src/utils/supabaseTestsService';\n\nexport default defineConfig({\n  defaultCommandTimeout: 15000,\n  watchForFileChanges: true,\n  chromeWebSecurity: false,\n  video: true,\n  reporter: 'junit',\n  reporterOptions: {\n    mochaFile: 'coverage/out-[hash].xml',\n  },\n  downloadsFolder: 'src/downloads',\n  fixturesFolder: 'src/fixtures',\n  screenshotsFolder: 'src/screenshots',\n  videosFolder: 'src/videos',\n  projectId: '4s5zgo',\n  viewportWidth: 1000,\n  viewportHeight: 1000,\n  retries: {\n    runMode: 2,\n    openMode: 0,\n  },\n  e2e: {\n    setupNodeEvents: (on, config) => {\n      const supabaseUrl = config.env.SUPABASE_API_URL;\n      const supabaseKey = config.env.SUPABASE_SERVICE_ROLE_KEY;\n      const tenantId = config.env.TENANT_ID;\n\n      on('task', {\n        log(message) {\n          console.log(message);\n          return null;\n        },\n\n        async 'seed database'() {\n          if (!supabaseUrl || !supabaseKey) {\n            throw new Error('SUPABASE_API_URL and SUPABASE_SERVICE_ROLE_KEY environment variables are required');\n          }\n\n          const supabaseService = new SupabaseTestsService(supabaseUrl, supabaseKey, tenantId);\n          await supabaseService.createStorage(tenantId);\n\n          const profileImages = await supabaseService.seedProfileImages();\n          const { profile_types } = await supabaseService.seedProfileTypes();\n          const { profile_badges } = await supabaseService.seedBadges();\n          await supabaseService.seedUpgradeBadges(profile_badges.data);\n          const { profile_tags } = await supabaseService.seedProfileTags();\n          const { profiles } = await supabaseService.seedAccounts(\n            profile_badges.data,\n            profile_tags.data,\n            profile_types.data,\n            profileImages,\n          );\n\n          await supabaseService.seedMap(profiles);\n\n          const { tags } = await supabaseService.seedTags();\n          await supabaseService.seedQuestions(profiles);\n          await supabaseService.seedNews(profiles, tags);\n          await supabaseService.seedResearch(profiles, tags);\n          await supabaseService.seedLibrary(profiles, tags);\n          await supabaseService.seedTenantSettings();\n          return null;\n        },\n        async 'clear database'() {\n          const supabaseUrl = config.env.SUPABASE_API_URL;\n          const supabaseKey = config.env.SUPABASE_SERVICE_ROLE_KEY;\n          const tenantId = config.env.TENANT_ID;\n\n          const supabaseService = new SupabaseTestsService(supabaseUrl, supabaseKey, tenantId);\n\n          await supabaseService.clearDatabase(\n            [\n              'categories',\n              'comments',\n              'news',\n              'research',\n              'research_updates',\n              'notifications',\n              'notifications_preferences',\n              'profiles',\n              'questions',\n              'projects',\n              'project_steps',\n              'tags',\n              'profile_badges',\n              'profile_badges_relations',\n              'profile_tags',\n              'profile_tags_relations',\n              'profile_types',\n              'upgrade_badge',\n              'tenant_settings',\n            ],\n            tenantId,\n          );\n\n          await supabaseService.clearStorage(tenantId);\n          await supabaseService.deleteAccounts();\n\n          return null;\n        },\n      });\n\n      return config;\n    },\n    baseUrl: 'http://localhost:3456',\n    specPattern: 'src/integration/**/*.{js,jsx,ts,tsx}',\n    supportFile: 'src/support/index.ts',\n    experimentalStudio: true,\n  },\n});\n"
  },
  {
    "path": "packages/cypress/package.json",
    "content": "{\n  \"name\": \"oa-cypress\",\n  \"version\": \"1.0.0\",\n  \"scripts\": {\n    \"start\": \"cross-env PORT=3456 tsx scripts/start.mts\"\n  },\n  \"devDependencies\": {\n    \"@faker-js/faker\": \"^10.1.0\",\n    \"@types/wait-on\": \"^5.3.4\",\n    \"cross-env\": \"^7.0.3\",\n    \"cypress\": \"13.15.1\",\n    \"dotenv\": \"^17.2.0\",\n    \"oa-shared\": \"workspace:*\",\n    \"tsx\": \"^4.21.0\",\n    \"typescript\": \"^5.7.2\",\n    \"wait-on\": \"^9.0.3\"\n  },\n  \"installConfig\": {\n    \"hoistingLimits\": \"workspaces\"\n  },\n  \"dependencies\": {\n    \"dateformat\": \"^5.0.3\"\n  }\n}\n"
  },
  {
    "path": "packages/cypress/scripts/start.mts",
    "content": "import { spawn, spawnSync } from 'child_process';\nimport { config } from 'dotenv';\nimport fs from 'node:fs';\nimport { dirname, resolve } from 'path';\nimport { fileURLToPath } from 'url';\nimport waitOn from 'wait-on';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst PLATFORM_ROOT_DIR = resolve(__dirname, '../../../');\nconst WORKSPACE_DIR = resolve(__dirname, '../');\n\nconst CY_BIN = resolve(WORKSPACE_DIR, 'node_modules/.bin/cypress');\nconst CROSSENV_BIN = resolve(WORKSPACE_DIR, 'node_modules/.bin/cross-env');\nconst BUILD_SERVE_JSON = resolve(PLATFORM_ROOT_DIR, 'build/serve.json');\n\nconst PATHS = {\n  WORKSPACE_DIR,\n  PLATFORM_ROOT_DIR,\n  CY_BIN,\n  CROSSENV_BIN,\n  BUILD_SERVE_JSON,\n};\n\nexport const generateAlphaNumeric = (length: number) => {\n  let result = '';\n  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n  const charactersLength = characters.length;\n  for (let i = 0; i < length; i++) {\n    result += characters.charAt(Math.floor(Math.random() * charactersLength));\n  }\n  return result;\n};\n\nconst e2eEnv = config();\nconfig({ path: '.env.local' });\n\nconst isCi = process.argv.includes('ci');\n// const isProduction = process.argv.includes('prod')\n\n// Prevent unhandled errors being silently ignored\nprocess.on('unhandledRejection', (err) => {\n  console.error('There was an uncaught error', err);\n  process.exitCode = 1;\n});\n/**\n * When running e2e tests with cypress we need to first get the server up and running\n * before launching the test suite. We will seed the DB from within the test suite\n *\n * @argument ci - specify if running in ci (e.g. circleci) to run and record\n * @argument prod - specify to use a production build instead of local development server\n * @example npm run test ci prod\n *\n * TODO: CC - 2021-02-24\n * - DB seeding happens inbetween test suites, but really should happen before/after test\n * scripts start and end (particularly teardown, as it won't be called if tests fail).\n * Possibly could be done with a Cypress.task or similar\n */\nmain()\n  .then(() => process.exit(0))\n  .catch((err) => {\n    console.error(err);\n    process.exit(1);\n  });\n\nasync function main() {\n  // copy endpoints for use in testing\n  const tenantId = process.env.CI_NODE\n  ? `${generateAlphaNumeric(8).toLowerCase()}-node-${process.env.CI_NODE}`\n  : generateAlphaNumeric(8).toLowerCase();\n\n  fs.writeFileSync(\n    'cypress.env.json',\n    JSON.stringify({\n      TENANT_ID: tenantId,\n      CI_NODE: process.env.CI_NODE,\n      RESEND_API_KEY: process.env.RESEND_API_KEY,\n      SUPABASE_API_URL: process.env.SUPABASE_API_URL,\n      SUPABASE_KEY: process.env.SUPABASE_KEY,\n      SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,\n    }),\n  );\n\n  await startAppServer(tenantId);\n  runTests();\n}\n\n/** We need to ensure the platform is up and running before starting tests\n * There are npm packages like start-server-and-test but they seem to have flaky\n * performance in some environments (https://github.com/bahmutov/start-server-and-test/issues/250).\n * Instead manually track via child spawns\n */\nasync function startAppServer(tenantId: string) {\n  const { CROSSENV_BIN } = PATHS;\n  // by default spawns will not respect colours used in stdio, so try to force\n  const crossEnvArgs = `VITE_SITE_VARIANT=test-ci`;\n\n  // run local debug server for testing unless production build specified\n  let serverCmd = `${CROSSENV_BIN} ${crossEnvArgs} BROWSER=none bun start`;\n\n  // create local build if not running on ci (which will have build already generated)\n  if (isCi) {\n    serverCmd = `${CROSSENV_BIN} ${crossEnvArgs} bun run start-ci`;\n  }\n\n  /******************* Run the main commands ******************* */\n  // as the spawn will not terminate create non-async, and just listen to and handle messages\n  // from the methods\n  const child = spawn(serverCmd, {\n    shell: true,\n    stdio: ['pipe', 'pipe', 'inherit'],\n    cwd: PATHS.PLATFORM_ROOT_DIR,\n    env: {\n      ...process.env,\n      VITE_SITE_VARIANT: 'test-ci',\n      TENANT_ID: tenantId,\n    },\n  });\n\n  child.stdout.on('data', (d) => {\n    const msg = d.toString('utf8');\n    console.log(msg);\n    // throw typescript build errors\n    if (msg.includes('Failed to compile')) {\n      // the server will still be running after compile failure (waiting for changes),\n      // so give time for any other messages to come through before exiting manually\n      setTimeout(() => {\n        process.exit(1);\n      }, 2000);\n    }\n  });\n  // do not end function until server responsive on port 3456\n  // give up if not responsive after 5 minutes (assume uncaught error somewhere)\n  const timeout = 5 * 60 * 1000;\n  return waitOn({ resources: ['http-get://localhost:3456'], timeout });\n}\n\nfunction runTests() {\n  console.log(isCi ? 'Start tests' : 'Opening cypress for manual testing');\n  const e = process.env;\n  const { CYPRESS_KEY } = e2eEnv.parsed;\n  const CI_BROWSER = e.CI_BROWSER || 'chrome';\n  const CI_GROUP = e.CI_GROUP || '1x-chrome';\n  // not currently used, but can pass variables accessed by Cypress.env()\n  const CYPRESS_ENV = `VITE_SITE_VARIANT=test-ci`;\n  // use workflow ID so that jobs running in parallel can be assigned to same cypress build\n  // cypress will use this to split tests between parallel runs\n  const buildId = e.CIRCLE_WORKFLOW_ID || generateAlphaNumeric(8);\n\n  // main testing command, depending on whether running on ci machine or interactive local\n  // call with path to bin as to ensure locally installed used\n  const { CY_BIN, CROSSENV_BIN } = PATHS;\n\n  const testCMD = isCi\n    ? `${CY_BIN} run --record --env ${CYPRESS_ENV} --key=${CYPRESS_KEY} --parallel --headless --browser ${CI_BROWSER} --group ${CI_GROUP} --ci-build-id ${buildId}`\n    : `${CY_BIN} open --browser chrome --env ${CYPRESS_ENV}`;\n\n  console.log(`Running cypress with cmd: ${testCMD}`);\n\n  const spawn = spawnSync(`${CROSSENV_BIN} VITE_SITE_VARIANT=test-ci ${testCMD}`, {\n    shell: true,\n    stdio: ['inherit', 'inherit', 'pipe'],\n    cwd: PATHS.WORKSPACE_DIR,\n  });\n  console.log('testing complete with exit code', spawn.status);\n  if (spawn.status === 1) {\n    console.error('error', spawn.stderr.toString());\n  }\n  process.exit(spawn.status);\n}\n"
  },
  {
    "path": "packages/cypress/src/data/index.ts",
    "content": "/**\n * Simple re-export of all the data within the oa-shared mocks\n * Can be imported locally as individual namespaces or combined\n * @example\n * ```\n * import { library } from '../data'\n * ```\n * or\n * ```\n * import { MOCK_DATA } from '../data\n * ```\n *\n **/\n\nimport * as allData from 'oa-shared/mocks/data';\n\nexport const MOCK_DATA = {\n  ...allData,\n};\n"
  },
  {
    "path": "packages/cypress/src/fixtures/searchResults.ts",
    "content": "export const SingaporeStubResponse = [\n  {\n    place_id: 297710747,\n    licence: 'Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright',\n    osm_type: 'relation',\n    osm_id: 536780,\n    boundingbox: ['1.1303611', '1.5143183', '103.5666667', '104.5716696'],\n    lat: '1.357107',\n    lon: '103.8194992',\n    display_name: 'Singapore',\n    class: 'boundary',\n    type: 'administrative',\n    importance: 0.8432767551059058,\n    icon: 'https://nominatim.openstreetmap.org/ui/mapicons/poi_boundary_administrative.p.20.png',\n  },\n];\n"
  },
  {
    "path": "packages/cypress/src/integration/SignIn.spec.ts",
    "content": "import { generateNewUserDetails } from '../utils/TestUtils';\n\ndescribe('[Sign in]', () => {\n  it('[By Anonymous]', () => {\n    cy.step('Reset Password requires email');\n    cy.visit('/sign-in');\n    cy.get('[data-cy=lost-password]').click();\n    cy.get('[data-cy=email]').should('be.visible');\n  });\n});\n\ndescribe('[Reset password]', () => {\n  it('Validate reset password form', () => {\n    const user = generateNewUserDetails();\n    const { email } = user;\n\n    cy.step('Reset Password requires email');\n    cy.visit('/sign-in');\n    cy.get('[data-cy=lost-password]').click({ force: true });\n    cy.wait(1000);\n    cy.get('[data-cy=email]').type(email);\n    cy.get('[data-cy=submit]').click();\n\n    cy.step('Reset Password should go back');\n    cy.get('[data-cy=go-back]').should('be.visible');\n    cy.wait(1000);\n    cy.get('[data-cy=go-back]').click({ force: true });\n    cy.get('[data-cy=email]').should('be.visible');\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/SignUp.spec.ts",
    "content": "import { FRIENDLY_MESSAGES } from 'oa-shared';\n\nimport { generateNewUserDetails } from '../utils/TestUtils';\n\ndescribe('[User sign-up]', () => {\n  beforeEach(() => {\n    cy.visit('/sign-up');\n  });\n\n  describe('[New user]', () => {\n    it('Validate sign-up form', () => {\n      cy.step('Email is invalid');\n      cy.get('[data-cy=email]').click();\n      cy.get('[data-cy=email]').clear();\n      cy.get('[data-cy=email]').type('a');\n      cy.get('[data-cy=consent]').uncheck().check();\n      cy.contains(FRIENDLY_MESSAGES['auth/invalid-email']).should('be.visible');\n\n      cy.step('Password is too short');\n      cy.get('[data-cy=password]').click();\n      cy.get('[data-cy=password]').clear();\n      cy.get('[data-cy=password]').type('a');\n      cy.get('[data-cy=consent]').uncheck().check();\n      cy.contains(FRIENDLY_MESSAGES['sign-up/password-short']).should('be.visible');\n\n      cy.step('Password confirmation does not match');\n      cy.get('[data-cy=password]').click();\n      cy.get('[data-cy=password]').clear();\n      cy.get('[data-cy=password]').type('a');\n      cy.get('[data-cy=confirm-password]').click();\n      cy.get('[data-cy=confirm-password]').clear();\n      cy.get('[data-cy=confirm-password]').type('b');\n      cy.get('[data-cy=consent]').uncheck().check();\n      cy.contains(FRIENDLY_MESSAGES['sign-up/password-mismatch']).should('be.visible');\n    });\n  });\n\n  describe('[Cannot duplicate existing user]', () => {\n    it('Prevents duplicate email', () => {\n      const user = generateNewUserDetails();\n      const { email, password } = user;\n\n      cy.signUpNewUser(user);\n      cy.logout();\n      cy.fillSignupForm(email, password);\n      cy.get('[data-cy=submit]').click();\n      cy.get('[data-cy=\"TextNotification: failure\"]').contains(FRIENDLY_MESSAGES['generic-error']).should('be.visible');\n    });\n  });\n\n  describe('[Update existing auth details]', () => {\n    it('Updates username and password', () => {\n      const user = generateNewUserDetails();\n      const { email, username, password } = user;\n      cy.signUpNewUser(user);\n\n      const tenantId = Cypress.env('TENANT_ID');\n      const newEmail = `delivered+${username}-super_cool+${tenantId}@resend.dev`;\n      const newPassword = '<dfbss73DF';\n\n      cy.step('Go to settings page');\n      cy.visit('/settings');\n\n      cy.get('[data-cy=\"tab-Account\"]').click();\n\n      cy.step('Update Email');\n      cy.get('[data-cy=\"accordionContainer\"]').click({ multiple: true });\n      cy.get('[data-cy=\"changeEmailContainer\"]').contains(`Current email address: ${email}`).should('be.visible');\n      cy.get('[data-cy=\"newEmail\"]').clear().type(newEmail);\n      cy.get('[data-cy=\"password\"]').clear().type(password);\n      cy.get('[data-cy=\"changeEmailSubmit\"]').click();\n      cy.contains('[data-cy=toast-message]', FRIENDLY_MESSAGES['auth/email-changed']).should('be.visible');\n\n      cy.step('Update Password');\n      cy.get('[data-cy=\"accordionContainer\"]').click({ multiple: true });\n      cy.get('[data-cy=\"oldPassword\"]').clear().type(password);\n      cy.get('[data-cy=\"newPassword\"]').clear().type(newPassword);\n      cy.get('[data-cy=\"repeatNewPassword\"]').clear().type(newPassword);\n      cy.get('[data-cy=\"changePasswordSubmit\"]').click();\n      cy.contains('[data-cy=toast-message]', FRIENDLY_MESSAGES['auth/password-changed']).should('be.visible');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/academy.spec.ts",
    "content": "import { Page } from '../utils/TestUtils';\n\ndescribe('[Academy]', () => {\n  describe('[List instructions]', () => {\n    it('[By Everyone]', () => {\n      cy.visit(Page.ACADEMY);\n      cy.step('Load instructions from another github repo');\n      cy.get('iframe').should('have.attr', 'src').and('contain', 'https://onearmy.github.io/academy');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/common.spec.ts",
    "content": "import { MOCK_DATA } from '../data';\nimport { UserMenuItem } from '../support/commandsUi';\nimport { getTenantUser } from '../utils/TestUtils';\n\ndescribe('[Common]', () => {\n  it('[Default Page]', () => {\n    cy.step('The home page is /academy');\n    cy.visit('/').url().should('include', '/academy');\n  });\n\n  it('[Not-Found Page]', () => {\n    const unknownUrl = '/abcdefghijklm';\n    cy.visit(unknownUrl);\n    cy.get('[data-test=\"NotFound: Heading\"]')\n      .contains(`Nada, page not found 💩`)\n      .should('be.visible');\n    cy.get('a').contains('home page').should('have.attr', 'href').and('eq', '/');\n  });\n\n  it('[Page Navigation]', () => {\n    cy.visit('/library');\n    cy.wait(2000);\n\n    cy.step('Go to Academy page');\n    cy.get('[data-cy=page-link]').contains('Academy').click();\n    cy.wait(2000);\n    cy.url().should('include', '/academy');\n\n    cy.step('Go to library page');\n    cy.get('[data-cy=page-link]').contains('Library').click();\n    cy.wait(2000);\n    cy.url().should('include', '/library');\n  });\n\n  it('[Forbidden Page]', () => {\n    cy.step('When not given page details');\n    cy.visit('/forbidden');\n    cy.contains(\"You don't have the right permissions\");\n    cy.contains('Report the problem');\n\n    cy.visit('/forbidden?page=news-create');\n    cy.contains('This is a new feature');\n    cy.contains('I want to use it');\n  });\n\n  describe('[User feedback button]', () => {\n    it('[Desktop]', () => {\n      cy.visit('/library');\n      cy.wait(2000);\n      cy.get('[data-cy=feedback]').should('contain', 'Report a Problem');\n      cy.get('[data-cy=feedback]')\n        .should('have.attr', 'href')\n        .and('contain', '/library?sort=MostUsefulLastWeek');\n    });\n\n    it('[Mobile]', () => {\n      cy.viewport('iphone-6');\n\n      cy.visit('/library');\n      cy.wait(2000);\n      cy.get('[data-cy=feedback]').should('contain', 'Problem?');\n      cy.get('[data-cy=feedback]')\n        .should('have.attr', 'href')\n        .and('contain', '/library?sort=MostUsefulLastWeek');\n    });\n  });\n\n  describe('[User Menu]', () => {\n    it('[By Anonymous]', () => {\n      cy.step('Login and Join buttons are available');\n      cy.visit('/library');\n      cy.wait(2000);\n      cy.get('[data-cy=login]').should('be.visible');\n      cy.get('[data-cy=join]').should('be.visible');\n      cy.get('[data-cy=user-menu]').should('not.exist');\n    });\n\n    it('[By Authenticated]', () => {\n      const subscriber = getTenantUser(MOCK_DATA.users.subscriber);\n      \n      cy.step('Login and Join buttons are unavailable to logged-in users');\n      cy.signIn(subscriber.email, subscriber.password);\n      cy.visit('/library');\n      cy.wait(2000);\n      cy.get('[data-cy=login]', { timeout: 20000 }).should('not.exist');\n      cy.get('[data-cy=join]').should('not.exist');\n\n      cy.step('User Menu is toggle');\n      cy.toggleUserMenuOn();\n      cy.get('[data-cy=user-menu-list]').should('be.visible');\n      cy.toggleUserMenuOff();\n      cy.get('[data-cy=user-menu-list]').should('not.exist');\n\n      cy.step('Go to Profile');\n      cy.clickMenuItem(UserMenuItem.Profile);\n      cy.url().should('include', `/u/${subscriber.username}`);\n\n      cy.step('Go to Settings');\n      cy.toggleUserMenuOn();\n      cy.clickMenuItem(UserMenuItem.Settings);\n      cy.url().should('include', 'settings');\n\n      cy.step('Logout the session');\n      cy.toggleUserMenuOn();\n      cy.clickMenuItem(UserMenuItem.LogOut);\n      cy.wait(2000);\n      cy.get('[data-cy=login]', { timeout: 20000 }).should('be.visible');\n      cy.get('[data-cy=join]').should('be.visible');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/library/discussions.spec.ts",
    "content": "// This is basically an identical set of steps to the discussion tests for\n// questions, projects and research. Any changes here should be replicated there.\n\nimport { MOCK_DATA } from '../../data';\nimport { generateAlphaNumeric, generateNewUserDetails } from '../../utils/TestUtils';\n\nlet randomId;\n\ndescribe('[Library.Discussions]', () => {\n  beforeEach(() => {\n    randomId = generateAlphaNumeric(8).toLowerCase();\n  });\n\n  it('shows existing comments', () => {\n    const project = MOCK_DATA.projects[0];\n    cy.visit(`/library/${project.slug}`);\n    cy.get(`[data-cy=comment-text]`).contains('First comment');\n    cy.get('[data-cy=show-replies]').first().click();\n    cy.get(`[data-cy=\"ReplyItem\"]`).contains('First Reply');\n  });\n\n  it('allows authenticated users to contribute to discussions', () => {\n    const commenter = generateNewUserDetails();\n    const project = MOCK_DATA.projects[2];\n    const projectPath = `/library/${project.slug}`;\n\n    const newComment = `An interesting project. I'll be making myself. ${commenter.username}`;\n    const updatedNewComment = `An interesting project. ${randomId}. I'll be making myself. Thanks ${commenter.username}!`;\n    const newReply = `So glad for this guide. What does everyone else think? - ${commenter.username}`;\n    const updatedNewReply = `Community - what else? Yours truly ${commenter.username}`;\n    const secondReply = `Quick reply. ${commenter.username}. ${randomId}`;\n\n    cy.signUpNewUser(commenter);\n\n    cy.step(\"Can't add comment with an incomplete profile\");\n    cy.visit(projectPath);\n\n    cy.get('[data-cy=comments-form]').should('not.exist');\n    cy.get('[data-cy=comments-incomplete-profile-prompt]').should('be.visible');\n\n    cy.step('Can add comment when profile is complete');\n    cy.completeUserProfile(commenter.username);\n    cy.visit(projectPath);\n    cy.get('[data-cy=comments-incomplete-profile-prompt]').should('not.exist');\n\n    cy.get('[data-cy=follow-button]').should('contain', 'Follow Comments');\n    cy.addComment(newComment);\n    cy.wait(2000);\n    cy.reload();\n    cy.get('[data-cy=follow-button]').should('contain', 'Following Comments');\n\n    cy.step('Can edit their comment');\n    cy.editDiscussionItem('CommentItem', newComment, updatedNewComment);\n\n    cy.step('Another user can add reply');\n    const replier = generateNewUserDetails();\n    cy.logout();\n    cy.signUpCompletedUser(replier);\n    cy.visit(projectPath);\n    cy.addReply(newReply);\n    cy.wait(1000);\n    cy.contains('Comments');\n\n    cy.step('Can edit their reply');\n    cy.editDiscussionItem('ReplyItem', newReply, updatedNewReply);\n    cy.step('Another user can leave a reply');\n\n    cy.step('First commenter can respond');\n    cy.logout();\n    cy.signIn(commenter.email, commenter.password);\n\n    cy.step('Notification generated for reply from replier');\n    cy.expectNewNotification({\n      content: updatedNewReply,\n      path: projectPath,\n      username: replier.username,\n    });\n    cy.get('[data-cy=highlighted-comment]').contains(updatedNewReply);\n\n    cy.visit(projectPath);\n\n    cy.step('Can add reply');\n    cy.addReply(secondReply);\n\n    cy.step('Can delete their comment');\n    cy.deleteDiscussionItem('CommentItem', updatedNewComment);\n\n    cy.step('Replies still show for deleted comments');\n    cy.get('[data-cy=\"deletedComment\"]').should('be.visible');\n    cy.get('[data-cy=OwnReplyItem]').contains(secondReply);\n\n    cy.step('Can delete their reply');\n    cy.deleteDiscussionItem('ReplyItem', secondReply);\n\n    cy.step('Notification generated for replier from commenter reply');\n    cy.logout();\n    cy.signIn(replier.email, replier.password);\n    cy.expectNewNotification({\n      content: secondReply,\n      path: projectPath,\n      username: commenter.username,\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/library/read.spec.ts",
    "content": "import { DifficultyLevelRecord } from 'oa-shared';\nimport { users } from 'oa-shared/mocks/data';\nimport { MOCK_DATA } from '../../data';\nimport { getTenantUser } from '../../utils/TestUtils';\n\nconst library = MOCK_DATA.projects;\nconst label = MOCK_DATA.questions.length === 1 ? 'item' : 'items';\n\ndescribe('[Library]', () => {\n  const demoAdmin = getTenantUser(users.admin);\n  const demoUser = getTenantUser(users.subscriber);\n\n  beforeEach(() => {\n    cy.visit('/library');\n  });\n\n  describe('[List Library Projects]', () => {\n    it('[By Everyone]', () => {\n      const project = library[0];\n\n      cy.step('Has expected page title');\n      cy.title().should('include', `Library`);\n      cy.step('Displays Item count');\n      cy.contains(`${MOCK_DATA.projects.filter((p) => !p.deleted && p.moderation === 'accepted').length} ${label}`);\n\n      cy.step('Can search for items');\n      cy.get('[data-cy=library-search-box]').click().type('brick');\n      cy.get('[data-cy=card]').its('length').should('be.eq', 1);\n\n      cy.step('All basic info displayed on each card');\n      const projectUrl = `/library/${project.slug}`;\n      // const coverFileRegex = /howto-intro.jpg/\n\n      cy.get('[data-cy=card]').within(() => {\n        cy.contains(project.title).should('be.visible');\n        // cy.get('img').should('have.attr', 'src').and('match', coverFileRegex)\n        cy.get('[data-cy=Username]').contains(users.settings_workplace_new.username);\n        cy.get('[data-cy=category]').contains('Machines');\n        cy.get('a').should('have.attr', 'href').and('eq', projectUrl);\n      });\n\n      cy.step('Can clear search');\n      cy.get('[data-cy=close]').click();\n      cy.get('[data-cy=card]').its('length').should('be.above', 1);\n\n      cy.step('Can select a category to limit items displayed');\n      cy.get('[data-cy=category]').contains('Moulds');\n      cy.get('[data-cy=CategoryHorizonalList]').within(() => {\n        cy.contains('Machines').click();\n      });\n      cy.get('[data-cy=CategoryHorizonalList-Item-active]');\n      cy.get('[data-cy=category]').contains('Machines');\n      cy.get('[data-cy=category]').contains('Moulds').should('not.exist');\n    });\n  });\n\n  describe('[Read a project]', () => {\n    const itemUrl = '/library/make-an-interlocking-brick';\n    // const coverFileRegex = /brick-12-1.jpg/\n\n    describe('[By Everyone]', () => {\n      it('[See all info]', () => {\n        const item = library[0];\n\n        cy.step('Old url pattern redirects to the new location');\n        cy.visit(itemUrl);\n\n        cy.step('Edit button is not available');\n        cy.get('[data-cy=edit]').should('not.exist');\n\n        cy.step('Project has basic info');\n        cy.title().should('eq', `${item.title} - Library - Test Site`);\n\n        cy.step('All metadata visible');\n        cy.get('[data-cy=ContentStatistics-views]').contains(/\\d/);\n        cy.get('[data-cy=ContentStatistics-useful]').contains(/\\d/);\n        cy.get('[data-cy=ContentStatistics-comments]').contains(/\\d/);\n        cy.get('[data-cy=ContentStatistics-steps]').contains(/\\d/);\n\n        cy.get('[data-cy=library-basis]').then(($summary) => {\n          expect($summary).to.contain(users.settings_workplace_new.username, 'Author');\n          expect($summary).to.contain('edit', 'Edit');\n          expect($summary).to.contain('Make an interlocking brick', 'Title');\n          expect($summary).to.contain('show you how to make a brick using the injection machine', 'Description');\n          expect($summary).to.contain('3-4 weeks', 'Duration');\n          expect($summary).to.contain(DifficultyLevelRecord.hard, 'Difficulty');\n\n          // TODO: add proper image and file download testing. We could probably do it now that the buckets are being created on for tests.\n          // expect($summary.find('img[alt=\"project cover image\"]'))\n          //   .to.have.attr('src')\n          //   .match(coverFileRegex)\n        });\n        cy.wait(2000);\n        cy.get('[data-cy=tag-list]').then(($tagList) => {\n          expect($tagList).to.contain('product');\n          expect($tagList).to.contain('exhibition');\n        });\n        // cy.get('[data-cy=file-download-counter]').should(\n        //   'contain',\n        //   '1,234 downloads',\n        // )\n\n        cy.step('Breadcrumbs work');\n        cy.get('[data-cy=breadcrumbsItem]').first().should('contain', 'Library');\n        cy.get('[data-cy=breadcrumbsItem]').first().children().should('have.attr', 'href').and('equal', `/library`);\n\n        cy.get('[data-cy=breadcrumbsItem]').eq(1).should('contain', item.category!.name);\n\n        cy.get('[data-cy=breadcrumbsItem]').eq(2).should('contain', item.title);\n\n        // cy.step('Download file button should redirect to sign in')\n        // cy.get('div[data-tooltip-content=\"Login to download\"]')\n        //   .first()\n        //   .click()\n        //   .url()\n        //   .should('include', 'sign-in')\n\n        cy.step('All steps are shown');\n        cy.visit(itemUrl);\n        cy.get('[data-cy^=step_]').should('have.length.above', 1);\n\n        cy.step('All step info is shown');\n        cy.get('[data-cy=step_2]').within(($step) => {\n          // const pic1Regex = /brick-12-1.jpg/\n          // const pic3Regex = /brick-12.jpg/\n          expect($step).to.contain('2', 'Step #');\n          expect($step).to.contain('Explore the possibilities!', 'Title');\n          expect($step).to.contain(`more for a partition or the wall`, 'Description');\n\n          // Commented out until https://github.com/ONEARMY/community-platform/issues/3462\n          //\n          //   cy.step('Step image is updated on thumbnail click')\n          //   cy.get('[data-cy=\"active-image\"]')\n          //     .should('have.attr', 'src')\n          //     .and('match', pic1Regex)\n          //   cy.get('[data-cy=thumbnail]:eq(2)').click()\n          //   cy.get('[data-cy=\"active-image\"]')\n          //     .should('have.attr', 'src')\n          //     .and('match', pic3Regex)\n        });\n\n        cy.step(`Comment functionality prompts user to login`);\n        cy.get(`[data-cy=\"comments-login-prompt\"]`).should('be.visible');\n\n        cy.step('Video embed exists');\n        cy.get('[data-testid=\"VideoPlayer\"]').within(() => {\n          cy.get('iframe').should('have.attr', 'src').and('include', 'youtube');\n        });\n        // This fails in firefox due to cross security, simply check url\n        // .should(iframe => expect(iframe.contents().find('video')).to.visible)\n\n        cy.step('Project should appear on users profile');\n        cy.get('[data-cy=Username]').first().click();\n        cy.get('[data-testid=library-stat]').should('exist');\n        cy.get('[data-cy=ContribTab]').click();\n        cy.get('[data-testid=\"library-contributions\"]').within(() => {\n          cy.contains(item.title);\n        });\n      });\n    });\n  });\n\n  describe('[Moderation]', () => {\n    it('[Feedback]', () => {\n      cy.visit('/library/rubbish-title');\n\n      cy.step('Feedback not visible when logged out');\n      cy.get('[data-cy=\"moderationstatus-improvements-needed\"]');\n      cy.get('[data-cy=\"moderationFeedback\"]').should('not.exist');\n\n      cy.step('Feedback is visible to content owner');\n      cy.signIn(demoUser.email, demoUser.password);\n      cy.visit('/library/rubbish-title');\n      cy.get('[data-cy=\"moderationFeedback\"]');\n\n      cy.step('Feedback is visible to admins');\n      cy.logout();\n      cy.signIn(demoAdmin.email, demoAdmin.password);\n      cy.visit('/library/rubbish-title');\n      cy.get('[data-cy=\"moderationFeedback\"]');\n    });\n  });\n\n  describe('[Fail to find a project]', () => {\n    const notFoundUrl = `/library/this-project-does-not-exist`;\n\n    it('[Redirects to search]', () => {\n      cy.visit(notFoundUrl);\n      cy.get('[data-test=\"NotFound: Heading\"').should('be.visible');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/library/seo.spec.ts",
    "content": "import { MOCK_DATA } from '../../data';\n\ndescribe('[Library]', () => {\n  describe('[SEO Metadata]', () => {\n    const { slug, title, description } = MOCK_DATA.projects[0];\n\n    const pageTitle = `${title} - Library - Test Site`;\n\n    it('[Populates title and description tags]', () => {\n      cy.visit(`/library/${slug}`);\n      // General\n      cy.title().should('eq', pageTitle);\n      cy.get('meta[name=\"description\"]').should('have.attr', 'content', description);\n\n      // OpenGraph (facebook)\n      cy.get('meta[property=\"og:title\"]').should('have.attr', 'content', pageTitle);\n      cy.get('meta[property=\"og:description\"]').should('have.attr', 'content', description);\n\n      // Twitter\n      cy.get('meta[name=\"twitter:title\"]').should('have.attr', 'content', pageTitle);\n      cy.get('meta[name=\"twitter:description\"]').should('have.attr', 'content', description);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/library/write.spec.ts",
    "content": "import { faker } from '@faker-js/faker';\nimport { DifficultyLevelRecord } from 'oa-shared';\n\nimport { MOCK_DATA } from '../../data';\nimport { generateAlphaNumeric, generateNewUserDetails, getTenantUser } from '../../utils/TestUtils';\n\nimport type { DifficultyLevel } from 'oa-shared';\n\nlet randomId;\nconst admin = getTenantUser(MOCK_DATA.users.admin);\n\ndescribe('[Library]', () => {\n  beforeEach(() => {\n    cy.visit('/library');\n    randomId = generateAlphaNumeric(8).toLowerCase();\n  });\n  type Category = 'brainstorm' | 'exhibition' | 'product';\n  type Duration = '<1 week' | '1-2 weeks' | '3-4 weeks';\n\n  const selectCategory = (category: Category) => {\n    cy.selectTag(category, '[data-cy=category-select]');\n  };\n  const selectTimeDuration = (duration: Duration) => {\n    cy.selectTag(duration, '[data-cy=time-select]');\n  };\n  const selectDifficultLevel = (difficultLevel: DifficultyLevel) => {\n    cy.selectTag(difficultLevel, '[data-cy=difficulty-select]');\n  };\n\n  const fillStep = (stepNumber: number, title: string, description: string, images: string[], videoUrl?: string) => {\n    cy.step(`Filling step ${stepNumber}`);\n    cy.get(`[data-cy=step_${stepNumber - 1}]`).should('be.visible');\n    cy.get(`[data-cy=step_${stepNumber - 1}]`).within(($step) => {\n      checkWhitespaceTrim('step-title');\n\n      cy.get('[data-cy=step-title]').clear().invoke('val', title).blur({ force: true });\n\n      cy.get('[data-cy=step-title]').should('have.value', title);\n\n      checkWhitespaceTrim('step-description');\n\n      cy.get('[data-cy=step-description]').clear().invoke('val', description).blur({ force: true });\n\n      cy.get('[data-cy=step-description]').should('have.value', description);\n\n      if (videoUrl) {\n        cy.step('Adding Video Url');\n        cy.get('[data-cy=step-videoUrl]').clear().type(videoUrl);\n      } else {\n        cy.step('Uploading pics');\n        const hasExistingPics = Cypress.$($step).find('[data-cy=delete-step-img]').length > 0;\n        if (hasExistingPics) {\n          cy.wrap($step)\n            .find('[data-cy=delete-image]')\n            .each(($deleteButton) => {\n              cy.wrap($deleteButton).click();\n            });\n        }\n\n        images.forEach((image, index) => {\n          cy.get(`[data-cy=new-image-upload]`).find(':file').selectFile(image, { force: true });\n        });\n      }\n    });\n  };\n\n  const deleteStep = (stepNumber: number) => {\n    const stepIndex = stepNumber - 1;\n    cy.step(`Deleting step [${stepNumber}]`);\n    cy.get(`[data-cy=step_${stepIndex}]:visible`, { timeout: 20000 }).find('[data-cy=delete-step]').click();\n    cy.get('[data-cy=confirm]').click();\n  };\n\n  const checkWhitespaceTrim = (element: string) => {\n    cy.step(`Check whitespace trim for [${element}]`);\n    cy.get(`[data-cy=${element}]`).clear().invoke('val', '  Test for trailing whitespace  ').blur();\n\n    cy.get(`[data-cy=${element}]`).should('have.value', 'Test for trailing whitespace');\n    cy.get(`[data-cy=${element}]`).clear();\n  };\n\n  describe('[Create a project]', () => {\n    const creator = getTenantUser(MOCK_DATA.users.howto_creator);\n\n    const expected = {\n      _createdBy: creator.username,\n      _deleted: false,\n      category: 'Moulds',\n      description: 'After creating, the project will be deleted',\n      moderation: 'awaiting-moderation',\n      difficulty_level: DifficultyLevelRecord.medium,\n      time: '1-2 weeks',\n      title: `Create a project test ${randomId}`,\n      slug: `create-a-project-test-${randomId}`,\n      previousSlugs: ['qwerty', `create-a-project-test-${randomId}`],\n      fileLink: 'http://google.com/',\n      files: [],\n      total_downloads: 0,\n      tags: {\n        EOVeOZaKKw1UJkDIf3c3: true,\n      },\n      cover_image: {\n        contentType: 'image/jpeg',\n        name: 'howto-intro.jpg',\n        size: 19897,\n        type: 'image/jpeg',\n      },\n      steps: [\n        {\n          images: [\n            {\n              contentType: 'image/jpeg',\n              name: 'howto-step-pic1.jpg',\n              size: 19410,\n              type: 'image/jpeg',\n            },\n            {\n              contentType: 'image/jpeg',\n              name: 'howto-step-pic2.jpg',\n              size: 20009,\n              type: 'image/jpeg',\n            },\n          ],\n          text: 'Description for step 1. This description should be between the minimum and maximum description length',\n          title: 'Step 1 is easy',\n        },\n        {\n          text: faker.lorem.sentences(50).slice(0, 1000).trim(),\n          title: 'A long title that is the total characters limit of',\n          videoURL: 'https://www.youtube.com/watch?v=Os7dREQ00l4',\n        },\n        {\n          images: [],\n          text: 'Description for step 3. This description should be between the minimum and maximum description length',\n          title: 'Step 3 is easy',\n          videoURL: 'https://www.youtube.com/watch?v=Os7dREQ00l4',\n        },\n      ],\n    };\n\n    it('[By Authenticated]', () => {\n      const { category, description, difficulty_level, fileLink, slug, steps, time, title, total_downloads } = expected;\n      const imagePaths = ['src/fixtures/images/howto-step-pic1.jpg', 'src/fixtures/images/howto-step-pic2.jpg'];\n      const categoryGuidanceMain = 'Cover image should show the fully built mould';\n\n      cy.get('[data-cy=\"sign-up\"]');\n      cy.signIn(creator.email, creator.password);\n      cy.get('[data-cy=\"MemberBadge-member\"]').should('be.visible');\n      cy.visit('/library');\n\n      cy.step('Access the create project page');\n      cy.get('a[href=\"/library/create\"]').should('be.visible');\n      cy.get('[data-cy=create-project]:visible').click();\n      cy.contains('Add your project').should('be.visible');\n\n      cy.step('Warn if title has less than minimum required characters');\n      cy.fillIntroTitle('qwer');\n      cy.contains(`Should be more than ${5} characters`).should('be.visible');\n\n      cy.step('Cannot be published yet');\n      cy.get('[data-cy=submit]').click();\n      cy.get('[data-cy=errors-container]');\n\n      cy.step('Warn if title is identical with the existing ones');\n      cy.fillIntroTitle('Make glass-like beams');\n\n      checkWhitespaceTrim('intro-description');\n      cy.get('[data-cy=intro-description]').type(description);\n\n      cy.get('[data-cy=draft]').click();\n\n      cy.wait(1000);\n      cy.contains('Error: A project with this name already exists').should('be.visible');\n\n      cy.step('A basic draft is created');\n      cy.fillIntroTitle(`qwerty ${randomId}`);\n\n      cy.get('[data-cy=draft]').click();\n      cy.contains('Draft saved!').should('be.visible');\n      cy.wait(1000);\n      cy.contains('View draft').should('be.visible').click();\n\n      const firstSlug = `/library/qwerty-${randomId}`;\n      cy.url().should('include', firstSlug);\n      cy.contains('Draft');\n\n      cy.step(\"Drafted project should not appear on user's profile\");\n      cy.visit('/u/' + creator.displayName);\n      cy.get('[data-testid=library-stat]').should('not.exist');\n      cy.get('[data-cy=ContribTab]').should('not.exist');\n\n      cy.step('Back to completing the project');\n      cy.visit(firstSlug);\n      cy.get('[data-cy=edit]').click();\n      checkWhitespaceTrim('intro-title');\n\n      cy.step('Fill up the intro');\n      cy.fillIntroTitle(title);\n      cy.selectTag('howto_testing');\n\n      cy.step('Select a category and see further guidance');\n      cy.contains(categoryGuidanceMain).should('not.exist');\n      selectCategory(category as Category);\n      cy.contains(categoryGuidanceMain).should('be.visible');\n\n      selectTimeDuration(time as Duration);\n      selectDifficultLevel(difficulty_level as DifficultyLevel);\n\n      cy.get('[data-cy=fileLink]').type(fileLink);\n      cy.step('Upload a cover for the intro');\n      cy.get('[data-cy=\"image-input\"]').find('input[type=\"file\"]').selectFile('src/fixtures/images/howto-intro.jpg', { force: true });\n      cy.get('[data-cy=\"image-input\"]').parent().find('[data-cy=delete-image]').should('exist');\n\n      fillStep(1, steps[0].title, steps[0].text, imagePaths);\n      fillStep(2, steps[2].title, steps[2].text, [], steps[2].videoURL);\n\n      cy.step('Move step two down to step three');\n      cy.get(`[data-cy=step_${1}]:visible`).find('[data-cy=move-step-down]').click();\n      fillStep(2, steps[1].title, steps[1].text, [], steps[1].videoURL);\n\n      cy.step('Add extra step');\n      cy.get('[data-cy=add-step]').click();\n\n      cy.step('Can remove extra steps');\n      deleteStep(4);\n      cy.screenClick();\n\n      cy.step('A full draft was saved');\n      cy.get('[data-cy=draft]').click();\n      cy.get('[data-cy=errors-container]').should('not.exist');\n\n      cy.contains('View draft').should('be.visible').click();\n\n      cy.step('A full draft can be submitted for review');\n      cy.get('[data-cy=edit]').click();\n      cy.get('[data-cy=errors-container]').should('not.exist');\n      cy.get('[data-cy=submit]').click();\n\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project').click();\n      cy.url().should('include', `/library/${slug}`);\n\n      cy.step('Project was created correctly');\n      cy.get('[data-cy=file-download-counter]').contains(total_downloads).should('be.visible');\n      cy.get('[data-cy=project-title]').should('contain', title);\n      cy.get('[data-cy=project-description]').should('contain', description);\n      cy.get('[data-cy=category]').should('contain', category);\n      cy.get('[data-cy=difficulty-level]').should('contain', difficulty_level);\n\n      cy.get('[data-cy=follow-button]').first().should('contain', 'Following Comments');\n\n      steps.forEach((step, index) => {\n        cy.get(`[data-cy=step_${index + 1}]`)\n          .find('[data-cy=step-title]')\n          .should('contain', step.title);\n        cy.get(`[data-cy=step_${index + 1}]`)\n          .find('[data-cy=step-text]')\n          .should('contain', step.text);\n      });\n\n      cy.step('Can access the project with the previous slug');\n      cy.visit(firstSlug);\n      cy.contains(title);\n\n      // Won't show on profile yet as project needs admin approval\n      // cy.step('Published project should appear on users profile')\n      // cy.visit('/u/' + creator.displayName)\n      // cy.get('[data-testid=library-stat]').contains('1')\n      // cy.get('[data-cy=ContribTab]').click()\n      // cy.get('[data-cy=\"library-contributions\"]').should('be.visible')\n    });\n\n    it('[By Anonymous]', () => {\n      cy.step('Ask users to login before creating a project');\n      cy.visit('/library');\n      cy.get('[data-cy=create-project]').should('not.exist');\n      cy.get('[data-cy=sign-up]').should('be.visible');\n\n      cy.visit('/library/create');\n      cy.url().should('contain', 'sign-in');\n    });\n\n    it('[By Incomplete Profile User]', () => {\n      const user = generateNewUserDetails();\n      cy.signUpNewUser(user);\n\n      cy.step(\"Can't add to library\");\n      cy.visit('/library');\n      cy.get('[data-cy=create-project]').should('not.exist');\n      cy.get('[data-cy=complete-profile-project]').should('be.visible');\n\n      cy.visit('/library/create');\n      cy.get('[data-cy=incomplete-profile-message]').should('be.visible');\n      cy.get('[data-cy=intro-title]').should('not.exist');\n    });\n\n    it('[Warning on leaving page]', () => {\n      cy.signIn(creator.email, creator.password);\n      cy.visit('/library');\n      cy.get('[data-cy=loader]').should('not.exist');\n      cy.step('Access the create project');\n      cy.get('a[href=\"/library/create\"]').should('be.visible');\n      cy.get('[data-cy=create-project]:visible').click();\n      cy.fillIntroTitle(expected.title);\n      cy.get('[data-cy=page-link][href*=\"/library\"]').click();\n      cy.get('[data-cy=\"Confirm.modal: Cancel\"]').click();\n      cy.url().should('match', /\\/library\\/create$/);\n\n      cy.step('Clear title input');\n      cy.get('[data-cy=intro-title]').clear().blur({ force: true });\n      cy.get('[data-cy=page-link][href*=\"/library\"]').click();\n      cy.url().should('match', /\\/library?/);\n    });\n\n    it('[Edit project - Replace images]', () => {\n      const randomId = generateAlphaNumeric(8).toLowerCase();\n      const initialTitle = `${randomId} Project for image edit`;\n      const slug = `${randomId}-project-for-image-edit`;\n      const updatedDescription = 'Updated with new images';\n      const category = 'Moulds';\n      const time = '1-2 weeks';\n      const difficulty = 'Medium';\n\n      cy.signIn(creator.email, creator.password);\n\n      cy.step('Create a project with images');\n      cy.visit('/library/create');\n      cy.fillIntroTitle(initialTitle);\n      cy.get('[data-cy=intro-description]').type('Initial description');\n      selectCategory(category as Category);\n      selectTimeDuration(time as Duration);\n      selectDifficultLevel(difficulty as DifficultyLevel);\n\n      cy.step('Upload cover image');\n      cy.get('[data-cy=\"image-input\"]').find('input[type=\"file\"]').selectFile('src/fixtures/images/howto-intro.jpg', { force: true });\n      cy.get('[data-cy=\"image-input\"]').parent().find('[data-cy=delete-image]').should('exist');\n\n      cy.step('Add step with images');\n      cy.get('[data-cy=step_0]').within(() => {\n        cy.get('[data-cy=step-title]').clear().type('Step with images').blur();\n        cy.get('[data-cy=step-description]')\n          .clear()\n          .type('Description for step 2. This description should be between the minimum and maximum description length')\n          .blur();\n        cy.get('[data-cy=new-image-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n        cy.get('[data-cy=delete-image]').should('exist');\n      });\n\n      cy.get('[data-cy=step_1]').within(() => {\n        cy.get('[data-cy=step-title]').clear().type('Second step').blur();\n        cy.get('[data-cy=step-description]')\n          .clear()\n          .type('Description for step 2. This description should be between the minimum and maximum description length')\n          .blur();\n        cy.get('[data-cy=new-image-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n        cy.get('[data-cy=delete-image]').should('exist');\n      });\n\n      cy.get('[data-cy=step_2]').within(() => {\n        cy.get('[data-cy=step-title]').clear().type('Third step').blur();\n        cy.get('[data-cy=step-description]')\n          .clear()\n          .type('Description for step 3. This description should be between the minimum and maximum description length')\n          .blur();\n        cy.get('[data-cy=new-image-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n        cy.get('[data-cy=delete-image]').should('exist');\n      });\n\n      cy.get('[data-cy=submit]').click();\n\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project').click();\n      cy.url().should('include', `/library/${slug}`);\n\n      cy.step('Edit project and replace images');\n      cy.get('[data-cy=edit]').click();\n      cy.get('[data-cy=intro-description]').clear().type(updatedDescription);\n\n      cy.step('Replace cover image');\n      cy.get('[data-cy=\"image-input\"]').parent().find('[data-cy=delete-image]').click({ force: true });\n      cy.get('[data-cy=\"image-input\"]').find('input[type=\"file\"]').selectFile('src/fixtures/images/howto-step-pic2.jpg', { force: true });\n      cy.get('[data-cy=\"image-input\"]').parent().find('[data-cy=delete-image]').should('exist');\n\n      cy.step('Replace step image');\n      cy.get('[data-cy=step_0]').within(() => {\n        cy.get('[data-cy=delete-image]').first().click({ force: true });\n        cy.get('[data-cy=new-image-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic2.jpg', { force: true });\n        cy.get('[data-cy=delete-image]').should('exist');\n      });\n\n      cy.get('[data-cy=submit]').click();\n\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project').click();\n      cy.url().should('include', `/library/${slug}`);\n      cy.contains(updatedDescription);\n    });\n\n    it('[Create and edit project with files]', () => {\n      const randomId = generateAlphaNumeric(8).toLowerCase();\n      const title = `${randomId} Project with files`;\n      const slug = `${randomId}-project-with-files`;\n      const category = 'Moulds';\n      const time = '1-2 weeks';\n      const difficulty = 'Medium';\n\n      cy.signIn(creator.email, creator.password);\n\n      cy.step('Create a project with file upload');\n      cy.visit('/library/create');\n      cy.fillIntroTitle(title);\n      cy.get('[data-cy=intro-description]').type('Project with downloadable files');\n      selectCategory(category as Category);\n      selectTimeDuration(time as Duration);\n      selectDifficultLevel(difficulty as DifficultyLevel);\n\n      cy.get('[data-cy=\"image-input\"]').find('input[type=\"file\"]').selectFile('src/fixtures/images/howto-intro.jpg', { force: true });\n      cy.get('[data-cy=\"image-input\"]').parent().find('[data-cy=delete-image]').should('exist');\n\n      cy.step('Upload a file');\n      cy.get('[id=file-input]').selectFile('src/fixtures/files/Example.pdf', { force: true });\n      cy.get('[data-cy=remove-file]').should('exist');\n\n      cy.step('Add steps with video URLs');\n      cy.get('[data-cy=step_0]').within(() => {\n        cy.get('[data-cy=step-title]').clear().type('First step').blur();\n        cy.get('[data-cy=step-description]')\n          .clear()\n          .type('Description for step 1. This description should be between the minimum and maximum description length')\n          .blur();\n        cy.get('[data-cy=step-videoUrl]').clear().type('https://www.youtube.com/watch?v=Os7dREQ00l4');\n      });\n\n      cy.get('[data-cy=step_1]').within(() => {\n        cy.get('[data-cy=step-title]').clear().type('Second step').blur();\n        cy.get('[data-cy=step-description]')\n          .clear()\n          .type('Description for step 2. This description should be between the minimum and maximum description length')\n          .blur();\n        cy.get('[data-cy=step-videoUrl]').clear().type('https://www.youtube.com/watch?v=Os7dREQ00l4');\n      });\n\n      cy.get('[data-cy=step_2]').within(() => {\n        cy.get('[data-cy=step-title]').clear().type('Third step').blur();\n        cy.get('[data-cy=step-description]')\n          .clear()\n          .type('Description for step 3. This description should be between the minimum and maximum description length')\n          .blur();\n        cy.get('[data-cy=step-videoUrl]').clear().type('https://www.youtube.com/watch?v=Os7dREQ00l4');\n      });\n\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project').click();\n      cy.url().should('include', `/library/${slug}`);\n      cy.get('[data-cy=downloadButton]').should('be.visible');\n\n      cy.step('Edit project and replace file');\n      cy.get('[data-cy=edit]').click();\n\n      cy.step('Remove old file and upload new one');\n      cy.get('[data-cy=remove-file]').click();\n      cy.get('[id=file-input]').selectFile('src/fixtures/files/Example.pdf', { force: true });\n      cy.get('[data-cy=remove-file]').should('exist');\n\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View project').click();\n      cy.url().should('include', `/library/${slug}`);\n      cy.get('[data-cy=downloadButton]').should('be.visible');\n    });\n\n    it('[Delete button is visible]', () => {\n      cy.signIn(admin.email, admin.password);\n\n      cy.visit('/library/qwerty/edit');\n\n      cy.step('Delete button should be visible to project author');\n      cy.get('[data-cy=\"Project: delete button\"]').should('be.visible');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/news/discussions.spec.ts",
    "content": "// This is basically an identical set of steps to the discussion tests for\n// questions, projects and research. Any changes here should be replicated there.\n\nimport { MOCK_DATA } from '../../data';\nimport { generateAlphaNumeric, generateNewUserDetails } from '../../utils/TestUtils';\n\nlet randomId;\n\ndescribe('[News.Discussions]', () => {\n  beforeEach(() => {\n    randomId = generateAlphaNumeric(8).toLowerCase();\n  });\n\n  it('shows existing comments', () => {\n    const news = MOCK_DATA.news[0];\n    cy.visit(`/news/${news.slug}`);\n    cy.get(`[data-cy=comment-text]`).contains('First comment');\n    cy.get('[data-cy=show-replies]').first().click();\n    cy.get(`[data-cy=\"ReplyItem\"]`).contains('First Reply');\n  });\n\n  it('allows authenticated users to contribute to discussions', () => {\n    const commenter = generateNewUserDetails();\n    const news = MOCK_DATA.news[2];\n    const newsPath = `/news/${news.slug}`;\n\n    const newComment = `An interesting post. Glad here the good news. ${commenter.username}`;\n    const updatedNewComment = `An interesting post. ${randomId}. Glad here the good news. Love, ${commenter.username}`;\n    const newReply = `Thanks Community. I agree! - ${commenter.username}`;\n    const updatedNewReply = `Anyone else? Yours truly ${commenter.username}`;\n    const secondReply = `Quick reply. ${commenter.username}. ${randomId}`;\n\n    cy.signUpNewUser(commenter);\n\n    cy.step(\"Can't add comment with an incomplete profile\");\n    cy.visit(newsPath);\n\n    cy.get('[data-cy=comments-form]').should('not.exist');\n    cy.get('[data-cy=comments-incomplete-profile-prompt]').should('be.visible');\n\n    cy.step('Can add comment when profile is complete');\n    cy.completeUserProfile(commenter.username);\n    cy.visit(newsPath);\n    cy.get('[data-cy=comments-incomplete-profile-prompt]').should('not.exist');\n\n    cy.get('[data-cy=follow-button]').should('contain', 'Follow Comments');\n    cy.addComment(newComment);\n    cy.wait(2000);\n    cy.reload();\n    cy.get('[data-cy=follow-button]').should('contain', 'Following Comments');\n\n    cy.step('Can edit their comment');\n    cy.editDiscussionItem('CommentItem', newComment, updatedNewComment);\n\n    cy.step('Another user can add reply');\n    const replier = generateNewUserDetails();\n    cy.logout();\n    cy.signUpCompletedUser(replier);\n    cy.visit(newsPath);\n    cy.wait(1000);\n    cy.get('[data-cy=CommentItem]').contains(updatedNewComment).should('be.visible');\n    cy.addReply(newReply);\n    cy.contains('Comments');\n\n    cy.step('Can edit their reply');\n    cy.editDiscussionItem('ReplyItem', newReply, updatedNewReply);\n    cy.step('Another user can leave a reply');\n\n    cy.step('First commenter can respond');\n    cy.logout();\n    cy.signIn(commenter.email, commenter.password);\n\n    cy.step('Notification generated for reply from replier');\n    cy.expectNewNotification({\n      content: updatedNewReply,\n      path: newsPath,\n      username: replier.username,\n    });\n    cy.get('[data-cy=highlighted-comment]').contains(updatedNewReply);\n\n    cy.visit(newsPath);\n\n    cy.step('Can add reply');\n    cy.addReply(secondReply);\n\n    cy.step('Can delete their comment');\n    cy.deleteDiscussionItem('CommentItem', updatedNewComment);\n\n    cy.step('Replies still show for deleted comments');\n    cy.get('[data-cy=\"deletedComment\"]').should('be.visible');\n    cy.get('[data-cy=OwnReplyItem]').contains(secondReply);\n\n    cy.step('Can delete their reply');\n    cy.deleteDiscussionItem('ReplyItem', secondReply);\n\n    cy.step('Notification generated for replier from commenter reply');\n    cy.logout();\n    cy.signIn(replier.email, replier.password);\n    cy.expectNewNotification({\n      content: secondReply,\n      path: newsPath,\n      username: commenter.username,\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/news/read.spec.ts",
    "content": "import { MOCK_DATA } from '../../data';\n\nconst news = MOCK_DATA.news[0];\n\ndescribe('[News.Read]', () => {\n  describe('[List news]', () => {\n    it('[By Everyone]', () => {\n      cy.visit(`/news/`);\n      cy.step('Has expected page title');\n      cy.title().should('include', `News`);\n\n      cy.step('News displays expected fields');\n      cy.get('[data-cy=news-list-item]')\n        .first()\n        .within(() => {\n          cy.get('[data-cy=news-list-item-title]');\n          cy.get('[data-cy=news-list-item-summary]');\n          cy.get('[data-cy=category]');\n        });\n    });\n  });\n\n  describe('[Individual news]', () => {\n    it('[By Everyone]', () => {\n      const { body, slug, title } = news;\n\n      const pageTitle = `${title} - News - Test Site`;\n\n      cy.step('Can visit news');\n      cy.visit(`/news/${slug}`);\n\n      cy.step('All metadata visible');\n      cy.get('[data-cy=ContentStatistics-views]').contains(/\\d/);\n      cy.get('[data-cy=ContentStatistics-following]').contains(/\\d/);\n      cy.get('[data-cy=ContentStatistics-comments]').contains(/\\d/);\n\n      cy.step('[Populates title, SEO and social tags]');\n      cy.title().should('eq', pageTitle);\n      cy.get('meta[name=\"description\"]').should('have.attr', 'content', body);\n\n      // OpenGraph (facebook)\n      cy.get('meta[property=\"og:title\"]').should('have.attr', 'content', pageTitle);\n      cy.get('meta[property=\"og:description\"]').should('have.attr', 'content', body);\n\n      // Twitter\n      cy.get('meta[name=\"twitter:title\"]').should('have.attr', 'content', pageTitle);\n      cy.get('meta[name=\"twitter:description\"]').should('have.attr', 'content', body);\n      cy.step('Website is clickable');\n      cy.contains('a', 'OneArmy').should('have.attr', 'href', 'https://www.onearmy.earth/');\n\n      cy.step('Breadcrumbs work');\n      cy.get('[data-cy=breadcrumbsItem]').first().should('contain', 'News');\n      cy.get('[data-cy=breadcrumbsItem]').first().children().should('have.attr', 'href').and('equal', `/news`);\n\n      cy.get('[data-cy=breadcrumbsItem]').eq(1).should('contain', title);\n\n      cy.step('News images are clickable');\n\n      cy.wait(500);\n\n      // Check content images\n      cy.get('[data-cy=news-body] img').first().should('have.css', 'cursor', 'pointer').click();\n\n      // Lightbox should open\n      cy.get('.pswp').should('be.visible');\n\n      // Wait for PhotoSwipe animation/initialization\n      cy.wait(1000);\n\n      // Close lightbox\n      cy.get('button[title=\"Close\"]').click();\n      cy.get('.pswp').should('not.exist');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/news/search.spec.ts",
    "content": "describe('[News.Search]', () => {\n  beforeEach(() => {\n    cy.visit('/news');\n  });\n\n  describe('[By Everyone]', () => {\n    it('Searches', () => {\n      // cy.step('Can search for items')\n      // cy.get('[data-cy=news-search-box]').clear().type(`deal`)\n      // cy.url().should('include', 'q=deal')\n      // cy.url().should('include', 'sort=MostRelevant')\n      // cy.get('[data-cy=news-list-item]').its('length').should('be.eq', 1)\n      // cy.get('[data-cy=HightedText]').contains('deal')\n      // cy.step('Can clear search')\n      // cy.get('[data-cy=close]').click()\n      // cy.url().should('not.include', 'q=deal')\n      // cy.get('[data-cy=news-search-box]').should('be.empty')\n      // cy.get('[data-cy=news-list-item]').its('length').should('be.above', 1)\n      // cy.step('should remove search filter after back navigation')\n      // cy.get('[data-cy=news-search-box]').clear().type(`deal`)\n      // cy.wait(2000)\n      // cy.get('[data-cy=news-list-item]').click()\n      // cy.go('back')\n      // cy.url().should('not.include', 'q=deal')\n    });\n\n    // it('should load more news', () => {\n    //   cy.get('[data-cy=news-list-item]:eq(21)').should('not.exist')\n    //   cy.get('[data-cy=load-more]').click()\n    //   cy.get('[data-cy=news-list-item]:eq(21)').should('exist')\n    // })\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/news/write.spec.ts",
    "content": "import { users } from 'oa-shared/mocks/data';\n\nimport { generateAlphaNumeric, getTenantUser } from '../../utils/TestUtils';\n\nlet initialRandomId;\n\ndescribe('[News.Write]', () => {\n  describe('[Create a news item]', () => {\n    beforeEach(() => {\n      initialRandomId = generateAlphaNumeric(8).toLowerCase();\n    });\n\n    it('[By Authenticated]', () => {\n      const initialTitle = `${initialRandomId} Amazing new thing`;\n      const initialExpectedSlug = `${initialRandomId}-amazing-new-thing`;\n      const initialNewsBodyOne = 'Yo.';\n      const initialNewsBodyTwo = 'HiHi!';\n      const initialNewsBodyThree = 'We did good.';\n      const initialSummary = `${initialNewsBodyOne} ${initialNewsBodyTwo} ${initialNewsBodyThree}`;\n      const category = 'Moulds';\n      const tag1 = 'product';\n      const tag2 = 'workshop';\n      const updatedTitle = `Still an amazing thing ${initialRandomId}`;\n      const updatedExpectedSlug = `still-an-amazing-thing-${initialRandomId}`;\n      const updatedNewsBody = 'PLUS sparkles!';\n      const updatedSummary = `${updatedNewsBody} ${initialNewsBodyOne} ${initialNewsBodyTwo}`;\n\n      cy.visit('/news');\n      const user = getTenantUser(users.admin);\n      cy.signIn(user.email, user.password);\n\n      cy.step(\"Can't add news from main page\");\n      cy.visit('/news');\n      cy.get('[data-cy=create-news]').should('not.exist');\n\n      cy.step('Can go direct to url');\n      cy.visit('/news/create');\n      cy.get('[data-cy=field-title]', { timeout: 20000 });\n\n      cy.step('Cannot be published when empty');\n      cy.get('[data-cy=submit]').click();\n      cy.get('[data-cy=errors-container]');\n\n      cy.step('Add image');\n      cy.get('[data-cy=heroImage-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n\n      cy.step('Can add draft news');\n      cy.get('[data-cy=field-title]').clear().type(initialTitle).blur({ force: true });\n\n      cy.addToMarkdownField(initialNewsBodyOne);\n      cy.addToMarkdownField(initialNewsBodyTwo);\n      cy.addToMarkdownField(initialNewsBodyThree);\n\n      cy.get('[data-cy=draft]').click();\n      cy.wait(2000);\n      cy.url().should('include', `/news/${initialExpectedSlug}`);\n\n      cy.step('Can get to drafts');\n      cy.visit('/news');\n      cy.contains(initialTitle).should('not.exist');\n      cy.get('[data-cy=my-drafts]').first().click({ force: true });\n      cy.contains(initialTitle).click();\n\n      cy.step('Shows draft news');\n      cy.get('[data-cy=draft-tag]').should('be.visible');\n      cy.contains(initialNewsBodyOne);\n\n      cy.step('Submit news');\n      cy.get('[data-cy=edit]').click();\n\n      cy.selectTag(category, '[data-cy=category-select]');\n      cy.selectTag(tag1, '[data-cy=\"tag-select\"]');\n      cy.selectTag(tag2, '[data-cy=\"tag-select\"]');\n\n      cy.get('[data-cy=errors-container]').should('not.exist');\n      cy.wait(2000);\n      cy.get('[data-cy=submit]').click();\n\n      cy.wait(2000);\n      cy.url().should('include', `/news/${initialExpectedSlug}`);\n\n      cy.step('All news fields shown');\n      cy.visit('/news');\n      cy.get('[data-cy=news-list-item-summary]').first().contains(initialSummary);\n      cy.get('[data-cy=news-list-item]').contains(initialTitle).click();\n\n      cy.contains(initialTitle);\n      cy.contains(initialNewsBodyOne);\n      cy.contains(initialNewsBodyTwo);\n      cy.contains(initialNewsBodyThree);\n      cy.contains(category);\n      cy.contains(tag1);\n      cy.contains(tag2);\n      // contains images\n\n      cy.step('All ready for a discussion');\n      cy.get('[data-cy=DiscussionTitle]').contains('Start the discussion');\n      cy.get('[data-cy=follow-button]').contains('Following Comments');\n\n      cy.step('Edit fields');\n      cy.wait(2000);\n      cy.get('[data-cy=edit]').click();\n      cy.wait(2000);\n      cy.url().should('include', `/news/${initialExpectedSlug}/edit`);\n\n      cy.get('[data-cy=field-title]').clear().type(updatedTitle).blur();\n      cy.get('.mdxeditor-root-contenteditable').type('{selectAll}{del}');\n\n      cy.addToMarkdownField(updatedNewsBody);\n      cy.addToMarkdownField(initialNewsBodyOne);\n      cy.addToMarkdownField(initialNewsBodyTwo);\n      cy.addToMarkdownField(initialNewsBodyThree);\n\n      cy.step('Replace hero image');\n      cy.get('[data-cy=existingHeroImage]').should('exist');\n      cy.get('[data-cy=existingHeroImage]').find('[data-cy=delete-image]').click({force: true});\n      cy.get('[data-cy=heroImage-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic2.jpg', { force: true });\n      cy.get('[data-cy=delete-image]').should('exist');\n\n      cy.step('Updated news details shown');\n      cy.wait(2000);\n      cy.get('[data-cy=submit]').click();\n      cy.wait(2000);\n      cy.url().should('include', `/news/${updatedExpectedSlug}`);\n      cy.contains(updatedNewsBody);\n\n      cy.contains(updatedTitle);\n      cy.contains(updatedNewsBody);\n      cy.contains(initialNewsBodyOne);\n      cy.contains(initialNewsBodyTwo);\n      cy.contains(initialNewsBodyThree);\n      cy.get('[data-cy=follow-button]').first().should('contain', 'Following Comments');\n\n      cy.step('Can access the news with the previous slug');\n      cy.visit(`/news/${initialExpectedSlug}`);\n      cy.contains(updatedTitle);\n\n      cy.step('All updated fields visible on list');\n      cy.visit('/news');\n      cy.contains(updatedSummary);\n      cy.contains(updatedTitle);\n      cy.contains(category);\n\n    });\n\n    it('[Profile badge restricts visibility]', () => {\n      const title = `${initialRandomId} Profile badge news`;\n      const expectedSlug = `${initialRandomId}-profile-badge-news`;\n      const newsBody = 'only news';\n\n      cy.visit('/news');\n      const user = getTenantUser(users.admin);\n      cy.signIn(user.email, user.password);\n\n      cy.step('Create a news item');\n      cy.visit('/news/create');\n      cy.get('[data-cy=field-title]', { timeout: 20000 });\n      cy.get('[data-cy=field-title]').clear().type(title).blur({ force: true });\n      cy.get('[data-cy=heroImage-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n      cy.addToMarkdownField(newsBody);\n      cy.selectTag('Moulds', '[data-cy=category-select]');\n      cy.wait(2000);\n      cy.get('[data-cy=submit]').click();\n      cy.wait(2000);\n      cy.url().should('include', `/news/${expectedSlug}`);\n\n      cy.step('Can add profile badge');\n      cy.visit(`/news/${expectedSlug}/edit`);\n      cy.wait(1000);\n      cy.selectTag('PRO', '[data-cy=profileBadge-select]');\n      cy.get('[data-cy=submit]').click().url().should('include', `/news/${expectedSlug}`);\n      cy.get('[data-cy=profileBadge]').contains('only news');\n\n      cy.step('Not visible to logged out users');\n      cy.wait(1000);\n      cy.logout();\n      cy.reload();\n      cy.url().should('include', `/sign-in?returnUrl=%2Fnews%2F${expectedSlug}`);\n\n      cy.step('Not visible on the list view');\n      cy.visit('/news');\n      cy.contains(title).should('not.exist');\n\n      cy.step(\"Logged in user (who is not an admin) can't view item\");\n      cy.signUpNewUser();\n      cy.visit(`/news/${expectedSlug}`);\n      cy.reload();\n      cy.url().should('include', `/news`);\n      cy.url().should('not.include', expectedSlug);\n    });\n\n    it('[By Anonymous]', () => {\n      cy.step('Ask users to login before creating a news');\n      cy.visit('/news');\n      cy.get('[data-cy=create-news]').should('not.exist');\n\n      cy.visit('/news/create');\n      cy.url().should('contain', '/sign-in?returnUrl=%2Fnews%2Fcreate');\n    });\n\n    // it('[Admin]', () => {\n    // Should check an admin can edit other's content\n    // })\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/notifications.spec.ts",
    "content": "describe('[Notifications]', () => {\n  it('email preferences can be set', () => {\n    cy.signUpCompletedUser();\n    cy.visit('/settings');\n\n    cy.step('All ticked be default');\n    cy.get('[data-cy=tab-Notifications]').click();\n    cy.get('[data-cy=SupabaseNotifications-field-comments]').invoke('prop', 'indeterminate', true);\n    cy.get('[data-cy=SupabaseNotifications-field-comments]').click();\n    cy.get('[data-cy=SupabaseNotifications-field-replies]').invoke('prop', 'indeterminate', true);\n    cy.get('[data-cy=SupabaseNotifications-field-replies]').click();\n\n    cy.get('[data-cy=save-notifications-preferences]').click();\n\n    cy.contains('Preferences updated');\n    cy.get('[data-cy=SupabaseNotifications-field-comments]').invoke('prop', 'indeterminate', false);\n    cy.get('[data-cy=SupabaseNotifications-field-replies]').invoke('prop', 'indeterminate', false);\n\n    cy.step('Changing messaging updates preferences form');\n    cy.get('[data-cy=messages-link]').contains('Stop receiving messages').click();\n\n    cy.get('[data-cy=isContactable-true]').click({ force: true });\n    cy.saveSettingsForm();\n\n    cy.get('[data-cy=tab-Notifications]').click();\n    cy.get('[data-cy=messages-link]').contains('Start receiving messages');\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/profile.spec.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport { MESSAGE_MAX_CHARACTERS } from '../../../../src/pages/User/constants';\nimport { contact } from '../../../../src/pages/User/labels';\nimport { MOCK_DATA } from '../data';\nimport { UserMenuItem } from '../support/commandsUi';\nimport { generateNewUserDetails, getTenantUser } from '../utils/TestUtils';\n\nconst profile_views = getTenantUser(MOCK_DATA.users.profile_views);\nconst subscriber = getTenantUser(MOCK_DATA.users.subscriber);\nconst eventReader = getTenantUser(MOCK_DATA.users.event_reader);\nconst workspacePopulated = getTenantUser(MOCK_DATA.users.settings_workplace_new);\nconst workspaceEmpty = getTenantUser(MOCK_DATA.users.settings_workplace_empty);\n\ndescribe('[Profile]', () => {\n  beforeEach(() => {\n    cy.visit('/');\n  });\n\n  describe('[By Anonymous]', () => {\n    it('[Can view all public profile information]', () => {\n      cy.step('Go to Profile');\n      cy.visit(`/u/${eventReader.username}`);\n      cy.title().should('eq', `${eventReader.displayName} - Profile - Test Site`);\n\n      cy.get('[data-cy=userDisplayName]').contains(eventReader.username);\n      cy.get('[data-testid=library-stat]').contains('1');\n      cy.get('[data-testid=research-stat]').should('exist');\n\n      cy.step('Cannot see profile views');\n      cy.get('[data-testid=profile-views-stat]').should('not.exist');\n\n      cy.get('[data-cy=emptyProfileMessage]').should('not.exist');\n    });\n  });\n\n  describe('[By User]', () => {\n    it('[User directed to own profile]', () => {\n      const user = generateNewUserDetails();\n      cy.signUpNewUser(user);\n      cy.setProfileUsername(user.username);\n      cy.visit('/');\n\n      cy.step('Go to Profile');\n      cy.clickMenuItem(UserMenuItem.Profile);\n      cy.wait(1000);\n      cy.url().should('include', `/u/${user.username}`);\n      cy.get('[data-cy=SpaceProfile]').should('not.exist');\n      cy.get('[data-cy=MemberProfile]').should('be.visible');\n      cy.get('.beta-tester-feature').should('not.exist');\n      cy.get('[data-cy=emptyProfileMessage]').should('be.visible');\n    });\n\n    it('[Contact form]', () => {\n      const contactee = generateNewUserDetails();\n\n      cy.step('Can sign-up and have a contact form');\n      cy.signUpNewUser(contactee);\n      cy.setProfileUsername(contactee.username);\n      cy.visit(`/u/${contactee.username}`);\n      cy.get('[data-cy=contact-tab]').click();\n      cy.get('[data-cy=\"UserContactForm-Available\"]');\n\n      cy.step(\"Logged out people can see that they're contactable\");\n      cy.logout();\n      cy.wait(2000);\n      cy.visit(`/u/${contactee.username}`);\n      cy.get('[data-cy=contact-tab]').click();\n      cy.get('[data-cy=\"UserContactNotLoggedIn\"]');\n\n      cy.step('Other users can contact people');\n      const contacter = generateNewUserDetails();\n      cy.signUpNewUser(contacter);\n      cy.setProfileUsername(contacter.username);\n      cy.visit(`/u/${contactee.username}`);\n      cy.get('[data-cy=contact-tab]').click();\n      cy.get('[data-cy=\"UserContactForm\"]').should('be.visible');\n      cy.contains(`${contact.title} ${contactee.username}`).should('be.visible');\n\n      cy.step('Form errors without a message');\n      cy.get('[data-cy=contact-submit]').click();\n      cy.contains('This field is required').should('be.visible');\n\n      cy.step('Contact form will send');\n      const message = faker.lorem.sentences(50).slice(0, MESSAGE_MAX_CHARACTERS).trim();\n\n      cy.get('[data-cy=name]').type('Bob');\n      cy.get('[data-cy=message]').invoke('val', message).blur({ force: true });\n      cy.get('[data-cy=contact-submit]').click();\n      cy.contains(contact.successMessage);\n\n      cy.step('Can opt-out of being contacted');\n      cy.logout();\n      cy.signIn(contactee.email, contactee.password);\n      cy.completeUserProfile(contactee.username);\n      cy.get('[data-cy=PublicContactSection]').should('be.visible');\n      cy.get('[data-cy=isContactable-true]').click({ force: true });\n      cy.saveSettingsForm();\n      cy.get('[data-cy=isContactable-false]');\n\n      cy.step('No contact tab visible for contactee');\n      cy.visit(`/u/${contactee.username}`);\n      cy.get('[data-cy=contact-tab]').should('not.exist');\n\n      cy.step('No contact tab visible for logged out users');\n      cy.logout();\n      cy.visit(`/u/${contactee.username}`);\n      cy.get('[data-cy=contact-tab]').should('not.exist');\n\n      cy.step('No contact tab visible for other users');\n      cy.signIn(contacter.email, contacter.password);\n      cy.visit(`/u/${contactee.username}`);\n      cy.get('[data-cy=contact-tab]').should('not.exist');\n\n      cy.step('Contact tab shows when website link is present');\n      cy.logout();\n      cy.signIn(contactee.email, contactee.password);\n      cy.visit('/settings');\n      cy.get('[data-cy=website').clear().type('https://bbc.co.uk');\n      cy.saveSettingsForm();\n\n      cy.visit(`/u/${contactee.username}`);\n      cy.get('[data-cy=contact-tab]').click();\n      cy.get('[data-cy=UserContactWrapper]');\n      cy.get('[data-cy=\"UserContactForm-NotAvailable\"]');\n      cy.get('[data-cy=\"UserContactForm\"]').should('not.exist');\n      cy.get('[data-cy=\"profile-website\"]').should('have.attr', 'href', `https://bbc.co.uk`);\n\n      cy.step('Contact tab links shows for everyone else');\n      cy.logout();\n      cy.visit(`/u/${contactee.username}`);\n      cy.get('[data-cy=\"UserContactForm-NotAvailable\"]').should('not.exist');\n    });\n\n    it('[Can see contribution data for workspaces]', () => {\n      cy.signIn(subscriber.email, subscriber.password);\n\n      cy.step('Can go to contribution data');\n      cy.visit(`/u/${workspacePopulated.username}`);\n      cy.get('[data-cy=ContribTab]').click();\n    });\n\n    it('[Tabs hidden without contributions]', () => {\n      cy.signIn(subscriber.email, subscriber.password);\n\n      cy.step('Ensure hidden with no contributions');\n      cy.visit(`/u/${workspaceEmpty.username}`);\n      cy.get('[data-cy=MemberProfile]').should('not.exist');\n      cy.get('[data-cy=SpaceProfile]').should('be.visible');\n\n      cy.get('[data-cy=ContribTab]').should('not.exist');\n      cy.get('[data-cy=ImpactTab]').should('not.exist');\n    });\n\n    it('[Cannot see profile views or member history without premium tier]', () => {\n      const user = generateNewUserDetails();\n      cy.signUpNewUser(user);\n      cy.visit(`/u/${profile_views.username}`);\n      cy.get('[data-testid=profile-views-stat]').should('not.exist');\n      cy.get('[data-cy=MemberHistory]').should('not.exist');\n    });\n\n    it('should display questions count on profile tab', () => {\n      cy.signIn(subscriber.email, subscriber.password);\n\n      cy.visit(`/u/${subscriber.username}`);\n\n      cy.get('[data-testid=questions-link]').should('be.visible');\n      cy.get('[data-testid=questions-stat]')\n        .should('be.visible')\n        .invoke('text')\n        .and('match', /^Questions: \\d+$/);\n    });\n\n    it('should navigate to questions page when clicking questions count on profile tab', () => {\n      cy.signIn(subscriber.email, subscriber.password);\n\n      cy.visit(`/u/${subscriber.username}`);\n\n      cy.get('[data-testid=questions-link]').click();\n      cy.url().should('include', `questions`);\n    });\n\n    it('should show questions in contributions tab', () => {\n      cy.signIn(subscriber.email, subscriber.password);\n\n      cy.visit(`/u/${subscriber.username}`);\n\n      cy.get('[data-cy=ContribTab]').click();\n      cy.get('[data-testid=\"question-contributions\"]').should('be.visible');\n      cy.get('[data-testid=\"question-contributions\"]').contains('The first test question?').should('be.visible');\n    });\n\n    it('should link to question page when question in clicked in contributions tab', () => {\n      cy.signIn(subscriber.email, subscriber.password);\n\n      cy.visit(`/u/${subscriber.username}`);\n      cy.get('[data-cy=ContribTab]').click();\n\n      cy.get('[data-cy=\"the-first-test-question-link\"]').click();\n      cy.wait(2000);\n      cy.url().should('include', `/questions/the-first-test-question?utm_source=user-profile`);\n      cy.get('[data-cy=\"question-title\"]').should('be.visible').contains('The first test question?');\n    });\n  });\n  describe('badges', () => {\n    it('should be shown across the platform', () => {\n      cy.step('On profile');\n      cy.visit(`/u/${subscriber.username}`);\n      cy.get('[data-testid=\"badge_pro\"]').contains('PRO');\n      cy.get('[data-testid=\"Username: pro badge\"]');\n\n      cy.step('On project pages');\n      cy.get('[data-cy=\"ContribTab\"]').click();\n      cy.get('[data-testid=\"library-link\"]').first().click();\n      cy.get('[data-cy=library-basis]').get('[data-testid=\"Username: pro badge\"]');\n\n      cy.step('On research pages');\n      cy.visit(`/u/${subscriber.username}`);\n      cy.get('[data-cy=\"ContribTab\"]').click();\n      cy.get('[data-testid=\"research-link\"]').first().click();\n      cy.get('[data-testid=\"Username: pro badge\"]');\n\n      cy.step('On question pages');\n      cy.visit(`/u/${subscriber.username}`);\n      cy.get('[data-cy=\"ContribTab\"]').click();\n      cy.get('[data-testid=\"questions-link\"]').first().click();\n      cy.get('[data-cy=question-body]').get('[data-testid=\"Username: pro badge\"]');\n      cy.get('[data-cy=comments-section]').get('[data-testid=\"Username: pro badge\"]');\n    });\n  });\n\n  describe('[Upgrade Badge Button]', () => {\n    it('[Should not show Go PRO button when user has pro badge]', () => {\n      cy.step('User with pro badge should not see upgrade button');\n      cy.signIn(subscriber.email, subscriber.password);\n      cy.visit(`/u/${subscriber.username}`);\n\n      cy.step('Verify pro badge is visible');\n      cy.get('[data-testid=\"badge_pro\"]').should('exist');\n\n      cy.step('Verify Go PRO button is not visible');\n      cy.get('[data-cy=\"UpgradeBadge\"]').should('not.exist');\n    });\n\n    it('[Should show Go PRO button when user does not have pro badge]', () => {\n      cy.intercept('GET', '/api/upgrade-badges').as('getUpgradeBadges');\n\n      const newUser = generateNewUserDetails();\n      cy.signUpNewUser(newUser);\n\n      cy.step('Set user as workspace to be eligible for PRO badge');\n      cy.visit('/settings');\n      cy.get('[data-cy=tab-Profile]').click();\n      cy.get('[data-cy=workspace]').click();\n      cy.get('[data-cy=username]').clear().type(newUser.username);\n      cy.setSettingImage('avatar', 'userImage');\n      cy.setSettingImage('profile-cover-1', 'coverImages-0');\n      cy.setSettingBasicUserInfo({\n        displayName: newUser.username,\n        description: 'A workspace profile',\n      });\n      cy.saveSettingsForm();\n\n      cy.step('Navigate to own profile');\n      cy.visit(`/u/${newUser.username}`);\n\n      cy.wait(2000);\n\n      cy.step('Verify Go PRO button is visible for user without badge');\n      cy.get('[data-cy=\"UpgradeBadge\"]', { timeout: 15000 }).should('be.visible');\n      cy.get('[data-cy=\"UpgradeBadge\"]').should('contain', 'Go PRO');\n\n      cy.step('Verify badge is not shown');\n      cy.get('[data-testid=\"badge_pro\"]').should('not.exist');\n    });\n\n    it('[Should not show Go PRO button when viewing another user profile]', () => {\n      const newUser = generateNewUserDetails();\n      cy.signUpNewUser(newUser);\n\n      cy.step('View another user profile');\n      cy.visit(`/u/${subscriber.username}`);\n\n      cy.step('Verify Go PRO button is not visible on other profiles');\n      cy.get('[data-cy=\"UpgradeBadge\"]').should('not.exist');\n    });\n  });\n});\n\ndescribe('[By Premium Tier User]', () => {\n  it('[Displays other information]', () => {\n    cy.signIn(subscriber.email, subscriber.password);\n    cy.visit(`/u/${profile_views.username}`);\n    cy.step('Displays view count for profile with views');\n    cy.get('[data-testid=profile-views-stat]').contains(/Views: \\d+/);\n    cy.step('Displays member history info');\n    cy.get('[data-cy=MemberHistory]').contains('Member since');\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/profileList.spec.ts",
    "content": "import { MOCK_DATA } from '../data';\nimport { generateNewUserDetails, getTenantUser } from '../utils/TestUtils';\n\nconst subscriber = getTenantUser(MOCK_DATA.users.subscriber);\n\ndescribe('[ProfileList Modal]', () => {\n  const question = MOCK_DATA.questions[0];\n  const { slug } = question;\n\n  it('[Opens the useful voters modal]', () => {\n    cy.signIn(subscriber.email, subscriber.password);\n    cy.visit(`/questions/${slug}`);\n    cy.wait(1000);\n    cy.get('[data-cy=ContentStatistics-useful]').should('be.visible').click();\n    cy.wait(500);\n    cy.get('[data-cy=profile-list-modal]').should('be.visible');\n    cy.get('[data-cy=profile-list-modal]').within(() => {\n      cy.contains('Others that found it useful');\n      cy.contains('demo_user');\n    });\n    cy.get('[data-cy=profile-list-modal] button[icon=\"close\"]').click();\n  });\n\n  it('[Cannot open the useful voters modal without premium tier]', () => {\n    const user = generateNewUserDetails();\n    cy.signUpNewUser(user);\n    cy.visit(`/questions/${slug}`);\n    cy.wait(1000);\n    cy.get('[data-cy=ContentStatistics-useful]').should('be.visible').click();\n    cy.wait(500);\n    cy.get('[data-cy=profile-list-modal]').should('not.exist');\n  });\n\n  const questionWithNoUsefulVotes = MOCK_DATA.questions[1];\n  const { slug: slugNoVotes } = questionWithNoUsefulVotes;\n\n  it('[Cannot open the useful voters modal when there are no useful votes]', () => {\n    cy.signIn(subscriber.email, subscriber.password);\n    cy.visit(`/questions/${slugNoVotes}`);\n    cy.wait(1000);\n    cy.get('[data-cy=ContentStatistics-useful]').should('be.visible').click();\n    cy.wait(500);\n    cy.get('[data-cy=profile-list-modal]').should('not.exist');\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/questions/discussions.spec.ts",
    "content": "// This is basically an identical set of steps to the discussion tests for\n// projects, research and news. Any changes here should be replicated there.\n\nimport { MOCK_DATA } from '../../data';\nimport { generateAlphaNumeric, generateNewUserDetails } from '../../utils/TestUtils';\n\nlet randomId;\n\ndescribe('[Questions.Discussions]', () => {\n  beforeEach(() => {\n    randomId = generateAlphaNumeric(8).toLowerCase();\n  });\n\n  it('shows existing comments', () => {\n    const question = MOCK_DATA.questions[0];\n    cy.visit(`/questions/${question.slug}`);\n    cy.get(`[data-cy=comment-text]`).contains('First comment');\n    cy.get('[data-cy=show-replies]').first().click();\n    cy.get(`[data-cy=\"ReplyItem\"]`).contains('First Reply');\n  });\n\n  it('allows users to sort comments', () => {\n    const commenter = generateNewUserDetails();\n    const question = MOCK_DATA.questions[1];\n    const questionPath = `/questions/${question.slug}`;\n\n    const comment1 = `First comment ${randomId}`;\n    const comment2 = `Second comment ${randomId}`;\n    const comment3 = `Third comment ${randomId}`;\n    const comment4 = `Fourth comment ${randomId}`;\n    const comment5 = `Fifth comment ${randomId}`;\n\n    cy.step('Create user and add five comments');\n    cy.signUpNewUser(commenter);\n    cy.completeUserProfile(commenter.username);\n    cy.visit(questionPath);\n\n    cy.addComment(comment1);\n    cy.wait(1000);\n    cy.get('[data-cy=comment-sort-select]').should('not.exist');\n\n    cy.addComment(comment2);\n    cy.wait(1000);\n    cy.get('[data-cy=comment-sort-select]').should('not.exist');\n\n    cy.addComment(comment3);\n    cy.wait(1000);\n    cy.get('[data-cy=comment-sort-select]').should('not.exist');\n\n    cy.addComment(comment4);\n    cy.wait(1000);\n    cy.get('[data-cy=comment-sort-select]').should('not.exist');\n\n    cy.addComment(comment5);\n    cy.wait(1000);\n    cy.get('[data-cy=comment-sort-select]').should('be.visible');\n\n    cy.step('Mark first and third comments as useful');\n    cy.get('[data-cy=comment-text]')\n      .contains(comment1)\n      .parents('[data-cy=OwnCommentItem]')\n      .find('[data-cy=vote-useful]')\n      .first()\n      .click();\n    cy.wait(1000);\n\n    cy.get('[data-cy=comment-text]')\n      .contains(comment3)\n      .parents('[data-cy=OwnCommentItem]')\n      .find('[data-cy=vote-useful]')\n      .first()\n      .click();\n    cy.wait(1000);\n\n    cy.reload();\n    cy.wait(1000);\n\n    cy.step('Sort dropdown is visible');\n    cy.get('[data-cy=comment-sort-select]').should('be.visible');\n\n    cy.step('Default sort is oldest - comment1 should be first');\n    cy.get('[data-cy=comment-sort-select]').contains('Oldest');\n    cy.get('[data-cy=comment-text]').first().should('contain', comment1);\n\n    cy.step('Sort by newest - comment3 should be first');\n    cy.get('[data-cy=comment-sort-select]').click();\n    cy.contains('Newest').click();\n    cy.get('[data-cy=comment-sort-select]').contains('Newest');\n    cy.get('[data-cy=comment-text]').first().should('contain', comment5);\n\n    cy.step('Sort by most useful - comment3 should be first (newer of the two useful)');\n    cy.get('[data-cy=comment-sort-select]').click();\n    cy.contains('Most Useful').click();\n    cy.get('[data-cy=comment-sort-select]').contains('Most Useful');\n    cy.get('[data-cy=comment-text]').first().should('contain', comment3);\n    cy.get('[data-cy=comment-text]').eq(1).should('contain', comment1);\n\n    cy.step('Sort back to oldest - comment1 should be first');\n    cy.get('[data-cy=comment-sort-select]').click();\n    cy.contains('Oldest').click();\n    cy.get('[data-cy=comment-sort-select]').contains('Oldest');\n    cy.get('[data-cy=comment-text]').first().should('contain', comment1);\n  });\n\n  it('allows authenticated users to contribute to discussions', () => {\n    const commenter = generateNewUserDetails();\n    const question = MOCK_DATA.questions[2];\n    const questionPath = `/questions/${question.slug}`;\n\n    const newComment = `An interesting question. The answer must be... ${commenter.username}`;\n    const updatedNewComment = `An interesting question. The answer must be that when the sky is red, the apocalypse _might_ be on the way. Love, ${commenter.username}. ${randomId}!`;\n    const newReply = `Thanks Dave and Ben. What does everyone else think? - ${commenter.username}`;\n    const updatedNewReply = `Anyone else? Your truly ${commenter.username}`;\n    const secondReply = `Quick reply. ${randomId}? ${commenter.username}`;\n\n    cy.signUpNewUser(commenter);\n\n    cy.step(\"Can't add comment with an incomplete profile\");\n    cy.visit(questionPath);\n    cy.get('[data-cy=comments-form]').should('not.exist');\n    cy.get('[data-cy=comments-incomplete-profile-prompt]').should('be.visible');\n\n    cy.step('Can add comment when profile is complete');\n    cy.completeUserProfile(commenter.username);\n    cy.visit(questionPath);\n    cy.contains('Start the discussion');\n    cy.get('[data-cy=comments-incomplete-profile-prompt]').should('not.exist');\n    cy.addComment(newComment);\n\n    cy.step('Can edit their comment');\n    cy.editDiscussionItem('CommentItem', newComment, updatedNewComment);\n\n    cy.step('Another user can add reply');\n    const replier = generateNewUserDetails();\n    cy.logout();\n    cy.signUpCompletedUser(replier);\n    cy.visit(questionPath);\n    cy.wait(1000);\n    cy.get('[data-cy=CommentItem]').contains(updatedNewComment).should('be.visible');\n    cy.addReply(newReply);\n    cy.contains('Comments');\n\n    cy.step('Can edit their reply');\n    cy.editDiscussionItem('ReplyItem', newReply, updatedNewReply);\n    cy.step('Another user can leave a reply');\n\n    cy.step('First commentor can respond');\n    cy.logout();\n    cy.signIn(commenter.email, commenter.password);\n\n    cy.step('Notification generated for reply from replier');\n    cy.expectNewNotification({\n      content: updatedNewReply,\n      path: questionPath,\n      username: replier.username,\n    });\n    cy.get('[data-cy=highlighted-comment]').contains(updatedNewReply);\n\n    cy.visit(questionPath);\n\n    cy.step('Can add reply');\n    cy.addReply(secondReply);\n\n    cy.step('Can delete their comment');\n    cy.deleteDiscussionItem('CommentItem', updatedNewComment);\n\n    cy.step('Replies still show for deleted comments');\n    cy.get('[data-cy=\"deletedComment\"]').should('be.visible');\n    cy.get('[data-cy=OwnReplyItem]').contains(secondReply);\n\n    cy.step('Can delete their reply');\n    cy.deleteDiscussionItem('ReplyItem', secondReply);\n\n    cy.step('Notification generated for replier from commenter reply');\n    cy.logout();\n    cy.signIn(replier.email, replier.password);\n    cy.expectNewNotification({\n      content: secondReply,\n      path: questionPath,\n      username: commenter.username,\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/questions/read.spec.ts",
    "content": "import { users } from 'oa-shared/mocks/data';\nimport { MOCK_DATA } from '../../data';\nimport { getTenantUser } from '../../utils/TestUtils';\n\nconst question = MOCK_DATA.questions[0];\nconst label = MOCK_DATA.questions.length === 1 ? 'item' : 'items';\n\ndescribe('[Questions]', () => {\n  const howtoCreator = getTenantUser(users.howto_creator);\n\n  describe('[List questions]', () => {\n    it('[By Everyone]', () => {\n      cy.visit(`/questions/`);\n\n      cy.step('Has expected page title');\n      cy.title().should('include', `Questions`);\n\n      cy.step('Displays Item count');\n      cy.contains(`${MOCK_DATA.questions.length} ${label}`);\n\n      cy.step('Questions display');\n\n      cy.get('[data-cy=question-list-item]')\n        .first()\n        .within(() => {\n          cy.get('[data-cy=question-list-item-title]');\n          cy.get('[data-cy=category]');\n          cy.get('[data-cy=Username]');\n          cy.get('[data-tooltip-content=\"How useful it is\"]');\n          cy.get('[data-tooltip-content=\"Total comments\"]');\n        });\n    });\n  });\n\n  describe('[Individual questions]', () => {\n    it('[By Everyone]', () => {\n      const { description, slug, title } = question;\n\n      const pageTitle = `${title} - Question - Test Site`;\n\n      cy.step('Can visit question');\n      cy.visit(`/questions/${slug}`);\n\n      cy.step('All metadata visible');\n      cy.get('[data-cy=ContentStatistics-views]').contains(/\\d/);\n      cy.get('[data-cy=ContentStatistics-following]').contains(/\\d/);\n      cy.get('[data-cy=ContentStatistics-useful]').contains(/\\d/);\n      cy.get('[data-cy=ContentStatistics-comments]').contains(/\\d/);\n\n      cy.step('[Populates title, SEO and social tags]');\n      cy.title().should('eq', pageTitle);\n      cy.get('meta[name=\"description\"]').should('have.attr', 'content', description);\n\n      // OpenGraph (facebook)\n      cy.get('meta[property=\"og:title\"]').should('have.attr', 'content', pageTitle);\n      cy.get('meta[property=\"og:description\"]').should('have.attr', 'content', description);\n\n      // Twitter\n      cy.get('meta[name=\"twitter:title\"]').should('have.attr', 'content', pageTitle);\n      cy.get('meta[name=\"twitter:description\"]').should('have.attr', 'content', description);\n      cy.step('Website is clickable');\n      cy.contains('a', 'https://www.onearmy.earth/');\n\n      cy.step('Breadcrumbs work');\n      cy.get('[data-cy=breadcrumbsItem]').first().should('contain', 'Question');\n      cy.get('[data-cy=breadcrumbsItem]').first().children().should('have.attr', 'href').and('equal', `/questions`);\n\n      cy.get('[data-cy=breadcrumbsItem]').eq(1).should('contain', 'Machines');\n\n      cy.get('[data-cy=breadcrumbsItem]').eq(2).should('contain', title);\n\n      cy.step('Logged in users can complete actions');\n      cy.signIn(howtoCreator.email, howtoCreator.password);\n      cy.visit(`/questions/${slug}`); // Page doesn't reload after login\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/questions/search.spec.ts",
    "content": "describe('[Questions]', () => {\n  beforeEach(() => {\n    cy.visit('/questions');\n  });\n\n  describe('[By Everyone]', () => {\n    it('Searches', () => {\n      cy.wait(2000);\n      cy.step('Can search for items');\n      cy.get('[data-cy=questions-search-box]').clear().type(`deal`);\n      cy.url().should('include', 'q=deal');\n      cy.url().should('include', 'sort=MostRelevant');\n      cy.get('[data-cy=question-list-item]').its('length').should('be.eq', 1);\n      cy.get('[data-cy=HightedText]').contains('deal');\n\n      cy.step('Can clear search');\n      cy.get('[data-cy=close]').click();\n      cy.url().should('not.include', 'q=deal');\n      cy.get('[data-cy=questions-search-box]').should('be.empty');\n      cy.get('[data-cy=question-list-item]').its('length').should('be.above', 1);\n\n      cy.step('should remove search filter after back navigation');\n      cy.get('[data-cy=questions-search-box]').clear().type(`deal`);\n      cy.wait(2000);\n      cy.get('[data-cy=question-list-item]').click();\n      cy.go('back');\n      cy.url().should('not.include', 'q=deal');\n    });\n\n    it('Filters', () => {\n      cy.step('Can select a category to limit items displayed');\n      cy.get('[data-cy=CategoryHorizonalList]').within(() => {\n        cy.contains('Machines').click();\n      });\n      cy.get('[data-cy=CategoryHorizonalList-Item-active]');\n      cy.url().should('include', 'category=');\n\n      cy.step('Can remove the category filter by selecting it again');\n      cy.get('[data-cy=CategoryHorizonalList]').within(() => {\n        cy.contains('Machines').click();\n      });\n      cy.url().should('not.include', 'category=');\n    });\n\n    it('should show question list items after visit a question', () => {\n      cy.get('[data-cy=question-list-item]:eq(0)').click();\n      cy.get('[data-cy=question-title]').should('be.visible');\n      cy.go('back');\n      cy.get('[data-cy=question-list-item]').should('be.visible');\n    });\n\n    it('should load more questions', () => {\n      // Initially on page 1 with 22 items\n      cy.get('[data-cy=question-list-item]').should('have.length', 20);\n      cy.url().should('not.include', 'page=');\n\n      // Click next page\n      cy.get('[data-cy=pagination-icon-paginationSingleRight]').click();\n\n      // Now on page 2 with 2 remaining items\n      cy.get('[data-cy=question-list-item]').should('have.length', 3);\n      cy.url().should('include', 'page=2');\n    });\n\n    it('should show previous questions', () => {\n      // First navigate to the next page\n      cy.get('[data-cy=pagination-icon-paginationSingleRight]').click();\n      cy.get('[data-cy=question-list-item]').should('have.length', 3);\n      cy.url().should('include', 'page=2');\n\n      // Then go back to previous page\n      cy.get('[data-cy=pagination-icon-paginationSingleLeft]').click();\n      cy.get('[data-cy=question-list-item]').should('have.length', 20);\n      cy.url().should('include', 'page=1');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/questions/write.spec.ts",
    "content": "import { users } from 'oa-shared/mocks/data';\nimport { MOCK_DATA } from '../../data';\nimport { generateAlphaNumeric, generateNewUserDetails, getTenantUser } from '../../utils/TestUtils';\n\nlet initialRandomId;\n\ndescribe('[Question]', () => {\n  const demoAdmin = getTenantUser(users.admin);\n  beforeEach(() => {\n    initialRandomId = generateAlphaNumeric(8).toLowerCase();\n  });\n\n  describe('[Create a question]', () => {\n    it('[By Authenticated]', () => {\n      const initialTitle = initialRandomId + ' Health cost of plastic?';\n      const initialExpectedSlug = initialRandomId + '-health-cost-of-plastic';\n      const initialQuestionDescription =\n        \"Hello! I'm wondering how people feel about the health concerns about working with melting plastic and being in environments with microplastics. I have been working with recycling plastic (hdpe) for two years now, shredding and injection molding and haven't had any bad consequences yet. But with the low knowledge around micro plastics and its effects on the human body, and many concerns and hypotheses I have been a bit concerned lately.So I would like to ask the people in this community how you are feeling about it, and if you have experienced any issues with the microplastics or gases yet, if so how long have you been working with it? And what extra steps do you take to be protected from it? I use a gas mask with dust filters\";\n      const category = 'Moulds';\n      // const tag1 = 'product'\n      // const tag2 = 'workshop'\n      const updatedRandomId = generateAlphaNumeric(8).toLowerCase();\n      const updatedTitle = updatedRandomId + ' Real health cost of plastic?';\n      const updatedExpectedSlug = updatedRandomId + '-real-health-cost-of-plastic';\n      const updatedQuestionDescription = `${initialQuestionDescription} and super awesome goggles`;\n\n      cy.visit('/questions');\n      const user = generateNewUserDetails();\n      cy.signUpNewUser(user);\n\n      cy.step(\"Can't add question with an incomplete profile\");\n      cy.visit('/questions');\n      cy.get('[data-cy=create-question]').should('not.exist');\n      cy.get('[data-cy=complete-profile-question]').should('be.visible');\n      cy.visit('/questions/create');\n      cy.get('[data-cy=incomplete-profile-message]').should('be.visible');\n      cy.get('[data-cy=field-title]').should('not.exist');\n\n      cy.completeUserProfile(user.username);\n\n      cy.step('Can add a library project now profile is complete');\n      cy.visit('/questions');\n      cy.get('[data-cy=complete-profile-question]').should('not.exist');\n      cy.get('[data-cy=create-question]:visible').click();\n\n      cy.get('[data-cy=field-title]', { timeout: 20000 });\n\n      cy.step('Cannot be published when empty');\n      cy.get('[data-cy=submit]').click();\n      cy.get('[data-cy=errors-container]');\n\n      cy.step('Add image');\n      cy.get('[data-cy=new-image-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n\n      cy.step('Add title field');\n      cy.get('[data-cy=field-title]').clear().type(initialTitle).blur({ force: true });\n\n      cy.step('Add title description');\n      cy.get('[data-cy=field-description]').type(initialQuestionDescription, {\n        delay: 5,\n      });\n\n      cy.get('[data-cy=draft]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View draft');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View draft').click();\n\n      cy.url().should('include', `/questions/${initialExpectedSlug}`);\n\n      cy.step('Can get to drafts');\n      cy.visit('/questions');\n      cy.contains(initialTitle).should('not.exist');\n      cy.get('[data-cy=my-drafts]:visible').click();\n      cy.contains(initialTitle).click();\n\n      cy.step('Shows draft question');\n      cy.get('[data-cy=draft-tag]').should('be.visible');\n      cy.contains(initialQuestionDescription);\n      cy.get('[data-cy=edit]').click();\n\n      cy.step('Add category');\n      cy.selectTag(category, '[data-cy=category-select]');\n\n      // Bug: Tags missing in test suite setup\n      //\n      // cy.step('Add tags')\n      // cy.selectTag(tag1, '[data-cy=\"tag-select\"]')\n      // cy.selectTag(tag2, '[data-cy=\"tag-select\"]')\n\n      cy.step('Submit question');\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View question');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View question').click();\n\n      cy.url().should('include', `/questions/${initialExpectedSlug}`);\n\n      cy.step('All question fields visible');\n      cy.contains(initialTitle);\n      cy.contains(initialQuestionDescription);\n      cy.contains(category);\n\n      cy.step('All ready for a discussion');\n      cy.get('[data-cy=DiscussionTitle]').contains('Start the discussion');\n      cy.get('[data-cy=follow-button]').first().should('contain', 'Following Comments');\n\n      cy.step('Edit question');\n      cy.get('[data-cy=edit]').click().url().should('include', `/questions/${initialExpectedSlug}/edit`);\n\n      cy.step('Add title description');\n      cy.get('[data-cy=field-description]').clear().type(updatedQuestionDescription, { delay: 5 });\n\n      cy.step('Updated question details shown');\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View question');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View question').click();\n      cy.url().should('include', `/questions/${initialExpectedSlug}`);\n      cy.contains(updatedQuestionDescription);\n\n      cy.step('Updating the title changes the slug');\n      cy.get('[data-cy=edit]').click();\n      cy.get('[data-cy=field-title]').clear().type(updatedTitle).blur();\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View question');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View question').click();\n      cy.url().should('include', `/questions/${updatedExpectedSlug}`);\n      cy.contains(updatedTitle);\n\n      cy.step('Can access the question with the previous slug');\n      cy.visit(`/questions/${initialExpectedSlug}`);\n      cy.contains(updatedTitle);\n\n      cy.step('Question should appear on users profile');\n      cy.visit('/u/' + user.username);\n      cy.get('[data-testid=questions-stat]').should('exist');\n      cy.get('[data-cy=ContribTab]').click();\n      cy.get('[data-testid=\"question-contributions\"]').within(() => {\n        cy.contains(updatedTitle);\n        cy.get(`[data-cy=\"UserDocumentItem: coverImage for ${updatedTitle}\"]`);\n      });\n\n      cy.step('All updated fields visible on list');\n      cy.visit('/questions');\n      cy.contains(updatedTitle);\n      cy.contains(category);\n    });\n\n    it('[By Anonymous]', () => {\n      cy.step('Ask users to login before creating a question');\n      cy.visit('/questions');\n      cy.get('[data-cy=create-question]').should('not.exist');\n      cy.get('[data-cy=sign-up]').should('be.visible');\n\n      cy.visit('/questions/create');\n      cy.url().should('contain', '/sign-in?returnUrl=%2Fquestions%2Fcreate');\n    });\n\n    it('[By Admin]', () => {\n      const question = MOCK_DATA.questions[0];\n\n      cy.signIn(demoAdmin.email, demoAdmin.password);\n\n      cy.step('Question is not authored by the admin');\n      cy.visit(`/questions/${question.slug}`);\n      cy.get('[data-cy=Username]').should('not.contain', 'demo_admin');\n\n      cy.step(\"Admin can see the edit button on another user's question\");\n      cy.get('[data-cy=edit]').should('be.visible');\n\n      cy.step('Admin can access the edit page');\n      cy.get('[data-cy=edit]').click();\n      cy.url().should('include', `/questions/${question.slug}/edit`);\n\n      cy.step('Admin can edit the question description');\n      cy.get('[data-cy=field-description]').should('be.visible');\n\n      const adminEdit = ' [edited by admin]';\n      cy.get('[data-cy=field-description]').type(adminEdit, { delay: 5 });\n      cy.get('[data-cy=submit]').click();\n\n      cy.step('Updated content is visible');\n      cy.url().should('include', `/questions/${question.slug}`);\n      cy.contains(adminEdit);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/research/discussions.spec.ts",
    "content": "// This is basically an identical set of steps to the discussion tests for\n// questions and projects. Any changes here should be replicated there.\n\nimport { MOCK_DATA } from '../../data';\nimport { generateAlphaNumeric, getTenantUser } from '../../utils/TestUtils';\n\nlet randomId;\n\ndescribe('[Research.Discussions]', () => {\n  beforeEach(() => {\n    randomId = generateAlphaNumeric(10).toLowerCase();\n  });\n\n  it('allows authenticated users to contribute to discussions', () => {\n    const admin = getTenantUser(MOCK_DATA.users.admin);\n    const secondCommentor = getTenantUser(MOCK_DATA.users.profile_views);\n\n    cy.signIn(admin.email, admin.password);\n\n    const newComment = `An example comment from ${admin.username}`;\n    const updatedNewComment = `I've updated my comment now. Love ${admin.username}. ${randomId}!`;\n\n    const research = MOCK_DATA.research[1];\n    const researchPath = `/research/${research.slug}`;\n\n    cy.step('Can add comment');\n    cy.visit(researchPath);\n    cy.get('[data-cy=\"HideDiscussionContainer:button\"]').first().click();\n    cy.addComment(newComment);\n\n    cy.step('Can edit their comment');\n    cy.editDiscussionItem('CommentItem', newComment, updatedNewComment);\n\n    cy.step('Another user can add reply');\n    const newReply = `An interesting point, I hadn't thought about that. All the best ${secondCommentor.username}`;\n    const updatedNewReply = `I hadn't thought about that. Really good point. ${secondCommentor.username}`;\n\n    cy.logout();\n\n    cy.signIn(secondCommentor.email, secondCommentor.password);\n    cy.visit(researchPath);\n    cy.get('[data-cy=\"HideDiscussionContainer:button\"]').first().click();\n\n    cy.addReply(newReply);\n    cy.wait(1000);\n    cy.contains('Comments');\n\n    cy.step('Can edit their reply');\n    cy.editDiscussionItem('ReplyItem', newReply, updatedNewReply);\n\n    cy.step('First commentor can respond');\n    const secondReply = `Quick reply. ${admin.username}. ${randomId}...`;\n\n    cy.step('Notification generated for reply from replier');\n    cy.logout();\n    cy.signIn(admin.email, admin.password);\n    cy.visit(researchPath);\n    cy.expectNewNotification({\n      content: updatedNewReply,\n      path: researchPath,\n      username: secondCommentor.username,\n    });\n    cy.wait(2000);\n    cy.get('[data-cy=highlighted-comment]').contains(updatedNewReply);\n\n    cy.visit(researchPath);\n\n    cy.step('Can add reply');\n    cy.get('[data-cy=\"HideDiscussionContainer:button\"]').first().click();\n    cy.addReply(secondReply);\n\n    cy.step('Can delete their comment');\n    cy.deleteDiscussionItem('CommentItem', updatedNewComment);\n\n    cy.step('Replies still show for deleted comments');\n    cy.get('[data-cy=\"deletedComment\"]').should('be.visible');\n    cy.get('[data-cy=OwnReplyItem]').contains(secondReply);\n\n    cy.step('Can delete their reply');\n    cy.deleteDiscussionItem('ReplyItem', secondReply);\n\n    cy.step('Notification generated for secondCommentor from admin reply');\n    cy.logout();\n    cy.signIn(secondCommentor.email, secondCommentor.password);\n    cy.expectNewNotification({\n      content: secondReply,\n      path: researchPath,\n      username: admin.username,\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/research/follow.spec.ts",
    "content": "import { users } from \"oa-shared/mocks/data\";\nimport { getTenantUser } from \"../../utils/TestUtils\";\n\ndescribe('[Research]', () => {\n  const researchArticleUrl = '/research/qwerty';\n  const researchListUrl = '/research';\n  const demoBetaTester = getTenantUser(users['beta-tester']);\n\n  describe('[By Everyone]', () => {\n    it('[Follow button]', () => {\n      cy.visit(researchArticleUrl);\n      cy.step('Should redirect to sign in');\n      cy.get('[data-cy=\"follow-button\"]').should('not.exist');\n      cy.get('[data-cy=\"follow-redirect\"]').should('exist');\n      cy.get('[data-cy=\"follow-redirect\"]').first().click();\n      cy.url().should('include', '/sign-in');\n    });\n\n    it('[Follow button on list]', () => {\n      cy.visit(researchListUrl);\n      cy.step('Should not show follow icon when not logged in');\n      cy.get('[data-cy=\"ResearchListItem\"]')\n        .first()\n        .within(() => {\n          cy.get('[data-cy=\"follow-icon\"]').should('not.exist');\n        });\n    });\n  });\n\n  describe('[By Authenticated]', () => {\n    it('[Follow button]', () => {\n      cy.step('Should exist');\n      cy.signIn(demoBetaTester.email, demoBetaTester.password);\n      cy.visit(researchArticleUrl);\n      cy.wait(1000);\n      cy.get('[data-cy=\"follow-redirect\"]').should('not.exist');\n      cy.get('[data-cy=\"follow-button\"]').should('be.visible').should('contain.text', 'Follow');\n\n      cy.step('Should follow on click');\n      cy.get('[data-cy=\"follow-button\"]').first().click();\n      cy.wait(2000);\n      cy.get('[data-cy=\"follow-button\"]').first().should('contain.text', 'Following');\n\n      cy.step('Should persist follow status on reload');\n      cy.visit(researchArticleUrl);\n      cy.wait(1000);\n      cy.get('[data-cy=\"follow-button\"]').first().should('contain.text', 'Following');\n      cy.get('[data-cy=\"follow-button\"]').first().click();\n      cy.wait(2000);\n      cy.get('[data-cy=\"follow-button\"]').should('be.visible').should('contain.text', 'Follow');\n    });\n\n    it('[Follow icon on list view]', () => {\n      cy.step('Should show follow icon when user is following');\n      cy.signIn(demoBetaTester.email, demoBetaTester.password);\n\n      cy.step('Follow the item from article view first');\n      cy.visit(researchArticleUrl);\n      cy.wait(1000);\n      cy.get('[data-cy=\"follow-button\"]').first().click();\n      cy.wait(2000);\n\n      cy.step('Follow icon should be visible in list view for the followed item');\n      cy.visit(researchListUrl);\n      cy.wait(2000);\n      cy.get('[data-cy=research-search-box]').click().type('qwerty');\n      cy.get('[data-cy=\"ResearchListItem\"]').should('exist');\n      cy.contains('[data-cy=\"ResearchListItem\"]', 'Qwerty').within(() => {\n        cy.get('[data-cy=\"follow-icon\"]').should('exist');\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/research/list.spec.ts",
    "content": "describe('[Research - List Articles]', () => {\n  const researchPageUrl = '/research';\n\n  beforeEach(() => {\n    cy.visit(researchPageUrl);\n  });\n\n  it('[By Everyone - Lists all research articles]', () => {\n    cy.step('Verify page loads with research articles');\n    cy.get('[data-cy=ResearchList]').should('be.visible');\n    cy.get('[data-cy=ResearchListItem]').should('have.length.greaterThan', 0);\n  });\n\n  it('[Search Functionality - Filters articles]', () => {\n    const searchTerm = 'test';\n\n    cy.wait(2000);\n    cy.step('Type a keyword into the search bar');\n    cy.get('[data-cy=research-search-box]').clear().type(searchTerm);\n\n    cy.step('Verify filtered results are displayed');\n    cy.get('[data-cy=ResearchListItem]').should('have.length.at.least', 1);\n  });\n\n  it('[Search Functionality - Partial word search]', () => {\n    cy.wait(2000);\n    cy.step('Search with a partial word');\n    cy.get('[data-cy=research-search-box]').clear().type('tes');\n\n    cy.step('Verify partial match returns results');\n    cy.get('[data-cy=ResearchListItem]').should('have.length.at.least', 1);\n  });\n\n  it('[Search Functionality - Sorts by Latest Updated]', () => {\n    cy.visit(researchPageUrl + '?sort=LatestUpdated');\n    cy.step('Verify sorted results are displayed');\n    cy.get('[data-cy=ResearchListItem]').first().contains('A test research');\n  });\n\n  it('[Pagination - Navigates to next page]', () => {\n    cy.step('Verify pagination is visible');\n    cy.get('[data-cy=pagination]').should('be.visible');\n    cy.get('[data-cy=pagination-icon-paginationSingleRight]').should('be.visible');\n\n    cy.step('Click next page');\n    cy.get('[data-cy=pagination-icon-paginationSingleRight]').click();\n\n    cy.step('Verify page changed');\n    cy.url().should('include', 'page=2');\n    cy.get('[data-cy=ResearchListItem]').should('have.length.greaterThan', 0);\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/research/read.spec.ts",
    "content": "import { users } from 'oa-shared/mocks/data';\nimport { MOCK_DATA } from '../../data';\nimport { getTenantUser } from '../../utils/TestUtils';\n\nconst article = Object.values(MOCK_DATA.research)[0];\nconst label = MOCK_DATA.research.length === 1 ? 'item' : 'items';\n\ndescribe('[Research]', () => {\n  const { description, slug, title } = article;\n\n  const authoredResearchArticleUrl = '/research/a-test-research';\n  // const image = updates[0].images[0].publicUrl\n  const pageTitle = `${title} - Research - Test Site`;\n  const researchArticleUrl = `/research/${slug}`;\n\n  describe('[Read a research article]', () => {\n    const demoAdmin = getTenantUser(users.admin);\n    const demoUser = getTenantUser(users.subscriber);\n\n    describe('[By Everyone]', () => {\n      it('[List View]', () => {\n        cy.visit('/research');\n\n        cy.step('Has expected page title');\n        cy.title().should('include', `Research`);\n\n        cy.step('Displays Item count');\n        cy.contains(`${MOCK_DATA.research.filter((r) => !r.deleted && !r.is_draft).length} ${label}`);\n\n        cy.step('Can search for items');\n        cy.wait(2000);\n        cy.get('[data-cy=research-search-box]').click().type('qwerty');\n        cy.get('[data-cy=ResearchListItem]').its('length').should('be.eq', 2);\n\n        cy.step('All basic info displayed on each card');\n        const researchTitle = 'Qwerty';\n        const researchUrl = '/research/qwerty';\n\n        cy.get('[data-cy=ResearchList]').within(() => {\n          cy.contains(researchTitle).should('be.visible');\n          cy.get('[data-cy=Username]').contains('event_reader');\n          cy.get('[data-cy=category]').contains('Machines');\n          cy.get('a').should('have.attr', 'href').and('eq', researchUrl);\n          cy.get('[data-cy=ItemResearchStatus]').contains('In Progress');\n          cy.get('[data-tooltip-content=\"How useful is it\"]');\n          cy.get('[data-tooltip-content=\"Total comments\"]');\n          cy.get('[data-tooltip-content=\"Amount of updates\"]');\n        });\n\n        cy.step('Can clear search');\n        cy.get('[data-cy=close]').click();\n        cy.get('[data-cy=ResearchListItem]').its('length').should('be.above', 2);\n\n        cy.step('Can select a category to limit items displayed');\n        cy.get('[data-cy=category]').contains('Machines');\n        cy.get('[data-cy=CategoryHorizonalList]').within(() => {\n          cy.contains('Moulds').click();\n        });\n        cy.get('[data-cy=CategoryHorizonalList-Item-active]');\n        cy.get('[data-cy=category]').contains('Moulds');\n        cy.get('[data-cy=category]').contains('Machines').should('not.exist');\n\n        cy.step('Can remove the category filter by selecting it again');\n        cy.get('[data-cy=CategoryHorizonalList]').within(() => {\n          cy.contains('Moulds').click();\n        });\n        cy.get('[data-cy=category]').contains('Machines');\n\n        cy.step('Can filter by research status');\n        cy.get('[data-cy=ItemResearchStatus]').contains('In Progress');\n        cy.contains('Filter by status').click({ force: true });\n        cy.contains('Completed').click({ force: true });\n        cy.get('[data-cy=ItemResearchStatus]').contains('Completed');\n        cy.get('[data-cy=ItemResearchStatus]').contains('In progress').should('not.exist');\n      });\n\n      it('[Visible to everyone]', () => {\n        cy.step('Can visit research');\n        cy.visit(researchArticleUrl);\n        cy.wait(1000);\n\n        cy.title().should('eq', `${article.title} - Research - Test Site`);\n\n        cy.step('All metadata visible');\n        cy.get('[data-cy=ContentStatistics-views]').contains(/\\d/);\n        cy.get('[data-cy=ContentStatistics-following]').contains(/\\d/);\n        cy.get('[data-cy=ContentStatistics-useful]').contains(/\\d/);\n        cy.get('[data-cy=ContentStatistics-comments]').contains(/\\d/);\n        cy.get('[data-cy=ContentStatistics-updates]').contains(/\\d/);\n\n        cy.step('[Populates title, SEO and social tags]');\n        cy.title().should('eq', pageTitle);\n        cy.get('meta[name=\"description\"]').should('have.attr', 'content', description);\n\n        // OpenGraph (facebook)\n        cy.get('meta[property=\"og:title\"]').should('have.attr', 'content', pageTitle);\n        cy.get('meta[property=\"og:description\"]').should('have.attr', 'content', description);\n        // cy.get('meta[property=\"og:image\"]').should(\n        //   'have.attr',\n        //   'content',\n        //   image,\n        // )\n\n        // Twitter\n        cy.get('meta[name=\"twitter:title\"]').should('have.attr', 'content', pageTitle);\n        cy.get('meta[name=\"twitter:description\"]').should('have.attr', 'content', description);\n        // cy.get('meta[name=\"twitter:image\"]').should(\n        //   'have.attr',\n        //   'content',\n        //   image,\n        // )\n\n        cy.step('Breadcrumbs work');\n        cy.get('[data-cy=breadcrumbsItem]').first().should('contain', 'Research');\n        cy.get('[data-cy=breadcrumbsItem]').first().children().should('have.attr', 'href').and('equal', `/research`);\n\n        cy.get('[data-cy=breadcrumbsItem]').eq(2).should('contain', article.title);\n\n        cy.step('Can get and paste update anchor');\n        cy.get('[data-cy=ResearchLinkToUpdate]').last().click();\n        cy.window().then((window) => {\n          window.navigator.clipboard.readText().then((clipboardItem) => {\n            cy.visit(clipboardItem);\n            cy.contains(article.title);\n          });\n        });\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/research/write.spec.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport { RESEARCH_TITLE_MIN_LENGTH } from '../../../../../src/pages/Research/constants';\nimport { MOCK_DATA } from '../../data';\nimport { generateAlphaNumeric, getTenantUser } from '../../utils/TestUtils';\n\nconst generateArticle = () => {\n  const title = faker.lorem.words(4);\n  const slug = title.toLowerCase().split(' ').join('-');\n\n  return {\n    _createdBy: 'research_creator',\n    _deleted: false,\n    description: 'After creating, the research will be deleted.',\n    title: title,\n    slug: slug,\n    previousSlugs: [slug],\n    status: 'In progress',\n  };\n};\n\nconst admin = getTenantUser(MOCK_DATA.users.admin);\nconst researcher = getTenantUser(MOCK_DATA.users.research_creator);\n\ndescribe('[Research]', () => {\n  beforeEach(() => {\n    cy.visit('/research');\n  });\n\n  describe('[Create research article]', () => {\n    it('[By Authenticated]', () => {\n      const initialRandomId = generateAlphaNumeric(4).toLowerCase();\n      const initialTitle = initialRandomId + ' Initial Title';\n      const initialExpectedSlug = initialRandomId + '-initial-title';\n\n      const expected = generateArticle();\n\n      const updateTitle = faker.lorem.words(5);\n      const updateDescription = 'This is the description for the update.';\n      const updateVideoUrl = 'http://youtube.com/watch?v=sbcWY7t-JX8';\n      const subscriber = getTenantUser(MOCK_DATA.users.subscriber);\n      const researchURL = `/research/${expected.slug}`;\n\n      cy.signIn(subscriber.email, subscriber.password);\n\n      cy.step(\"Can't add research with an incomplete profile\");\n      cy.visit('/research');\n      cy.get('[data-cy=create-research]').should('not.exist');\n      cy.get('[data-cy=complete-profile-research]').should('be.visible');\n      cy.visit('/research/create');\n      cy.url().should('contain', '/forbidden');\n\n      cy.logout();\n      cy.signIn(admin.email, admin.password);\n      cy.step('Create the research article');\n      cy.visit('/research');\n      cy.get('[data-cy=loader]').should('not.exist');\n      cy.get('[data-cy=create]:visible').click();\n\n      cy.step('Warn if title is identical to an existing one');\n      cy.contains('Start your Research');\n\n      cy.step('Cannot be published when empty');\n      cy.get('[data-cy=submit]').should('be.visible').click();\n      cy.get('[data-cy=errors-container]').should('be.visible');\n\n      cy.step('Warn if title not long enough');\n      cy.get('[data-cy=intro-title').clear().type('Q').blur({ force: true });\n      cy.contains(`Should be more than ${RESEARCH_TITLE_MIN_LENGTH} characters`);\n\n      cy.step('Enter research article details');\n      cy.get('[data-cy=intro-title').clear().type(initialTitle).blur();\n\n      cy.step('Cannot be published without description');\n\n      cy.get('[data-cy=intro-description]').type(expected.description).blur();\n\n      cy.get('[data-cy=draft]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View draft');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View draft').click();\n\n      cy.get('[data-cy=draft-tag]').should('be.visible');\n      cy.get('[data-cy=follow-button]').first().should('contain', 'Following');\n\n      cy.step('Drafted Research should not appear on users profile');\n      cy.visit('/u/' + admin.displayName);\n      cy.get('[data-testid=research-stat]').should('not.exist');\n      cy.get('[data-cy=ContribTab]').should('not.exist');\n\n      cy.visit(`/research/${initialExpectedSlug}`);\n      cy.get('[data-cy=edit]').click();\n      cy.get('[data-cy=intro-title').clear().type(expected.title).blur();\n\n      cy.step('Add image');\n      cy.get('[data-cy=image-input]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n      cy.get('[data-cy=delete-image]').should('exist');\n\n      cy.step('New collaborators can be assigned to research');\n      cy.selectTag(subscriber.username, '[data-cy=UserNameSelect]');\n\n      cy.get('[data-cy=errors-container]').should('not.exist');\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research').click();\n\n      cy.url().should('include', researchURL);\n      cy.visit(researchURL);\n\n      cy.step('Research article displays correctly');\n      cy.contains(expected.title);\n      cy.contains(expected.description);\n      cy.contains(admin.username);\n\n      cy.step('Can access the research with the previous slug');\n      cy.visit(`/research/${initialExpectedSlug}`);\n      cy.contains(expected.title);\n\n      cy.step('Published Research should appear on users profile');\n      cy.visit('/u/' + admin.displayName);\n      cy.get('[data-testid=research-stat]').should('exist');\n      cy.get('[data-cy=ContribTab]').click();\n      cy.get('[data-testid=\"research-contributions\"]').within(() => {\n        cy.contains(expected.title);\n        cy.get(`[data-cy=\"UserDocumentItem: coverImage for ${expected.title}\"]`);\n      });\n\n      cy.step('New collaborators can add update');\n      cy.logout();\n      cy.signIn(subscriber.email, subscriber.password);\n      cy.visit(`/research/${expected.slug}`);\n      cy.get('[data-cy=follow-button]').first().should('contain', 'Following');\n      cy.visit(`/research/${expected.slug}/new-update`);\n      cy.contains('New update');\n\n      cy.step('Cannot be published when empty');\n      cy.wait(1000);\n\n      cy.get('label[for=files]').should('exist');\n      cy.get('[data-cy=submit]').should('be.visible').click();\n      cy.get('[data-cy=errors-container]').should('be.visible');\n\n      cy.step('Enter update details');\n      cy.get('[data-cy=intro-title]').should('be.visible').clear().type(updateTitle).blur({ force: true });\n\n      cy.get('[data-cy=intro-description]').clear().type(updateDescription).blur({ force: true });\n\n      cy.get('[data-cy=videoUrl]').clear().type(updateVideoUrl).blur({ force: true });\n\n      cy.step('Add file to update');\n      cy.get('[id=file-input]').selectFile('src/fixtures/files/Example.pdf', {\n        force: true,\n      });\n      cy.get('[data-cy=remove-file]').should('exist');\n\n      cy.step('Published when fields are populated correctly');\n      cy.get('[data-cy=submit]').click();\n\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research update');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research update').click();\n\n      cy.contains(updateTitle).should('be.visible');\n      cy.contains(updateDescription).should('be.visible');\n\n      cy.step('Uploaded file is available for download');\n      cy.get('[data-cy=downloadButton]').should('be.visible');\n      cy.get('[data-cy=\"HideDiscussionContainer:button\"]').last().click();\n      cy.get('[data-cy=\"CollapsableCommentSection\"]')\n        .last()\n        .within(() => {\n          cy.get('[data-cy=follow-button]').should('contain', 'Following');\n        });\n\n      cy.step('Collaborator is subscribed to research and research update discussion');\n      cy.logout();\n      cy.signIn(subscriber.email, subscriber.password);\n      cy.visit(researchURL);\n      cy.get('[data-cy=follow-button]').first().should('contain', 'Following');\n      cy.get('[data-cy=\"HideDiscussionContainer:button\"]').last().click();\n      cy.get('[data-cy=follow-button]').first().should('contain', 'Following');\n      cy.step('Notification generated for update');\n      cy.logout();\n      cy.signIn(admin.email, admin.password);\n      cy.expectNewNotification({\n        content: updateTitle,\n        path: `${researchURL}#update_`,\n        title: expected.title,\n        username: subscriber.username,\n      });\n    });\n\n    it('[By Anonymous]', () => {\n      cy.step('Ask users to login before creating a research item');\n      cy.visit('/research');\n      cy.get('[data-cy=create]').should('not.exist');\n      cy.get('[data-cy=sign-up]').should('be.visible');\n\n      cy.visit('/research/create');\n      cy.url().should('contain', '/sign-in');\n    });\n  });\n\n  describe('[Edit a research article]', () => {\n    const editResearchUrl = '/research/create-research-article-test/edit';\n\n    it('[By Anonymous]', () => {\n      cy.step('Prevent anonymous access to edit research article');\n      cy.visit(editResearchUrl);\n      cy.url().should('contain', '/sign-in');\n    });\n\n    it('[By Authenticated - Replace cover image]', () => {\n      const randomId = generateAlphaNumeric(8).toLowerCase();\n      const title = `${randomId} Research for image edit test`;\n      const slug = `${randomId}-research-for-image-edit-test`;\n      const description = 'Testing image replacement during edit';\n      const updatedDescription = 'Updated description after image change';\n\n      cy.signIn(admin.email, admin.password);\n\n      cy.step('Create a research article with image');\n      cy.visit('/research/create');\n      cy.wait(2000);\n      cy.contains('Start your Research');\n      cy.get('[data-cy=intro-title').clear().type(title).blur();\n      cy.get('[data-cy=intro-description]').clear().type(description).blur();\n      cy.get('[data-cy=image-input]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n      cy.get('[data-cy=delete-image]').should('exist');\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research').click();\n\n      cy.url().should('include', `/research/${slug}`);\n\n      cy.step('Edit research and replace cover image');\n      cy.get('[data-cy=edit]').click();\n      cy.get('[data-cy=intro-description]').clear().type(updatedDescription).blur();\n\n      cy.step('Delete existing image and upload new one');\n      cy.get('[data-cy=delete-image]').click({ force: true });\n      cy.get('[data-cy=image-input]').find(':file').selectFile('src/fixtures/images/howto-step-pic2.jpg', { force: true });\n      cy.get('[data-cy=delete-image]').should('exist');\n\n      cy.get('[data-cy=submit]').click();\n      cy.url().should('include', `/research/${slug}`);\n      cy.contains(updatedDescription);\n    });\n\n    it('[Delete button is visible]', () => {\n      cy.signIn(admin.email, admin.password);\n\n      cy.visit('/research/a-research-test-4/edit');\n\n      cy.step('Delete button should be visible to research author');\n      cy.get('[data-cy=\"Research: delete button\"]').should('be.visible');\n    });\n  });\n\n  describe('[Displays draft updates for Author]', () => {\n    it('[By Authenticated]', () => {\n      const randomId = generateAlphaNumeric(8).toLowerCase();\n      const updateTitle = `${randomId} Create a research update`;\n      const updateDescription = 'This is the description for the update.';\n      const updateVideoUrl = 'http://youtube.com/watch?v=sbcWY7t-JX8';\n      const researchItem = {\n        category: 'Machines',\n        description: 'After creating, the research will be deleted.',\n        title: `${randomId} Create research article test`,\n        slug: `${randomId}-create-research-article-test`,\n      };\n      const finalUpdateTitle = `Publish title: ${randomId}`;\n      const researchURL = `/research/${researchItem.slug}`;\n      cy.get('[data-cy=\"sign-up\"]');\n      cy.signIn(researcher.email, researcher.password);\n\n      cy.step('Create the research article');\n      cy.visit('/research');\n      cy.get('[data-cy=loader]').should('not.exist');\n      cy.get('a[href=\"/research/create\"]').should('be.visible');\n      cy.get('[data-cy=create]:visible').click();\n\n      cy.wait(2000);\n\n      cy.step('Enter research article details');\n      cy.get('[data-cy=intro-title').clear().type(researchItem.title).blur();\n      cy.get('[data-cy=intro-description]').clear().type(researchItem.description);\n      cy.selectTag(researchItem.category, '[data-cy=category-select]');\n      cy.get('[data-cy=image-input]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n      cy.get('[data-cy=delete-image]').should('exist');\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research').click();\n\n      cy.get('[data-cy=follow-button]', { timeout: 20000 }).should('contain', 'Following');\n      cy.contains(researchItem.title);\n\n      cy.step('Users can follow for research updates (for later expectations)');\n      cy.logout();\n      cy.signIn(admin.email, admin.password);\n      cy.visit(researchURL);\n      cy.get('[data-cy=follow-button]').first().click();\n      cy.clearNotifications();\n      cy.logout();\n\n      cy.step('Can start adding research update');\n\n      cy.signIn(researcher.email, researcher.password);\n      cy.visit(researchURL);\n      cy.get('[data-cy=addResearchUpdateButton]').click();\n      cy.fillIntroTitle(updateTitle);\n\n      cy.get('[data-cy=intro-description]').wait(0).focus().clear().type(updateDescription).blur();\n\n      cy.get('[data-cy=videoUrl]').clear().type(updateVideoUrl).blur();\n\n      cy.step('Add file to draft update');\n      cy.get('[id=file-input]').selectFile('src/fixtures/files/Example.pdf', {\n        force: true,\n      });\n      cy.get('[data-cy=remove-file]').should('exist');\n\n      cy.step('Save as Draft');\n      cy.get('[data-cy=draft]').click();\n\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View draft');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View draft').click();\n\n      cy.step('Can see Draft after refresh');\n      cy.contains(updateTitle);\n      cy.get('[data-cy=DraftUpdateLabel]').should('be.visible');\n\n      cy.step('Draft not visible to others');\n      cy.logout();\n      cy.visit(researchURL);\n      cy.get(updateTitle).should('not.exist');\n      cy.get('[data-cy=DraftUpdateLabel]').should('not.exist');\n\n      cy.step(\"Draft hasn't generated notifications\");\n      cy.signIn(admin.email, admin.password);\n      cy.expectNoNewNotification();\n      cy.logout();\n\n      cy.step('Draft updates can be published');\n      cy.signIn(researcher.email, researcher.password);\n      cy.visit(researchURL);\n      cy.get('[data-cy=edit-update]').should('be.visible').click();\n      cy.contains('Edit your update');\n      cy.get('[data-cy=intro-title]').should('be.visible');\n      cy.fillIntroTitle(finalUpdateTitle);\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research update');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research update').click();\n      cy.contains(finalUpdateTitle);\n      cy.get('[data-cy=DraftUpdateLabel]').should('not.exist');\n\n      cy.step('Uploaded file is available for download');\n      cy.get('[data-cy=downloadButton]').should('be.visible');\n\n      cy.step('All ready for a discussion');\n      cy.get('[data-cy=\"HideDiscussionContainer:button\"]').click();\n      cy.get('[data-cy=DiscussionTitle]').contains('Start the discussion');\n      cy.get('[data-cy=follow-button]').should('contain', 'Following');\n\n      cy.step('Now published draft has generated notifications');\n      cy.logout();\n      cy.signIn(admin.email, admin.password);\n      cy.expectNewNotification({\n        content: finalUpdateTitle,\n        path: `${researchURL}#update_`,\n        title: researchItem.title,\n        username: researcher.username,\n      });\n    });\n\n    it('[Edit published update - Replace images and files]', () => {\n      const randomId = generateAlphaNumeric(8).toLowerCase();\n      const researchTitle = `${randomId} Research with update`;\n      const researchSlug = `${randomId}-research-with-update`;\n      const updateTitle = `${randomId} Initial update`;\n      const updatedTitle = `${randomId} Updated update`;\n      const updateDescription = 'Initial update description';\n      const updatedDescription = 'Updated description with new media';\n\n      cy.signIn(researcher.email, researcher.password);\n\n      cy.step('Create research article');\n      cy.visit('/research/create');\n      cy.wait(2000);\n      cy.contains('Start your Research');\n      cy.get('[data-cy=intro-title').clear().type(researchTitle).blur();\n      cy.get('[data-cy=intro-description]').clear().type('Research description').blur();\n      cy.get('[data-cy=image-input]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n      cy.get('[data-cy=delete-image]').should('exist');\n      cy.get('[data-cy=submit]').click();\n\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research').click();\n      cy.url().should('include', `/research/${researchSlug}`);\n\n      cy.step('Create update with image and file');\n      cy.get('[data-cy=addResearchUpdateButton]').should('exist').click();\n      cy.wait(2000);\n      cy.contains('New update');\n      cy.get('[data-cy=intro-title]').should('be.visible');\n      cy.fillIntroTitle(updateTitle);\n      cy.get('[data-cy=intro-description]').clear().type(updateDescription).blur();\n\n      cy.step('Add images to update');\n      cy.get('[data-cy=new-image-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic1.jpg', { force: true });\n      cy.get('[data-cy=delete-image]').should('exist');\n\n      cy.step('Add file to update');\n      cy.get('[id=file-input]').selectFile('src/fixtures/files/Example.pdf', { force: true });\n      cy.get('[data-cy=remove-file]').should('exist');\n\n      cy.get('[data-cy=submit]').click();\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research update');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research update').click();\n\n      cy.url().should('include', `${researchSlug}#update_`);\n\n      cy.step('Edit update and replace media');\n      cy.get('[data-cy=edit-update]').should('be.visible').click();\n      cy.wait(2000);\n      cy.contains('Edit your update');\n      cy.get('[data-cy=intro-title]').should('be.visible');\n      cy.fillIntroTitle(updatedTitle);\n      cy.get('[data-cy=intro-description]').clear().type(updatedDescription).blur();\n\n      cy.step('Replace image');\n      cy.get('[data-cy=image-upload-0]').find('[data-cy=delete-image]').click({ force: true });\n      cy.get('[data-cy=new-image-upload]').find(':file').selectFile('src/fixtures/images/howto-step-pic2.jpg', { force: true });\n      cy.get('[data-cy=image-upload-0]').find('[data-cy=delete-image]').should('exist');\n\n      cy.step('Replace file');\n      cy.get('[data-cy=remove-file]').click();\n      cy.get('[id=file-input]').selectFile('src/fixtures/files/Example.pdf', { force: true });\n      cy.get('[data-cy=remove-file]').should('exist');\n\n      cy.get('[data-cy=submit]').click();\n\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research update');\n      cy.wait(1000);\n      cy.get('a[data-cy=toast-action-link]').should('contain', 'View research update').click();\n\n      cy.url().should('include', `${researchSlug}#update_`);\n      cy.contains(updatedTitle);\n      cy.contains(updatedDescription);\n      cy.get('[data-cy=downloadButton]').should('be.visible');\n    });\n\n    // it('[By Admin]', () => {\n    // Should check an admin can edit other's content\n    // })\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/integration/settings.spec.ts",
    "content": "import { MOCK_DATA } from '../data';\nimport { SingaporeStubResponse } from '../fixtures/searchResults';\nimport { UserMenuItem } from '../support/commandsUi';\nimport { generateNewUserDetails, getTenantUser } from '../utils/TestUtils';\n\nconst locationStub = {\n  administrative: '',\n  country: 'Singapore',\n  countryCode: 'sg',\n  latlng: { lng: '103.8194992', lat: '1.357107' },\n  postcode: '578774',\n  value: 'Singapore',\n};\n\nconst mapDetails = {\n  searchKeyword: 'singapo',\n  locationName: locationStub.value,\n};\n\nconst settings_member_new = getTenantUser(MOCK_DATA.users.settings_member_new);\n\ndescribe('[Settings]', () => {\n  beforeEach(() => {\n    cy.interceptAddressSearchFetch(SingaporeStubResponse);\n    cy.visit('/sign-in');\n  });\n\n  it('[Cancel edit profile and get confirmation]', () => {\n    cy.signIn(settings_member_new.email, settings_member_new.password);\n\n    cy.step('Go to User Settings');\n    cy.clickMenuItem(UserMenuItem.Settings);\n    cy.wait(1000);\n    cy.get('[data-cy=displayName').clear().type('Wrong user');\n\n    cy.step('Confirm shown when attempting to go to another page');\n    cy.get('[data-cy=page-link]').contains('Library').click();\n    cy.get('[data-cy=\"Confirm.modal: Modal\"]').should('be.visible');\n  });\n\n  it('Can create member', () => {\n    cy.viewport('macbook-16');\n\n    const country = 'Bolivia';\n    const countryCode = 'BO';\n    const userImage = 'avatar';\n    const displayName = 'ff_settings_member_new';\n    const description = \"I'm a very active member\";\n    const profileType = 'member';\n    const tag = ['Product Design', 'Accounting'];\n    const website = 'https://social.network';\n\n    cy.step('Incomplete profile banner visible when logged out');\n    cy.get('[data-cy=notificationBanner]').should('not.exist');\n\n    const user = generateNewUserDetails();\n    cy.signUpNewUser(user);\n\n    cy.step('Without username, Profile menu directs to settings');\n    cy.clickMenuItem(UserMenuItem.Profile);\n    cy.url().should('include', '/settings');\n\n    cy.step('Cannot add map pin');\n    cy.get('[data-cy=\"tab-Map\"]').click();\n    cy.get('[data-cy=descriptionMember]').should('be.visible');\n    cy.get('[data-cy=IncompleteProfileTextDisplay]').should('be.visible');\n    cy.get('[data-cy=complete-profile-button]').should('be.visible');\n\n    cy.step('Member profile badge shown in header by default');\n    cy.get('[data-cy=\"tab-Profile\"]').click();\n    cy.get(`[data-cy=MemberBadge-${profileType}]`);\n\n    cy.setSettingFocus(profileType);\n\n    cy.step(\"Can't save without required fields being populated\");\n    cy.get('[data-cy=save]').click();\n    cy.contains('This field is required').should('be.visible');\n    cy.get('[data-cy=CompleteProfileHeader]').should('be.visible');\n\n    cy.step('Can set the required fields');\n    cy.get('[data-cy=username]').clear().type(user.username);\n    cy.setSettingBasicUserInfo({\n      displayName,\n      country,\n      description,\n      website,\n    });\n    cy.selectTag(tag[0], '[data-cy=profile-tag-select]');\n    cy.selectTag(tag[1], '[data-cy=profile-tag-select]');\n    cy.get(`[data-cy=\"country:${countryCode}\"]`);\n\n    cy.step('Errors if trying to upload invalid image');\n    cy.get(`[data-cy=userImage]`).find(':file').selectFile(`src/fixtures/files/Example.pdf`, { force: true });\n    cy.get('[data-cy=photo-error]').should('be.visible');\n\n    cy.step('Can add avatar');\n    cy.setSettingImage(userImage, 'userImage');\n\n    cy.step(\"Can't add cover image\");\n    cy.get('[data-cy=coverImages]').should('not.exist');\n\n    cy.saveSettingsForm();\n\n    cy.step('Incomplete profile prompts no longer visible');\n    cy.get('[data-cy=incompleteProfileBanner]').should('not.exist');\n    cy.get('[data-cy=CompleteProfileHeader]').should('not.exist');\n\n    cy.step('User image shown in header');\n    cy.get('[data-cy=\"header-avatar\"]').should('have.attr', 'src').and('include', userImage);\n\n    cy.step('Updated settings display on profile');\n    cy.visit(`u/${user.username}`);\n    cy.get('[data-cy=emptyProfileMessage]').should('not.exist');\n    cy.contains(user.username);\n    cy.contains(displayName);\n    cy.contains(description);\n    cy.contains(tag[0]);\n    cy.contains(tag[1]);\n    cy.get(`[data-cy=\"country:${countryCode}\"]`);\n    cy.get(`[data-cy=\"MemberBadge-${profileType}\"]`);\n    cy.get('[data-cy=\"profile-avatar\"]').should('have.attr', 'src').and('include', userImage);\n\n    cy.step('Can add map pin');\n    cy.get('[data-cy=EditYourProfile]').click({ force: true });\n    cy.get('[data-cy=\"tab-Map\"]').click();\n    cy.get('[data-cy=descriptionMember]').should('be.visible');\n    cy.contains('No map pin currently saved');\n    cy.get('[data-cy=IncompleteProfileTextDisplay]').should('not.exist');\n    cy.get('[data-cy=complete-profile-button]').should('not.exist');\n    cy.fillSettingMapPin(mapDetails);\n    cy.get('[data-cy=save-map-pin]').click();\n    cy.contains('Map pin saved!');\n    cy.contains('Your current map pin is here:');\n    cy.contains(locationStub.country);\n\n    cy.step('Can delete map pin');\n    cy.visit('/settings');\n    cy.get('[data-cy=\"tab-Map\"]').click();\n    cy.get('[data-cy=remove-map-pin]').click();\n    cy.get('[data-cy=\"Confirm.modal: Confirm\"]').click();\n    cy.contains('No map pin currently saved');\n    cy.get('[data-cy=\"tab-Profile\"]').click();\n    cy.get('[data-cy=country-dropdown]').should('be.visible');\n  });\n\n  it('Can create space', () => {\n    const coverImage = 'profile-cover-1-edited';\n    const userImage = 'avatar';\n    const displayName = 'new_ff_space';\n    const description = 'We have some space to run a workplace';\n    const profileType = 'workspace';\n    const tag = 'Meetups';\n    const website = 'https://wikipedia.com';\n\n    const user = generateNewUserDetails();\n    cy.signUpNewUser(user);\n\n    cy.step('Go to User Settings');\n    cy.visit('/settings');\n    cy.setSettingFocus(profileType);\n\n    cy.step(\"Can't save without required fields being populated\");\n    cy.get('[data-cy=save]').click();\n    cy.contains('This field is required.').should('be.visible');\n\n    cy.step('Populate profile');\n    cy.get('[data-cy=username]').clear().type(user.username);\n    cy.setSettingBasicUserInfo({\n      displayName,\n      description,\n      website,\n    });\n    cy.selectTag(tag, '[data-cy=profile-tag-select]');\n\n    cy.step('Can add avatar and cover image');\n    cy.setSettingImage(userImage, 'userImage');\n    cy.setSettingImage(coverImage, 'coverImages-0');\n\n    cy.saveSettingsForm();\n\n    cy.step('Updated settings display on profile');\n    cy.visit(`u/${user.username}`);\n    cy.contains(user.username);\n    cy.contains(displayName);\n    cy.contains(description);\n    cy.contains(tag);\n    cy.get(`[data-cy=\"MemberBadge-${profileType}\"]`);\n    cy.get('[data-cy=\"userImage\"]').should('have.attr', 'src').and('include', userImage);\n    cy.get('[data-cy=\"active-image\"]').should('have.attr', 'src').and('include', coverImage);\n\n    cy.step('Updated settings display on contact tab');\n    cy.get('[data-cy=\"contact-tab\"]').click({ force: true });\n    cy.get('[data-cy=\"contact-tab\"]').click({ force: true });\n    cy.contains(`Other users are able to contact you`);\n    cy.get('[data-cy=\"profile-website\"]').should('have.attr', 'href', website);\n  });\n});\n\nit('Notifications', () => {\n  cy.signUpNewUser();\n\n  cy.step('Notification setting not shown when messaging off');\n  // TODO: mock no_messaging true\n  cy.visit('/settings');\n  cy.get('[data-cy=tab-Notifications]').click();\n  cy.get('[data-cy=messages-link]').should('not.exist');\n\n  cy.step('Notification setting present for contact feature ');\n  // TODO: mock no_messaging false\n  cy.visit('/settings');\n  cy.get('[data-cy=tab-Notifications]').click();\n  cy.get('[data-cy=messages-link]');\n});\n\ndescribe('[Precious Plastic]', () => {\n  beforeEach(() => {\n    cy.visit('/sign-in');\n  });\n\n  it('Can create impact for space', () => {\n    const coverImage = 'profile-cover-1-edited';\n    const userImage = 'avatar';\n    const displayName = 'settings_workplace_new';\n    const description = 'We have a precious space.';\n    const profileType = 'workspace';\n    const tag = 'Shredder';\n    const visitorType = 'Open to visitors';\n    const visitorDetails = 'Visitors are welcome between 13:00 and 15:00 every day';\n    const impactFields = [\n      { name: 'plastic', value: 5 },\n      { name: 'revenue', value: 10003 },\n      { name: 'employees', value: 7 },\n      { name: 'volunteers', value: 28 },\n      { name: 'machines', value: 2, visible: false },\n    ];\n    const user = generateNewUserDetails();\n    cy.signUpNewUser(user);\n\n    cy.step('Go to User Settings');\n    cy.visit('/settings');\n    cy.setSettingFocus(profileType);\n\n    cy.step(\"Can't save without required fields being populated\");\n    cy.get('[data-cy=save]').click();\n    cy.contains('This field is required.').should('be.visible');\n\n    cy.step('Populate profile');\n    cy.get('[data-cy=username]').clear().type(user.username);\n    cy.setSettingBasicUserInfo({\n      displayName,\n      description,\n    });\n    cy.selectTag(tag, '[data-cy=profile-tag-select]');\n\n    cy.step('Can add avatar and cover image');\n    cy.setSettingImage(userImage, 'userImage');\n    cy.setSettingImage(coverImage, 'coverImages-0');\n\n    cy.step('Can add contact link and visitor details');\n    cy.setSettingVisitorPolicy(visitorType, visitorDetails);\n    cy.saveSettingsForm();\n\n    cy.step('Updated settings display on profile');\n    cy.visit(`u/${user.username}`);\n    cy.contains(user.username);\n    cy.contains(displayName);\n    cy.contains(description);\n    cy.contains(tag);\n    cy.get('[data-cy=\"ImpactTab\"]').should('not.exist');\n    cy.get(`[data-cy=\"MemberBadge-${profileType}\"]`);\n    cy.get('[data-cy=\"userImage\"]').should('have.attr', 'src').and('include', userImage);\n    cy.get('[data-cy=\"active-image\"]').should('have.attr', 'src').and('include', coverImage);\n\n    cy.step('Can see visitor policy');\n    cy.get('[data-cy=tag-openToVisitors]').contains(visitorType).click();\n    cy.get('[data-cy=VisitorModal]').contains(visitorDetails);\n    cy.get('[data-cy=\"close\"]').click();\n\n    cy.step('Set and display impact data');\n    cy.visit('/settings');\n    cy.setSettingImpactData(2022, impactFields);\n    cy.visit(`u/${user.username}`);\n    cy.get('[data-cy=\"ImpactTab\"]').click();\n\n    // From visibleImpactFields above\n    cy.contains('5 Kg of plastic recycled');\n    cy.contains('USD 10,003 revenue');\n    cy.contains('7 full time employees');\n    cy.contains('28 volunteers');\n\n    cy.step('Can remove visitor policy');\n    cy.visit('/settings');\n    cy.clearSettingVisitorPolicy();\n    cy.saveSettingsForm();\n    cy.visit(`u/${user.username}`);\n    cy.get('[data-cy=tag-openToVisitors]').should('not.exist');\n  });\n});\n"
  },
  {
    "path": "packages/cypress/src/support/commands.ts",
    "content": "export {};\n\ninterface ExpectedNewNotification {\n  content: string;\n  path: string;\n  title: string;\n  username: string;\n}\n\ndeclare global {\n  namespace Cypress {\n    interface Chainable {\n      clearServiceWorkers(): Promise<void>;\n      clearNotifications(): Chainable<void>;\n      expectNewNotification(ExpectedNewNotification): Chainable<void>;\n      expectNoNewNotification(): Chainable<void>;\n      interceptAddressSearchFetch(addressResponse): Chainable<void>;\n      interceptAddressReverseFetch(addressResponse): Chainable<void>;\n      step(message: string);\n    }\n  }\n}\n\nCypress.Commands.add('clearServiceWorkers', () => {\n  cy.window().then((w) => {\n    cy.wrap('Clearing service workers').then(() => {\n      return new Cypress.Promise((resolve) => {\n        // if running production builds locally may also need to remove service workers between runs\n        if (w.navigator && navigator.serviceWorker) {\n          navigator.serviceWorker.getRegistrations().then((registrations) => {\n            registrations.forEach((registration) => {\n              registration.unregister();\n            });\n            resolve();\n          });\n        } else {\n          resolve();\n        }\n      });\n    });\n  });\n});\n\nCypress.Commands.add('step', (message: string) => {\n  Cypress.log({\n    displayName: 'step',\n    message: [`**${message}**`],\n  });\n});\n\nCypress.Commands.add('interceptAddressSearchFetch', (addressResponse) => {\n  cy.intercept('GET', 'https://nominatim.openstreetmap.org/search*', {\n    body: addressResponse,\n  }).as('fetchAddress');\n});\n\nCypress.Commands.add('interceptAddressReverseFetch', (addressResponse) => {\n  cy.intercept('GET', 'https://nominatim.openstreetmap.org/reverse?*', {\n    body: addressResponse,\n  }).as('fetchAddressReverse');\n});\n\n/**\n * Overwrite default logging to also output to console\n * https://github.com/cypress-io/cypress/issues/3199\n */\nCypress.Commands.overwrite('log', (subject, message) => cy.task('log', message));\n\nCypress.Commands.add('clearNotifications', () => {\n  cy.get('[data-cy=NotificationsSupabase-desktop]').click();\n\n  cy.get('[data-cy=NotificationListSupabase]').then(($listView) => {\n    if ($listView.text().includes('Mark all read')) {\n      cy.get('[data-cy=NotificationListSupabase-MarkAllRead]').click();\n    }\n  });\n  cy.get('[data-cy=NotificationListSupabase-CloseButton]').click();\n});\n\nCypress.Commands.add('expectNewNotification', (props: ExpectedNewNotification) => {\n  const { content, path, title, username } = props;\n\n  cy.get('[data-cy=NotificationsSupabase-desktop]').click();\n\n  cy.get('[data-cy=NotificationListSupabase]').contains(username);\n  if (title) {\n    cy.get('[data-cy=NotificationListSupabase]').contains(title);\n  }\n  cy.get('[data-cy=NotificationListItemSupabase]').first().find('img').should('be.visible');\n  cy.get('[data-cy=NotificationListSupabase]').contains(content).click();\n\n  cy.url().should('include', path);\n});\n\nCypress.Commands.add('expectNoNewNotification', () => {\n  cy.get('[data-cy=notifications-no-new-messages]').first();\n});\n"
  },
  {
    "path": "packages/cypress/src/support/commandsUi.ts",
    "content": "import { form } from '../../../../src/pages/UserSettings/labels';\nimport { generateNewUserDetails } from '../utils/TestUtils';\n\n// import { visitorDisplayData } from 'oa-components'\n\nexport enum UserMenuItem {\n  Profile = 'Profile',\n  Settings = 'Settings',\n  LogOut = 'Logout',\n}\n\ninterface IInfo {\n  displayName?: string;\n  country?: string;\n  description: string;\n  website?: string;\n}\n\ninterface IMapPin {\n  searchKeyword: string;\n  locationName: string;\n}\n\ndeclare global {\n  namespace Cypress {\n    interface Chainable {\n      addComment(newComment: string): Chainable<void>;\n      addReply(reply: string): Chainable<void>;\n      addToMarkdownField(text: string): Chainable<void>;\n      clickMenuItem(menuItem: UserMenuItem): Chainable<void>;\n      deleteDiscussionItem(element: string, item: string);\n      editDiscussionItem(element: string, oldComment: string, updatedNewComment: string): Chainable<void>;\n      signIn(email: string, password: string): Chainable<void>;\n      logout(): Chainable<void>;\n      fillSignupForm(email: string, password: string): Chainable<void>;\n      fillIntroTitle(intro: string);\n      fillSettingMapPin(pin: IMapPin);\n\n      saveSettingsForm();\n      /**\n       * Trigger form validation\n       */\n      screenClick(): Chainable<void>;\n      /**\n       * Selecting options from the react-select picker can be a bit fiddly\n       * so user helper method to locate select box, type input and pick tag\n       * (if exists) https://github.com/cypress-io/cypress/issues/549\n       * @param tagname This will be typed into the input box and selected from the dropdown list\n       * @param selector Specify the selector of the react-select element\n       **/\n      selectTag(tagName: string, selector?: string): Chainable<void>;\n      setSettingVisitorPolicy(policyText: string, details?: string);\n      clearSettingVisitorPolicy();\n      setSettingBasicUserInfo(info: IInfo);\n      setSettingFocus(focus: string);\n      setSettingImage(image: string, selector: string);\n      setSettingImpactData(year: number, fields);\n      setSettingPublicContact();\n\n      signUpNewUser(user?): Chainable<void>;\n      signUpCompletedUser(user?): Chainable<void>;\n      completeUserProfile(username: string): Chainable<void>;\n      setProfileUsername(username: string): Chainable<void>;\n      confirmUser(username: string): Chainable<void>;\n\n      toggleUserMenuOn(): Chainable<void>;\n      toggleUserMenuOff(): Chainable<void>;\n    }\n  }\n}\n\n/**\n * Create custom commands that can be used within cypress chaining and namespace\n * @remark - any called functions should be 'wrapped' in a cy.wrap('some name') statement to allow chaining\n * @remark - async code should be wrapped in a Cypress.promise block to allow the resolved promise to be\n * used in chained results\n */\n\nCypress.Commands.add('addToMarkdownField', (text: string) => {\n  cy.get('[aria-label=\"editable markdown\"]').click().type('{moveToEnd}').type('{enter}').type('{enter}');\n\n  for (let i = 0; i < text.length; i++) {\n    // This is a very slow way to do this, but avoidable currently.\n    cy.get('[aria-label=\"editable markdown\"]').click().type('{moveToEnd}').type(text[i], { delay: 0 });\n  }\n});\n\nCypress.Commands.add('saveSettingsForm', () => {\n  cy.get('[data-cy=save]').click({ force: true });\n\n  cy.contains('[data-cy=toast-message]', 'Profile updated!').should('be.visible');\n});\n\nCypress.Commands.add('setSettingVisitorPolicy', (policyText: string, details?: string) => {\n  cy.step('Set Visitor policy');\n  cy.get('[data-testid=\"openToVisitors-switch\"]').click({ force: true });\n  cy.selectTag(policyText, '[data-cy=\"openToVisitors-policy\"]');\n  if (details) {\n    cy.get('[data-cy=\"openToVisitors-details\"]').clear().type(details).blur({ force: true });\n  }\n});\n\nCypress.Commands.add('clearSettingVisitorPolicy', () => {\n  cy.step('Clear visitor policy');\n  cy.get('[data-testid=\"openToVisitors-switch\"]').click({ force: true });\n});\n\nCypress.Commands.add('setSettingBasicUserInfo', (info: IInfo) => {\n  const { country, description, displayName, website } = info;\n\n  cy.step('Update Info section');\n  displayName && cy.get('[data-cy=displayName').clear().type(displayName);\n  cy.get('[data-cy=info-about').clear().type(description);\n  country && cy.selectTag(country, '[data-cy=country-dropdown]');\n  website && cy.get('[data-cy=website').clear().type(website);\n});\n\nCypress.Commands.add('setSettingFocus', (focus: string) => {\n  cy.get(`[data-cy=${focus}]`).first().click();\n});\n\nCypress.Commands.add('setSettingImage', (image, selector) => {\n  cy.get(`[data-cy=${selector}]`).find(':file').selectFile(`src/fixtures/images/${image}.jpg`, { force: true });\n  cy.wait(2000);\n});\n\nCypress.Commands.add('setSettingImpactData', (year: number, fields) => {\n  cy.step('Save impact data');\n  cy.get('[data-cy=\"tab-Impact\"]').click();\n\n  cy.get(`[data-cy=\"impactForm-${year}-button-edit\"]`).click();\n\n  fields.forEach((field) => {\n    cy.get(`[data-cy=\"impactForm-${year}-field-${field.name}-value\"]`).clear().type(field.value);\n    field.visible === false && cy.get(`[data-cy=\"impactForm-${year}-field-${field.name}-isVisible\"]`).click();\n  });\n  cy.get(`[data-cy=\"impactForm-${year}-button-save\"]`).click();\n  cy.contains('Impact updated!').should('be.visible');\n});\n\nCypress.Commands.add('fillSettingMapPin', (mapPin: IMapPin) => {\n  cy.get('[data-cy=\"osm-geocoding-input\"]').clear().type(mapPin.searchKeyword);\n  cy.get('[data-cy=\"osm-geocoding-results\"]');\n  cy.wait('@fetchAddress').then(() => {\n    cy.get('[data-cy=\"osm-geocoding-results\"]').find('li:eq(0)').click();\n  });\n});\n\nCypress.Commands.add('setSettingPublicContact', () => {\n  cy.step('Opts out of public contact');\n  cy.get('[data-cy=isContactable').should('be.checked');\n  cy.get('[data-cy=isContactable').click({ force: true });\n});\n\nCypress.Commands.add('fillSignupForm', (email: string, password: string) => {\n  cy.log('Fill in sign-up form');\n  cy.visit('/sign-up');\n  cy.wait(2000);\n  cy.get('[data-cy=email]').clear().type(email);\n  cy.get('[data-cy=password]').clear().type(password);\n  cy.get('[data-cy=confirm-password]').clear().type(password);\n  cy.get('[data-cy=consent]').check();\n});\n\nCypress.Commands.add('signIn', (email: string, password: string) => {\n  cy.log('Fill in sign in form');\n  cy.visit('/sign-in');\n  cy.wait(2000);\n  cy.get('[data-cy=email]').clear().type(email);\n  cy.get('[data-cy=password]').clear().type(password);\n  cy.get('[data-cy=submit]').click();\n  cy.get('[data-cy=loader]').should('not.exist');\n});\n\nCypress.Commands.add('logout', () => {\n  cy.request('/logout');\n});\n\nCypress.Commands.add('fillIntroTitle', (intro: string) => {\n  cy.log('Fill in intro title');\n  cy.get('[data-cy=intro-title]').clear().type(intro).blur({ force: true });\n});\n\nCypress.Commands.add('toggleUserMenuOn', () => {\n  Cypress.log({ displayName: 'OPEN_USER_MENU' });\n  cy.get('[data-cy=user-menu]').should('be.visible');\n  cy.get('[data-cy=user-menu]').click();\n});\n\nCypress.Commands.add('toggleUserMenuOff', () => {\n  Cypress.log({ displayName: 'CLOSE_USER_MENU' });\n  cy.get('[data-cy=header]').click({ force: true });\n});\n\nCypress.Commands.add('clickMenuItem', (menuItem: UserMenuItem) => {\n  Cypress.log({\n    displayName: 'CLICK_MENU_ITEM',\n    consoleProps: () => {\n      return { menuItem };\n    },\n  });\n  cy.toggleUserMenuOn();\n  cy.get(`[data-cy=menu-${menuItem}]`).should('be.visible').click();\n});\n\nCypress.Commands.add('screenClick', () => {\n  cy.get('[data-cy=header]').click({ force: true });\n});\n\nCypress.Commands.add('selectTag', (tagName: string, selector = '[data-cy=tag-select]') => {\n  cy.log('Select tag', tagName);\n  cy.get(`${selector} input`).click({ force: true });\n  cy.get(`${selector} input`).type(tagName, { force: true });\n  cy.get(`${selector} .data-cy__menu-list`).contains(tagName).click();\n});\n\nCypress.Commands.add('addComment', (newComment: string) => {\n  cy.get('[data-cy=comments-form]').last().type(newComment);\n  cy.get('[data-cy=comment-submit]').last().click();\n\n  cy.contains(newComment);\n  cy.get('[data-cy=OwnCommentItem]').contains('less than a minute ago');\n});\n\nCypress.Commands.add('editDiscussionItem', (element, oldComment, updatedNewComment) => {\n  cy.get(`[data-cy=\"${element}: ActionSetButton\"]`).last().click();\n  cy.get(`[data-cy=\"${element}: edit button\"]`).click();\n  cy.get('[data-cy=edit-comment]').as('editField');\n  cy.get('@editField').clear({ force: true });\n  cy.get('@editField').type(updatedNewComment);\n  cy.get('[data-cy=edit-comment-submit]').click();\n  cy.get('@editField').should('not.exist');\n\n  cy.wait(1000);\n  cy.get(`[data-cy=Own${element}]`).contains(updatedNewComment);\n  cy.get(`[data-cy=Own${element}]`).contains('less than a minute ago');\n  cy.get(`[data-cy=Own${element}]`).contains(oldComment).should('not.exist');\n});\n\nCypress.Commands.add('deleteDiscussionItem', (element, item) => {\n  cy.get(`[data-cy=\"${element}: ActionSetButton\"]`).last().click();\n  cy.get(`[data-cy=\"${element}: delete button\"]`).click();\n  cy.get('[data-cy=\"Confirm.modal: Confirm\"]').last().click();\n\n  cy.contains(item).should('not.exist');\n});\n\nCypress.Commands.add('addReply', (reply: string) => {\n  cy.get('[data-cy=show-replies]').first().click({ force: true });\n  cy.get('[data-cy=reply-form]').first().type(reply);\n  cy.get('[data-cy=reply-submit]').first().click();\n\n  cy.get('[data-cy=OwnReplyItem]').contains(reply);\n});\n\nCypress.Commands.add('signUpNewUser', (user?) => {\n  cy.log('Generate new user details');\n  const { email, password } = user || generateNewUserDetails();\n\n  cy.fillSignupForm(email, password);\n  cy.get('[data-cy=submit]').click();\n  cy.url().should('include', 'sign-up-message');\n});\n\nCypress.Commands.add('completeUserProfile', (username) => {\n  cy.log('Complete user profile');\n  cy.visit('/settings');\n  cy.get('[data-cy=username]').then(($el) => {\n    if (!$el.prop('disabled')) {\n      cy.wrap($el).clear().type(username);\n    }\n  });\n  cy.setSettingImage('avatar', 'userImage');\n  cy.setSettingBasicUserInfo({\n    displayName: username,\n    description: `${username} profile description.`,\n  });\n  cy.saveSettingsForm();\n});\n\nCypress.Commands.add('setProfileUsername', (username: string) => {\n  cy.log('Set profile username (without completing profile)');\n  cy.visit('/settings');\n  cy.get('[data-cy=username]').clear().type(username);\n  cy.get('[data-cy=displayName').clear().type(username);\n  cy.get('[data-cy=info-about').clear().type(`${username} profile`);\n  cy.get('[data-cy=save]').click({ force: true });\n  cy.get('[data-cy=errors-container]').should('not.exist');\n  cy.contains('[data-cy=toast-message]', 'Username updated!').should('be.visible');\n});\n\nCypress.Commands.add('signUpCompletedUser', (user?) => {\n  const { username } = user || generateNewUserDetails();\n  cy.signUpNewUser(user);\n  cy.completeUserProfile(username);\n});\n"
  },
  {
    "path": "packages/cypress/src/support/hooks.ts",
    "content": "/**\n * Before all tests begin seed the database. CY runs this before all specs.\n * Note, cy also automatically will clear browser caches.\n * https://docs.cypress.io/api/commands/clearlocalstorage.html\n *\n * The should not be confused with beforeAll which is run before each test.\n * Additionally any aliases created in before will not be passed to test instance,\n * put aliases created in beforeAll will be (not currently required)\n */\nbefore(() => {\n  // Add error handlers\n  // https://docs.cypress.io/api/utilities/promise.html#Rejected-test-promises-do-not-fail-tests\n  window.addEventListener('unhandledrejection', (event) => {\n    throw event.reason;\n  });\n  Cypress.Promise.onPossiblyUnhandledRejection((error) => {\n    throw error;\n  });\n\n  cy.task('seed database');\n\n  localStorage.clear();\n  cy.clearServiceWorkers();\n});\n\nafterEach(() => {\n  // ensure all tests are also logged out (skip ui check in case page not loaded)\n  cy.logout();\n});\n\nafter(async () => {\n  Cypress.log({\n    displayName: 'Clearing database for tenant',\n    message: Cypress.env('TENANT_ID'),\n  });\n\n  // Safe to always cleanup - each tenant has isolated data by tenant_id + ci_node and auth users with unique emails\n  cy.task('clear database');\n});\n"
  },
  {
    "path": "packages/cypress/src/support/index.ts",
    "content": "// ***********************************************************\n// This example support/index.js is processed and\n// loaded automatically before your test files.\n//\n// This is a great place to put global configuration and\n// behaviour that modifies Cypress.\n//\n// You can change the location of this file or turn off\n// automatically serving support files with the\n// 'supportFile' configuration option.\n//\n// You can read more here:\n// https://on.cypress.io/configuration\n// ***********************************************************\nimport './hooks';\nimport './commands';\nimport './commandsUi';\nimport './rules';\n"
  },
  {
    "path": "packages/cypress/src/support/rules.ts",
    "content": "Cypress.on('uncaught:exception', (err) => {\n  const skipErrors = [\n    'The query requires an index.',\n    'No document to update',\n    'KeyPath previousSlugs',\n    'KeyPath slug',\n    'Hydration',\n    'hydration',\n    'There was an error while hydrating',\n    'Hydration failed because the initial UI does not match what was rendered on the server',\n    'An error occurred during hydration.',\n    'Minified React',\n  ];\n\n  const foundSkipError = skipErrors.find((error) => err.message.includes(error));\n\n  if (foundSkipError) {\n    return false;\n  }\n\n  // Cypress and React Hydrating the document don't get along\n  // for some unknown reason. Hopefully, we figure out why eventually.\n  // Maybe https://github.com/cypress-io/cypress/issues/27204#issuecomment-2224833564\n  if (\n    /hydrat/i.test(err.message) ||\n    /Minified React error #418/.test(err.message) ||\n    /Minified React error #423/.test(err.message)\n  ) {\n    return false;\n  }\n  // we still want to ensure there are no other unexpected\n  // errors, so we let them fail the test\n});\n"
  },
  {
    "path": "packages/cypress/src/utils/TestUtils.ts",
    "content": "export interface IUserSignUpDetails {\n  username: string;\n  email: string;\n  password: string;\n}\n\nexport enum Page {\n  HOWTO = '/library',\n  ACADEMY = '/academy',\n  SETTINGS = '/settings',\n}\n\nexport const generateAlphaNumeric = (length: number) => {\n  let result = '';\n  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n  const charactersLength = characters.length;\n  for (let i = 0; i < length; i++) {\n    result += characters.charAt(Math.floor(Math.random() * charactersLength));\n  }\n  return result;\n};\n\nexport enum DbCollectionName {\n  users = 'users',\n  howtos = 'howtos',\n}\n\nexport const generateNewUserDetails = (): IUserSignUpDetails => {\n  const username = `CI_${generateAlphaNumeric(9)}`.toLocaleLowerCase();\n  const tenantId = Cypress.env('TENANT_ID');\n  return {\n    username,\n    email: `delivered+${username}+${tenantId}@resend.dev`.toLocaleLowerCase(),\n    password: 'test1234',\n  };\n};\n\n/**\n * Transforms a mock user to include the tenant ID in their email\n * Use this when you need to reference user data that matches what's in the database\n * @example\n * const user = getTenantUser(users.admin)\n * cy.signIn(user.email, user.password)\n */\nexport const getTenantUser = <T extends { email: string }>(user: T): T => {\n  const tenantId = Cypress.env('TENANT_ID');\n  const tenantAwareEmail = user.email.includes(`+${tenantId}@`)\n    ? user.email\n    : user.email.replace('@', `+${tenantId}@`);\n  \n  return {\n    ...user,\n    email: tenantAwareEmail,\n  };\n};\n"
  },
  {
    "path": "packages/cypress/src/utils/supabaseTestsService.ts",
    "content": "import { createClient } from '@supabase/supabase-js';\n\nimport { MOCK_DATA } from '../data';\n\nimport type { SupabaseClient, User } from '@supabase/supabase-js';\nimport type { DBProfile, DBProfileBadge, DBProfileTag, DBProfileType, DBResearchItem, Profile, TenantSettings } from 'oa-shared';\n\ntype SeedData = {\n  [tableName: string]: Array<Record<string, any>>;\n};\n\nexport class SupabaseTestsService {\n  private client: SupabaseClient<any, 'public', 'public', any, any>;\n  private adminClient: SupabaseClient<any, 'public', 'public', any, any>;\n  private tenantId: string;\n\n  constructor(apiUrl: string, secretKey: string, tenantId: string) {\n    this.tenantId = tenantId.toLowerCase();\n    this.client = createClient(apiUrl, secretKey, {\n      global: {\n        headers: {\n          'x-tenant-id': this.tenantId,\n        },\n      },\n    });\n\n    this.adminClient = createClient(apiUrl, secretKey, {\n      global: {\n        headers: {\n          'x-tenant-id': this.tenantId,\n        },\n      },\n      auth: {\n        autoRefreshToken: false,\n        persistSession: false,\n      },\n    });\n  }\n\n  async deleteAccounts() {\n    let page = 1;\n    const toDelete: string[] = [];\n\n    while (true) {\n      const { data, error } = await this.adminClient.auth.admin.listUsers({ perPage: 1000, page });\n      if (error) throw new Error(`listUsers failed: ${error.message}`);\n      if (!data.users.length) break;\n\n      for (const user of data.users) {\n        if (user.email?.includes(`+${this.tenantId}`)) {\n          toDelete.push(user.id);\n        }\n      }\n\n      if (data.users.length < 1000) break;\n      page++;\n    }\n\n    console.log(`Deleting ${toDelete.length} auth users for tenant ${this.tenantId}`);\n    for (const id of toDelete) {\n      await this.adminClient.auth.admin.deleteUser(id);\n    }\n  }\n\n  async seedDatabase(data: SeedData) {\n    const results: Record<string, any> = {};\n\n    for (const [table, rows] of Object.entries(data)) {\n      const result = await this.client.from(table).insert(rows).select();\n\n      if (!result.error) {\n        results[table] = result;\n        continue;\n      }\n\n      // Log errors so they're visible in CI\n      console.error(`[${this.tenantId}] Error seeding ${table}:`, {\n        error: result.error,\n        message: result.error?.message,\n        code: result.error?.code,\n        details: result.error?.details,\n        hint: result.error?.hint,\n        rowCount: rows.length\n      });\n\n      results[table] = result;\n    }\n\n    return results;\n  }\n\n  async clearDatabase(tables: string[], tenantId: string) {\n    // sequential so there are no constraint issues\n    for (const table of tables) {\n      await this.client.from(table).delete().eq('tenant_id', tenantId);\n    }\n  }\n\n  async createStorage(tenantId: string) {\n    await this.adminClient.storage.createBucket(tenantId, {\n      public: true,\n    });\n\n    await this.adminClient.storage.createBucket(tenantId + '-documents');\n  }\n\n  async clearStorage(tenantId: string) {\n    await this.manuallyEmptyBucket(tenantId);\n    await this.adminClient.storage.deleteBucket(tenantId);\n\n    await this.manuallyEmptyBucket(tenantId + '-documents');\n    await this.adminClient.storage.deleteBucket(tenantId + '-documents');\n  }\n\n  async manuallyEmptyBucket (bucketName: string, path = '') {\n    const limit = 100;\n    let offset = 0;\n\n    while (true) {\n      const { data, error } = await this.adminClient.storage\n        .from(bucketName)\n        .list(path, { limit, offset });\n\n      if (error || !data || data.length === 0) break;\n\n      const folders = data.filter((item) => item.metadata === null);\n      const files = data.filter((item) => item.metadata !== null);\n\n      if (files.length > 0) {\n        const paths = files.map((f) => (path ? `${path}/${f.name}` : f.name));\n        await this.adminClient.storage.from(bucketName).remove(paths);\n      }\n\n      for (const folder of folders) {\n        const folderPath = path ? `${path}/${folder.name}` : folder.name;\n        await this.manuallyEmptyBucket(bucketName, folderPath);\n      }\n\n      if (data.length < limit) break; // last page\n      offset += limit;\n    }\n  };\n\n  async getUserProfileByUsername(username: string) {\n    const { data, error } = await this.client.from('profiles').select().eq('username', username).maybeSingle();\n\n    if (error || !data) {\n      return error;\n    }\n\n    return data;\n  }\n\n  async seedResearch(profiles: DBProfile[], tagsData) {\n    const { categories } = await this.seedCategories('research');\n\n    const researchData: Partial<DBResearchItem & { tenant_id: string }>[] = [];\n\n    for (let i = 0; i < MOCK_DATA.research.length; i++) {\n      const item = MOCK_DATA.research[i];\n      const createdBy: number = profiles.find((profile) => profile.username === item.created_by_username)?.id || profiles[0].id;\n\n      researchData.push({\n        created_at: item.created_at,\n        deleted: item.deleted,\n        modified_at: item.modified_at,\n        description: item.description,\n        slug: item.slug,\n        previous_slugs: item.previous_slugs,\n        title: item.title,\n        status: item.status,\n        created_by: createdBy,\n        is_draft: item.is_draft,\n        published_at: item.is_draft ? null : item.created_at,\n        tags: [tagsData.data[0].id, tagsData.data[1].id],\n        category: categories.data[i % 2].id,\n        tenant_id: this.tenantId,\n      });\n    }\n\n    const { research } = await this.seedDatabase({\n      research: researchData,\n    });\n\n    for (let i = 0; i < MOCK_DATA.research.length; i++) {\n      const researchItem = MOCK_DATA.research[i];\n      const createdBy = profiles.find((profile) => profile.username === researchItem.created_by_username)?.id || profiles[0].id;\n\n      if (researchItem.updates) {\n        const { research_updates } = await this.seedDatabase({\n          research_updates: MOCK_DATA.researchUpdates.map((item) => ({\n            created_at: item.created_at,\n            deleted: item.deleted,\n            description: item.description,\n            modified_at: item.modified_at,\n            title: item.title,\n            research_id: research.data[i].id,\n            created_by: createdBy,\n            published_at: item.created_at,\n            tenant_id: this.tenantId,\n          })),\n        });\n\n        // Only seed comments for first research\n        if (i === 0) {\n          const { comments } = await this.seedComment(profiles, research_updates, 'research_update');\n\n          await this.seedReply(profiles, comments, research);\n        }\n      }\n    }\n  }\n\n  async seedCategories(type: string) {\n    return await this.seedDatabase({\n      categories: MOCK_DATA.categories.map((category) => ({\n        ...category,\n        type,\n        tenant_id: this.tenantId,\n      })),\n    });\n  }\n\n  async seedProfileTags() {\n    const response = await this.seedDatabase({\n      profile_tags: MOCK_DATA.profileTags.map((category) => ({\n        ...category,\n        tenant_id: this.tenantId,\n      })),\n    });\n\n    return response;\n  }\n\n  async seedProfileTypes() {\n    const response = await this.seedDatabase({\n      profile_types: MOCK_DATA.profileTypes.map((type) => ({\n        ...type,\n        tenant_id: this.tenantId,\n      })),\n    });\n\n    return response;\n  }\n\n  async seedTags() {\n    return await this.seedDatabase({\n      tags: MOCK_DATA.tags.map((category) => ({\n        ...category,\n        tenant_id: this.tenantId,\n      })),\n    });\n  }\n\n  async seedTenantSettings() {\n    return await this.seedDatabase({\n      tenant_settings: [\n        {\n          site_name: 'Test Site',\n          site_description: 'Test description',\n          site_url: 'https://community.preciousplastic.com',\n          academy_resource: 'https://onearmy.github.io/academy/',\n          color_primary: '#fee77b',\n          color_primary_hover: '#ffde45',\n          color_accent: '#fee77b',\n          color_accent_hover: '#ffde45',\n          show_impact: true,\n          create_research_roles: undefined,\n          ga_tracking_id: 'G-TEST123456',\n          tenant_id: this.tenantId,\n        },\n      ],\n    });\n  }\n\n  async seedQuestions(profiles) {\n    const { categories } = await this.seedCategories('questions');\n\n    const { questions } = await this.seedDatabase({\n      questions: MOCK_DATA.questions.map((question) => ({\n        ...question,\n        tenant_id: this.tenantId,\n        created_by: profiles[0].id,\n        category: categories.data[0].id,\n        published_at: question.created_at,\n      })),\n    });\n\n    const { comments } = await this.seedComment(profiles, questions, 'questions');\n    await this.seedUsefulVotes(profiles, questions, 'questions');\n    await this.seedReply(profiles, comments, questions);\n  }\n\n  async seedUsefulVotes(profiles, sourceData, sourceType) {\n    const usefulVotesData = await this.seedDatabase({\n      useful_votes: [\n        {\n          created_at: new Date().toUTCString(),\n          content_id: sourceData.data[0].id,\n          content_type: sourceType,\n          user_id: profiles[0].id,\n          tenant_id: this.tenantId,\n        },\n      ],\n    });\n    return usefulVotesData;\n  }\n\n  async seedComment(profiles, sourceData, sourceType) {\n    const commentData = await this.seedDatabase({\n      comments: [\n        {\n          tenant_id: this.tenantId,\n          created_at: new Date().toUTCString(),\n          comment: 'First comment',\n          created_by: profiles[0].id,\n          source_type: sourceType,\n          source_id: sourceData.data[0].id,\n        },\n      ],\n    });\n    return commentData;\n  }\n\n  async seedReply(profiles, comments, source) {\n    await this.seedDatabase({\n      comments: [\n        {\n          tenant_id: this.tenantId,\n          created_at: new Date().toUTCString(),\n          comment: 'First Reply',\n          created_by: profiles[0].id,\n          source_type: comments.data[0].source_type,\n          source_id: source.data[0].id,\n          parent_id: comments.data[0].id,\n        },\n      ],\n    });\n  }\n\n  async seedNews(profiles, tagsData) {\n    const { categories } = await this.seedCategories('news');\n\n    const { news } = await this.seedDatabase({\n      news: MOCK_DATA.news.map((news) => ({\n        ...news,\n        created_by: profiles[0].id,\n        tags: [tagsData.data[0].id, tagsData.data[1].id],\n        category: categories.data[0].id,\n        tenant_id: this.tenantId,\n        published_at: news.created_at,\n      })),\n    });\n\n    const { comments } = await this.seedComment(profiles, news, 'news');\n    await this.seedReply(profiles, comments, news);\n  }\n\n  async seedMap(profiles) {\n    const response = await this.seedDatabase({\n      map_pins: MOCK_DATA.mapPins.map((pin, index) => ({\n        profile_id: profiles[index].id,\n        tenant_id: this.tenantId,\n        ...pin,\n      })),\n    });\n\n    return response;\n  }\n\n  async seedLibrary(profiles, tagsData) {\n    const { categories } = await this.seedCategories('projects');\n\n    const projectsData = [];\n\n    for (let i = 0; i < MOCK_DATA.projects.length; i++) {\n      const item = MOCK_DATA.projects[i];\n\n      projectsData.push({\n        created_at: item.createdAt,\n        modified_at: item.modifiedAt,\n        title: item.title,\n        description: item.description,\n        slug: item.slug,\n        time: item.time,\n        difficulty_level: item.difficultyLevel,\n        created_by: profiles.find((x) => x.username === item.createdBy)?.id || null,\n        tags: [tagsData.data[0].id, tagsData.data[1].id],\n        category: categories.data[i % 2].id,\n        deleted: item.deleted,\n        moderation: item.moderation,\n        tenant_id: this.tenantId,\n        published_at: item.createdAt,\n        ...(item.moderationFeedback ? { moderation_feedback: item.moderationFeedback } : {}),\n      });\n    }\n\n    // seed projects\n    const { projects } = await this.seedDatabase({\n      projects: projectsData,\n    });\n\n    // seed steps\n    for (let i = 0; i < MOCK_DATA.projects.length; i++) {\n      const project = MOCK_DATA.projects[i];\n\n      if (project.steps && project.steps.length) {\n        await this.seedDatabase({\n          project_steps: project.steps.map((item) => ({\n            title: item.title,\n            project_id: projects.data[i].id,\n            description: item.description,\n            video_url: item.video_url,\n            tenant_id: this.tenantId,\n          })),\n        });\n      }\n    }\n\n    // seed comments\n    const { comments } = await this.seedComment(profiles, projects, 'projects');\n    await this.seedReply(profiles, comments, projects);\n  }\n\n  async seedBadges() {\n    const response = await this.seedDatabase({\n      profile_badges: MOCK_DATA.badges.map((badge) => ({\n        ...badge,\n        tenant_id: this.tenantId,\n      })),\n    });\n\n    return response;\n  }\n\n  async seedUpgradeBadges(profileBadges: DBProfileBadge[]) {\n    const proBadge = profileBadges.find((badge) => badge.name === 'pro');\n\n    if (!proBadge) {\n      return { upgrade_badge: { data: [] } };\n    }\n\n    const response = await this.seedDatabase({\n      upgrade_badge: [\n        {\n          tenant_id: this.tenantId,\n          action_label: 'Go PRO',\n          badge_id: proBadge.id,\n          is_space: true, // Only for workspaces\n          action_url: 'https://www.preciousplastic.com/pro-membership',\n        },\n      ],\n    });\n\n    return response;\n  }\n\n  async seedProfileImages(): Promise<{ id: string; path: string; fullPath: string }[]> {\n    const { data: image1Data } = await this.client.storage.from(this.tenantId).upload('profiles/image1.png', new Blob());\n\n    const { data: image2Data } = await this.client.storage.from(this.tenantId).upload('profiles/image2.png', new Blob());\n\n    return [image1Data, image2Data];\n  }\n\n  async seedAccounts(profileBadges, profileTags, profileTypes, profileImages) {\n    await this.deleteAccounts();\n\n    const accounts = Object.values(MOCK_DATA.users).map((user) => ({\n      ...user,\n      email: user['email'].replace('@', `+${this.tenantId}@`),\n      password: user['password'],\n    }));\n\n    const profiles: DBProfile[] = [];\n\n    for (const account of accounts) {\n      const profileType = profileTypes.find((t) => t.name === account.profileType) || profileTypes[0];\n      const profile = await this.createAuthAndProfile(\n        account,\n        profileBadges[0].id,\n        [profileTags[0].id, profileTags[1].id],\n        profileType.id,\n        profileImages,\n      );\n      profiles.push(profile);\n    }\n\n    return { profiles };\n  }\n\n  async createAuthAndProfile(user, profileBadgeId, profilTagIds, profileTypeId, profileImages) {\n    const authUser = await this.adminClient.auth.admin.createUser({\n      email: user.email,\n      password: user.password,\n      email_confirm: true,\n    });\n\n    let authId: string;\n\n    if (authUser.error?.code === 'email_exists' || authUser.error?.code === 'user_already_exists') {\n      // Shouldn't happen after deleteAccounts, but handle it gracefully\n      // Supabase lowercases emails on storage, so compare case-insensitively\n      console.warn(`User ${user.email} already exists after deleteAccounts - looking up...`);\n\n      let found: { id: string } | undefined;\n      let page = 1;\n\n      while (!found) {\n        const { data, error } = await this.adminClient.auth.admin.listUsers({ perPage: 1000, page });\n        if (error) throw new Error(`listUsers failed: ${error.message}`);\n        if (!data.users.length) break;\n        found = data.users.find(u => u.email?.toLowerCase() === user.email.toLowerCase());\n        if (data.users.length < 1000) break;\n        page++;\n      }\n\n      if (!found) throw new Error(`User ${user.email} not found after email_exists error`);\n      authId = found.id;\n\n    } else if (authUser.error) {\n      throw new Error(`Failed to create user ${user.email}: ${authUser.error.message}`);\n    } else {\n      authId = authUser.data.user.id;\n    }\n\n    return await this.createProfile(user, authId, profileBadgeId, profilTagIds, profileTypeId, profileImages);\n  }\n\n  async createProfile(\n    user: Partial<Profile>,\n    authId: string,\n    profileBadgeId: number,\n    profilTagIds: number[],\n    profileTypeId: number,\n    profileImages: { id: string; path: string; fullPath: string }[],\n  ) {\n    const { data } = await this.adminClient.from('profiles').select('*').eq('auth_id', authId).eq('tenant_id', this.tenantId).maybeSingle();\n\n    if (data) {\n      console.log(`Profile already exists for ${user.username}, reusing...`);\n      return data;\n    }\n\n    console.log(`Creating profile for ${user.username} with auth_id ${authId} and roles:`, user.roles);\n\n    const profileDB: Partial<DBProfile> & { tenant_id: string } = {\n      created_at: user.createdAt,\n      auth_id: authId,\n      display_name: user.displayName,\n      username: user.username,\n      roles: user.roles,\n      tenant_id: this.tenantId,\n      profile_type: profileTypeId,\n      about: user.about || '',\n      photo: user.photo ? profileImages[0] : null,\n      country: user.country,\n      cover_images: user.coverImages || ([] as any),\n      impact: JSON.stringify(user.impact) || null,\n      is_blocked_from_messaging: user.isBlockedFromMessaging || false,\n      is_contactable: user.isContactable || true,\n      last_active: user.lastActive || null,\n      website: user.website || null,\n    };\n\n    const profileResult = await this.adminClient.from('profiles').insert(profileDB).select('*');\n\n    if (profileResult.error) {\n      throw new Error(`Failed to create profile for ${user.username}: ${profileResult.error.message}`);\n    }\n\n    if (!profileResult.data || profileResult.data.length === 0) {\n      throw new Error(`Profile creation returned no data for ${user.username}`);\n    }\n\n    if (profileResult.data[0].username === 'demo_user') {\n      await this.seedDatabase({\n        profile_badges_relations: [\n          {\n            profile_id: profileResult.data[0].id,\n            profile_badge_id: profileBadgeId,\n            tenant_id: this.tenantId,\n          },\n        ],\n      });\n    }\n\n    await Promise.all(\n      profilTagIds.map(async (profileTag) => {\n        return this.seedDatabase({\n          profile_tags_relations: [\n            {\n              profile_id: profileResult.data[0].id,\n              profile_tag_id: profileTag,\n              tenant_id: this.tenantId,\n            },\n          ],\n        });\n      }),\n    );\n\n    return profileResult.data[0];\n  }\n}\n"
  },
  {
    "path": "packages/cypress/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es2020\",\n    \"module\": \"NodeNext\",\n    \"lib\": [\"ESNext\", \"dom\"],\n    \"types\": [\"cypress\", \"node\"],\n    \"moduleResolution\": \"NodeNext\",\n    \"esModuleInterop\": true,\n    \"resolveJsonModule\": true,\n    \"jsx\": \"react\"\n  },\n  \"include\": [\"**/*.ts\", \"**/*.mts\"]\n}\n"
  },
  {
    "path": "packages/themes/.gitignore",
    "content": "node_modules\ndist"
  },
  {
    "path": "packages/themes/assets/fonts/README.md",
    "content": "# Fonts\n\nCustom fonts are imported in [app.globalStyle.js](src\\themes\\app.globalStyle.js)\nThis allows them to be more easily combined in the build and work in harmony with the styled components system\n\nSee article for more info: https://dev.to/alaskaa/how-to-import-a-web-font-into-your-react-app-with-styled-components-4-1dni\n\nNote - as the platform relies on modern browser feature legacy font formats (ttf, otf) can mostly be ignored in favour of woff2 and woff\n\nLegacy (css) implementation:\nDefine a `fonts.css` file\n\n<!--- spell-checker: disable --->\n\n```\n@font-face {\n  font-family: 'Varela Round';\n  src: url('./VarelaRound-Regular.eot');\n  src: url('./VarelaRound-Regular.woff') format('woff'),\n    url('./VarelaRound-Regular.ttf') format('truetype');\n  font-weight: normal;\n  font-style: normal;\n}\n\n@font-face {\n  font-family: 'Inter';\n  src: url('./Inter-Regular.otf');\n  src: url('./Inter-Regular.woff') format('woff'),\n    url('./Inter-Regular.woff2') format('woff2'),\n    url('./Inter-Regular.ttf') format('truetype');\n  font-weight: normal;\n  font-style: normal;\n}\n\n@font-face {\n  font-family: 'Inter';\n  src: url('./Inter-Medium.otf');\n  src: url('./Inter-Medium.woff') format('woff'),\n    url('./Inter-Medium.woff2') format('woff2'),\n    url('./Inter-Medium.ttf') format('truetype');\n  font-weight: bold;\n  font-style: normal;\n}\n\n```\n\n<!--- spell-checker: enable -->\n"
  },
  {
    "path": "packages/themes/package.json",
    "content": "{\n  \"name\": \"oa-themes\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"exports\": {\n    \".\": {\n      \"bun\": \"./src/index.ts\",\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/index.js\",\n      \"default\": \"./dist/index.js\"\n    },\n    \"./*\": {\n      \"bun\": \"./src/*.ts\",\n      \"import\": \"./dist/*.js\",\n      \"require\": \"./dist/*.js\",\n      \"default\": \"./dist/*.js\"\n    }\n  },\n  \"main\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./src/index.ts\", \"./src/*\"]\n    }\n  },\n  \"scripts\": {\n    \"build\": \"tsc --build --verbose\",\n    \"dev\": \"tsc --watch\"\n  },\n  \"devDependencies\": {\n    \"typescript\": \"^5.7.2\"\n  }\n}\n"
  },
  {
    "path": "packages/themes/src/common/button.ts",
    "content": "import { commonStyles } from './commonStyles';\n\nconst BASE_BUTTON = {\n  fontFamily: '\"Varela Round\", Arial, sans-serif',\n  fontSize: 3,\n  display: 'inline-flex',\n  cursor: 'pointer',\n  alignItems: 'center',\n  position: 'relative',\n  transition: '.2s ease-in-out',\n  borderRadius: 1,\n  width: 'auto',\n  border: '2px solid',\n  height: '2.75rem',\n};\n\nconst { black, white, softblue, red2, betaGreen, blue, grey, background, lightgrey } =\n  commonStyles.colors;\n\nexport const buttons = {\n  primary: {\n    ...BASE_BUTTON,\n    color: black,\n    backgroundColor: 'var(--color-primary)',\n    '&:hover': {\n      backgroundColor: 'var(--color-primary-hover)',\n    },\n    '&[disabled]': {\n      opacity: 0.5,\n      cursor: 'not-allowed',\n    },\n    '&[disabled]:hover': {\n      backgroundColor: 'var(--color-primary)',\n    },\n  },\n  disabled: {\n    ...BASE_BUTTON,\n    color: black,\n    backgroundColor: 'var(--color-primary)',\n    opacity: 0.5,\n    '&:hover': {\n      backgroundColor: 'var(--color-primary-hover)',\n    },\n  },\n  secondary: {\n    ...BASE_BUTTON,\n    border: '2px solid ' + black,\n    color: black,\n    backgroundColor: softblue,\n    '&:hover': {\n      backgroundColor: white,\n      cursor: 'pointer',\n    },\n    '&[disabled]': {\n      opacity: 0.5,\n    },\n    '&[disabled]:hover': {\n      backgroundColor: softblue,\n    },\n  },\n  destructive: {\n    ...BASE_BUTTON,\n    border: '2px solid ' + black,\n    backgroundColor: red2,\n    color: black,\n    '&:hover': {\n      backgroundColor: white,\n    },\n    '&[disabled]': {\n      opacity: 0.5,\n      cursor: 'not-allowed',\n    },\n    '&[disabled]:hover': {\n      backgroundColor: red2,\n    },\n  },\n  success: {\n    ...BASE_BUTTON,\n    border: '2px solid ' + black,\n    backgroundColor: betaGreen,\n    color: black,\n    '&:hover': {\n      filter: 'brightness(90%)',\n    },\n    '&[disabled]': {\n      opacity: 0.5,\n      cursor: 'not-allowed',\n    },\n    '&[disabled]:hover': {\n      backgroundColor: betaGreen,\n    },\n  },\n  info: {\n    ...BASE_BUTTON,\n    border: '2px solid ' + black,\n    backgroundColor: blue,\n    color: black,\n    '&:hover': {\n      filter: 'brightness(90%)',\n    },\n    '&[disabled]': {\n      opacity: 0.5,\n      cursor: 'not-allowed',\n    },\n    '&[disabled]:hover': {\n      backgroundColor: blue,\n    },\n  },\n  outline: {\n    ...BASE_BUTTON,\n    border: '2px solid ' + black,\n    color: black,\n    backgroundColor: 'transparent',\n    '&:hover': {\n      backgroundColor: softblue,\n    },\n  },\n  quiet: {\n    ...BASE_BUTTON,\n    border: '2px dashed ' + grey,\n    color: grey,\n    backgroundColor: 'transparent',\n    '&:hover': {\n      backgroundColor: background,\n    },\n    fontSize: 1,\n  },\n  imageInput: {\n    border: '2px dashed #e0e0e0',\n    color: '#e0e0e0',\n    backgroundColor: 'transparent',\n  },\n  subtle: {\n    ...BASE_BUTTON,\n    borderWidth: 0,\n    color: black,\n    backgroundColor: 'transparent',\n    '&:hover': {\n      backgroundColor: softblue,\n    },\n    '&[disabled]': {\n      opacity: 0.5,\n    },\n    '&[disabled]:hover': {\n      backgroundColor: softblue,\n    },\n  },\n  breadcrumb: {\n    ...BASE_BUTTON,\n    padding: '1',\n    border: '1px solid transparent',\n    backgroundColor: 'transparent',\n    height: 'auto',\n    color: 'dimgray',\n    fontSize: 15,\n    '&:hover': {\n      backgroundColor: softblue,\n      border: '1px solid ' + lightgrey,\n    },\n  },\n};\n\nexport type ButtonVariants = 'primary' | 'secondary' | 'outline' | 'disabled' | 'subtle';\n"
  },
  {
    "path": "packages/themes/src/common/commonStyles.ts",
    "content": "export const commonStyles = {\n  input: {\n    borderRadius: 1,\n    '&:focus': {\n      borderColor: '#83ceeb',\n      outline: 'none',\n      boxShadow: 'none',\n    },\n    '&:disabled': {\n      color: 'lightgrey',\n      cursor: 'not-allowed',\n    },\n  },\n  colors: {\n    activeYellow: '#fee77b',\n    white: '#fff',\n    offWhite: '#ececec',\n    black: '#1b1b1b',\n    softyellow: '#f5ede2',\n    blue: '#83ceeb',\n    red: '#eb1b1f',\n    red2: '#f58d8e',\n    softblue: '#e2edf7',\n    bluetag: '#5683b0',\n    grey: '#61646b',\n    green: '#00c3a9',\n    error: 'red',\n    background: '#f4f6f7',\n    silver: '#c0c0c0',\n    softgrey: '#c2c2c2',\n    lightgrey: '#ababac',\n    darkGrey: '#686868',\n    subscribed: 'orange',\n    notSubscribed: '#1b1b1b',\n    betaGreen: '#98cc98',\n  },\n  alert: {\n    borderRadius: 1,\n    paddingX: 3,\n    paddingY: 3,\n    textAlign: 'center',\n    fontWeight: 'normal',\n  },\n  fontFamily: {\n    title: `\"Varela Round\", Arial, sans-serif`,\n    body: `'Inter', Arial, sans-serif`,\n  },\n  space: [\n    0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100, 105, 110,\n    115, 120, 125, 130, 135, 140,\n  ],\n};\n"
  },
  {
    "path": "packages/themes/src/common/index.ts",
    "content": "import { commonStyles } from './commonStyles';\n\nexport type Colors = keyof typeof baseTheme.colors;\n\nexport const baseTheme = {\n  colors: {\n    ...commonStyles.colors,\n    // Theme-specific colors use CSS variables for dynamic theming\n    primary: 'var(--color-primary)',\n    primaryHover: 'var(--color-primary-hover)',\n    accent: 'var(--color-accent)',\n    accentHover: 'var(--color-accent-hover)',\n  },\n  zIndex: {\n    behind: -1,\n    level: 0,\n    default: 1,\n    modalProfile: 900,\n    logoContainer: 999,\n    header: 3000,\n  },\n  text: {\n    heading: {\n      fontFamily: '\"Varela Round\", Arial, sans-serif',\n      fontSize: 6,\n      fontWeight: 'normal',\n    },\n    small: {\n      fontFamily: '\"Varela Round\", Arial, sans-serif',\n      fontSize: 4,\n      fontWeight: 'normal',\n    },\n    subHeading: {\n      fontFamily: '\"Varela Round\", Arial, sans-serif',\n      fontSize: 3,\n      fontWeight: 'bold',\n    },\n    body: {\n      fontFamily: `'Inter', Arial, sans-serif`,\n    },\n    quiet: {\n      fontFamily: `'Inter', Arial, sans-serif`,\n      color: 'grey',\n    },\n    auxiliary: {\n      fontFamily: '\"Inter\", Helvetica Neue, Arial, sans-serif;',\n      fontSize: 1,\n      color: commonStyles.colors.grey,\n    },\n    paragraph: {\n      fontFamily: '\"Inter\", Helvetica Neue, Arial, sans-serif;',\n      fontSize: '16px',\n      color: commonStyles.colors.grey,\n    },\n    h1: {\n      fontFamily: '\"Varela Round\", Arial, sans-serif',\n      fontSize: 6,\n      fontWeight: 'normal',\n    },\n    h2: {\n      fontFamily: '\"Varela Round\", Arial, sans-serif',\n      fontSize: 5,\n      fontWeight: 'normal',\n    },\n    h3: {\n      fontFamily: '\"Varela Round\", Arial, sans-serif',\n      fontSize: 4,\n      fontWeight: 'normal',\n    },\n  },\n  space: commonStyles.space,\n  radii: commonStyles.space,\n  fonts: commonStyles.fontFamily,\n\n  forms: {\n    input: {\n      ...commonStyles.input,\n      background: commonStyles.colors.background,\n      border: '1px solid transparent',\n      fontFamily: commonStyles.fontFamily.body,\n      fontSize: 1,\n    },\n    inputOutline: {\n      ...commonStyles.input,\n      background: 'white',\n      border: `2px solid ${commonStyles.colors.black}`,\n    },\n    error: {\n      ...commonStyles.input,\n      background: commonStyles.colors.background,\n      border: `1px solid ${commonStyles.colors.error}`,\n      fontFamily: commonStyles.fontFamily.body,\n      fontSize: 1,\n    },\n    textarea: {\n      ...commonStyles.input,\n      background: commonStyles.colors.background,\n      border: `2px solid transparent`,\n      fontFamily: commonStyles.fontFamily.title,\n      fontSize: 1,\n      padding: 2,\n    },\n    textareaError: {\n      ...commonStyles.input,\n      background: commonStyles.colors.background,\n      border: `2px solid ${commonStyles.colors.error}`,\n      fontFamily: commonStyles.fontFamily.title,\n      fontSize: 1,\n      padding: 2,\n    },\n    select: {\n      ...commonStyles.input,\n      border: `2px solid ${commonStyles.colors.black}`,\n      padding: 3,\n    },\n  },\n  maxContainerWidth: 1280,\n  sizes: {\n    container: 1280,\n  },\n  regular: 400,\n  bold: 600,\n  fontSizes: [10, 12, 14, 16, 18, 22, 30, 38, 42, 46, 50, 58, 66, 74],\n  cards: {\n    primary: {\n      background: 'white',\n      border: `2px solid ${commonStyles.colors.black}`,\n      borderRadius: 1,\n      overflow: 'hidden',\n    },\n    responsive: {\n      background: 'white',\n      border: ['none', `2px solid ${commonStyles.colors.black}`],\n      borderTop: `2px solid ${commonStyles.colors.black}`,\n      borderBottom: `2px solid ${commonStyles.colors.black}`,\n      borderRadius: [0, 2],\n      overflow: 'hidden',\n    },\n    borderless: {\n      background: 'white',\n      border: 'none',\n      borderRadius: 2,\n      overflow: 'hidden',\n    },\n  },\n  breakpoints: ['40em', '52em', '70em'], // standard widths: 512px, 768px, 1024px\n  alerts: {\n    success: {\n      ...commonStyles.alert,\n      backgroundColor: commonStyles.colors.green,\n    },\n    failure: {\n      ...commonStyles.alert,\n      backgroundColor: commonStyles.colors.red2,\n    },\n    info: {\n      ...commonStyles.alert,\n      backgroundColor: commonStyles.colors.softblue,\n      color: commonStyles.colors.black,\n    },\n    warning: {\n      ...commonStyles.alert,\n      backgroundColor: commonStyles.colors.activeYellow,\n      color: commonStyles.colors.black,\n    },\n  },\n};\n"
  },
  {
    "path": "packages/themes/src/fonts/fonts.d.ts",
    "content": "declare module '*.woff';\ndeclare module '*.woff2';\ndeclare module '*.ttf';\n"
  },
  {
    "path": "packages/themes/src/fonts/index.ts",
    "content": "import InterRegular_ttf from '../../assets/fonts/Inter-Regular.ttf';\nimport InterRegular_woff from '../../assets/fonts/Inter-Regular.woff';\nimport InterRegular_woff2 from '../../assets/fonts/Inter-Regular.woff2';\nimport InterSemiBold_ttf from '../../assets/fonts/Inter-SemiBold.ttf';\nimport InterSemiBold_woff2 from '../../assets/fonts/Inter-SemiBold.woff2';\nimport VarelaRound_ttf from '../../assets/fonts/VarelaRound-Regular.ttf';\nimport VarelaRound_woff from '../../assets/fonts/VarelaRound-Regular.woff';\n\n// declare global styling overrides (fonts etc.)\n\nexport const GlobalFonts = `\n  @font-face {\n    font-family: 'Varela Round';\n    font-display: auto;\n    src:  url(\"${VarelaRound_woff}\") format('woff'),\n          url(\"${VarelaRound_ttf}\") format('truetype');\n    font-weight: normal;\n    font-style: normal;\n  }\n  \n  @font-face {\n    font-family: 'Inter';\n    font-display: auto;\n    src:  url(\"${InterRegular_woff2}\") format('woff2'),\n          url(\"${InterRegular_woff}\") format('woff'),\n          url(\"${InterRegular_ttf}\") format('truetype');\n    font-weight: normal;\n    font-style: normal;\n  }\n  \n  @font-face {\n    font-family: 'Inter';\n    font-display: auto;\n    src:  url(\"${InterSemiBold_woff2}\") format('woff2'),\n          url(\"${InterSemiBold_ttf}\") format('truetype');\n    font-weight: bold;\n    font-style: normal;\n  }\n`;\n"
  },
  {
    "path": "packages/themes/src/index.ts",
    "content": "import { baseTheme } from './common';\nimport { buttons } from './common/button';\nimport { commonStyles } from './common/commonStyles';\n\nimport type { PlatformTheme } from './types';\n\n// Export single theme that uses CSS variables for dynamic theming\nexport const theme: PlatformTheme = {\n  ...baseTheme,\n  buttons,\n};\n\n// Keep commonStyles export for backward compatibility\nexport { commonStyles };\n\nexport type { Colors } from './common';\nexport type { ButtonVariants } from './common/button';\nexport { GlobalFonts } from './fonts';\n\nexport type { PlatformTheme } from './types';\n"
  },
  {
    "path": "packages/themes/src/types/index.ts",
    "content": "export interface PlatformTheme {\n  text: any;\n  colors: {\n    primary: string;\n    primaryHover: string;\n    accent: string;\n    accentHover: string;\n    white: string;\n    black: string;\n    softyellow: string;\n    blue: string;\n    red: string;\n    red2: string;\n    softblue: string;\n    bluetag: string;\n    grey: string;\n    green: string;\n    error: string;\n    background: string;\n    silver: string;\n    softgrey: string;\n    offWhite: string;\n    lightgrey: string;\n    darkGrey: string;\n    subscribed: string;\n    notSubscribed: string;\n    betaGreen: string;\n  };\n\n  fontSizes: number[];\n\n  space: number[];\n  radii: number[];\n\n  zIndex: {\n    behind: number;\n    level: number;\n    default: number;\n    modalProfile: number;\n    logoContainer: number;\n    header: number;\n  };\n  breakpoints: string[];\n  buttons: any;\n  maxContainerWidth: number;\n  regular: number;\n  bold: number;\n\n  sizes: {\n    container: number;\n  };\n}\n"
  },
  {
    "path": "packages/themes/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"target\": \"es2021\",\n    \"jsx\": \"react-jsx\",\n    \"module\": \"ES6\",\n    \"moduleResolution\": \"node\",\n    \"rootDir\": \"./src\",\n    \"declaration\": true,\n    \"outDir\": \"./dist\",\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"strict\": true,\n    \"strictPropertyInitialization\": false,\n    \"skipLibCheck\": true\n  },\n  \"include\": [\"src\", \"src/types\"]\n}\n"
  },
  {
    "path": "react-router.config.ts",
    "content": "import type { Config } from '@react-router/dev/config';\n\nexport default {\n  ssr: true,\n  appDirectory: './src',\n} satisfies Config;\n"
  },
  {
    "path": "seed/profilesSeed.ts",
    "content": "import type { profilesChildInputs, profilesScalars } from '@snaplet/seed';\n\nconst _PROFILES_BASE: (tenant_id: string) => Partial<profilesScalars> = (tenant_id: string) => ({\n  tenant_id,\n  type: 'member',\n  roles: [],\n  photo: null,\n  patreon: null,\n  impact: null,\n  is_blocked_from_messaging: false,\n  is_contactable: true,\n  total_views: 0,\n});\n\nexport const profilesSeed = (tenant_id: string): profilesChildInputs => [\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'jereerickson92',\n    display_name: 'Jere Erickson',\n    country: 'Portugal',\n    about:\n      \"Passionate about creating meaningful connections and exploring new experiences. Whether it's traveling to new destinations, trying new foods, or diving into fresh hobbies, I'm all about embracing the adventure in life. Let's chat and share our stories!\",\n  },\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'aldaplaskett48',\n    display_name: 'Alda Plaskett',\n    country: 'Spain',\n    about:\n      \"Tech enthusiast, avid reader, and coffee lover. I spend my days coding, learning about the latest trends, and finding ways to innovate. Always up for a deep conversation or a good book recommendation. Let's connect and exchange ideas!\",\n  },\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'sampathpini67',\n    display_name: 'Sampath Pini',\n    country: 'France',\n    about:\n      \"Fitness junkie who believes in the power of mental and physical health. When I'm not in the gym, you can find me hiking, practicing yoga, or experimenting with healthy recipes. Looking for like-minded people who value balance and growth. Let's inspire each other!\",\n  },\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'galenagiugovaz15',\n    display_name: 'Galena Giugovaz',\n    country: 'Sudan',\n    about:\n      \"Curious traveler with a passion for photography and storytelling. Exploring the world, one city at a time, while capturing moments that tell unique stories. I believe that life is all about experiences and the memories we create. Let's share the journey!\",\n  },\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'veniaminjewell33',\n    display_name: 'Veniamin Jewell',\n    country: 'Tuvalu',\n    about:\n      \"Creative soul with a love for design and innovation. I'm always experimenting with new ways to bring ideas to life—whether it's through art, technology, or writing. Let's collaborate and create something beautiful together!\",\n  },\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'cortneybrown81',\n    display_name: 'Cortney Brown',\n    country: 'Ukraine',\n    about:\n      \"Outgoing and energetic, I'm always looking for new adventures and opportunities to grow. Whether it's trying a new hobby or tackling an exciting project, I'm up for anything. Join me on my journey, and let's make the most out of every moment!\",\n  },\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'melisavang56',\n    display_name: 'Melisa Vang',\n    country: 'Uruguay',\n    about:\n      \"Music is my life, and I'm constantly seeking out new sounds and genres to explore. From playing instruments to attending live shows, I live and breathe rhythm. If you're passionate about music or just want to chat about the latest trends, let's connect!\",\n  },\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'lianabegam24',\n    display_name: 'Liana Begam',\n    country: 'United States',\n    about:\n      \"Outdoor enthusiast and nature lover who finds peace in hiking, camping, and exploring the great outdoors. When I'm not soaking up the beauty of nature, you'll find me sharing my love for the environment with others. Looking to meet fellow adventurers!\",\n  },\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'akromstarkova72',\n    display_name: 'Akrom Stárková',\n    country: 'Yemen',\n    about:\n      \"Ambitious and driven, I strive to make the most out of every opportunity. Whether it's working on personal projects or helping others achieve their goals, I believe in continuous growth and learning. Let's build a community of success together!\",\n  },\n  {\n    ..._PROFILES_BASE(tenant_id),\n    username: 'mirzoblazkova19',\n    display_name: 'Mirzo Blažková',\n    country: 'Zimbabwe',\n    about:\n      \"Bookworm with a passion for storytelling and deep discussions. I'm always immersed in a good novel or seeking out new perspectives on life. Looking to connect with fellow book lovers or anyone interested in meaningful conversations. Let's dive into the world of words together!\",\n  },\n];\n"
  },
  {
    "path": "seed/usersSeed.ts",
    "content": "import { copycat } from '@snaplet/copycat';\n\nexport const usersSeed = () => [\n  {\n    email: 'jereerickson92@seed.com',\n    password: copycat.password({}),\n  },\n  {\n    email: 'aldaplaskett48@seed.com',\n    password: copycat.password({}),\n  },\n  {\n    email: 'sampathpini67@seed.com',\n    password: copycat.password({}),\n  },\n  {\n    email: 'galenagiugovaz15@seed.com',\n    password: copycat.password({}),\n  },\n  {\n    email: 'veniaminjewell33@seed.com',\n    password: copycat.password({}),\n  },\n  {\n    email: 'cortneybrown81@seed.com',\n    password: copycat.password({}),\n  },\n  {\n    email: 'melisavang56@seed.com',\n    password: copycat.password({}),\n  },\n  {\n    email: 'lianabegam24@seed.com',\n    password: copycat.password({}),\n  },\n  {\n    email: 'akromstarkova72@seed.com',\n    password: copycat.password({}),\n  },\n  {\n    email: 'mirzoblazkova19@seed.com',\n    password: copycat.password({}),\n  },\n];\n"
  },
  {
    "path": "seed.config.ts",
    "content": "import { SeedPg } from '@snaplet/seed/adapter-pg';\nimport { defineConfig } from '@snaplet/seed/config';\nimport { Client } from 'pg';\n\nexport default defineConfig({\n  adapter: async () => {\n    const client = new Client({\n      connectionString: 'postgresql://postgres:postgres@localhost:54322/postgres',\n    });\n    await client.connect();\n    return new SeedPg(client);\n  },\n  select: ['!*', 'public.*', 'auth.users', 'auth.identities', 'storage.buckets'],\n});\n"
  },
  {
    "path": "seed.sql",
    "content": ""
  },
  {
    "path": "seed.ts",
    "content": "import type {\n  categoriesChildInputs,\n  categoriesScalars,\n  map_pinsChildInputs,\n  newsScalars,\n  profile_badges_relationsScalars,\n  profile_badgesScalars,\n  profile_typesScalars,\n  profilesInputs,\n  profilesScalars,\n  project_stepsChildInputs,\n  projectsScalars,\n  questionsChildInputs,\n  questionsScalars,\n  researchScalars,\n  subscribersChildInputs,\n  subscribersScalars,\n  tagsChildInputs,\n  tagsScalars,\n  upgrade_badgeScalars,\n  useful_votesChildInputs,\n  useful_votesScalars,\n} from '@snaplet/seed';\nimport { createSeedClient } from '@snaplet/seed';\nimport libraryJson from './.snaplet/library.json';\nimport questionsJson from './.snaplet/questions.json';\nimport { profilesSeed } from './seed/profilesSeed';\nimport { usersSeed } from './seed/usersSeed';\nimport { convertToSlug } from './src/utils/slug';\n\nconst tenant_id = `precious-plastic`;\n\nconst _QUESTIONS_BASE: Partial<questionsScalars> = {\n  tenant_id,\n  moderation: 'accepted',\n  legacy_id: null,\n  previous_slugs: [],\n  images: [],\n  deleted: false,\n  tags: [],\n};\n\nconst _PROJECT_BASE: Partial<projectsScalars> = {\n  tenant_id,\n  moderation: 'accepted',\n  legacy_id: null,\n  previous_slugs: [],\n  deleted: false,\n  tags: [],\n  is_draft: false,\n  file_download_count: 0,\n  total_views: 0,\n  comment_count: 0,\n};\n\nconst _PROJECT_STEP_BASE = {\n  tenant_id,\n  images: null,\n  video_url: null,\n};\n\nconst _CATEGORIES_BASE: Partial<categoriesScalars> = {\n  tenant_id,\n  legacy_id: null,\n};\n\nconst _TAGS_BASE: Partial<tagsScalars> = {\n  tenant_id,\n  legacy_id: null,\n};\n\nconst _SUBSCRIBERS_BASE: Partial<subscribersScalars> = {\n  tenant_id,\n  content_type: 'questions',\n};\n\nconst _USEFUL_VOTES_BASE: Partial<useful_votesScalars> = {\n  tenant_id,\n  content_type: 'questions',\n};\n\nconst _BADGES_BASE: Partial<profile_badgesScalars> = {\n  tenant_id,\n};\n\nconst _TYPES_BASE: Partial<profile_typesScalars> = {\n  tenant_id,\n};\n\nconst _BADGES_RELATIONS_BASE: Partial<profile_badges_relationsScalars> = {\n  tenant_id,\n};\n\nconst seedTags = (): tagsChildInputs => [{ ..._TAGS_BASE, name: 'tag 1' }];\n\nconst seedProfileTypes = (): Partial<profile_typesScalars>[] => [\n  {\n    ..._TYPES_BASE,\n    name: 'member',\n    display_name: 'Member',\n    is_space: false,\n    description: '',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-member.svg',\n    map_pin_name: 'Want to get started',\n    order: 1,\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/map-member.svg',\n  },\n  {\n    ..._TYPES_BASE,\n    name: 'workspace',\n    display_name: 'Workspace',\n    is_space: true,\n    description: '',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-workspace.svg',\n    map_pin_name: 'Workspace',\n    order: 2,\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-workspace-small.svg',\n  },\n  {\n    ..._TYPES_BASE,\n    name: 'machine-builder',\n    display_name: 'Machine Builder',\n    is_space: true,\n    description: '',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-machine.svg',\n    map_pin_name: 'Machine Builder',\n    order: 3,\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-machine-small.svg',\n  },\n  {\n    ..._TYPES_BASE,\n    name: 'community-point',\n    display_name: 'Community Point',\n    is_space: true,\n    description: '',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-community.svg',\n    map_pin_name: 'Community Point',\n    order: 4,\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-community-small.svg',\n  },\n  {\n    ..._TYPES_BASE,\n    name: 'collection-point',\n    display_name: 'Collection Point',\n    is_space: true,\n    description: '',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-collection.svg',\n    map_pin_name: 'Collection Point',\n    order: 5,\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-collection-small.svg',\n  },\n];\n\nconst seedBadges = (): Partial<profile_badgesScalars>[] => [\n  {\n    ..._BADGES_BASE,\n    name: 'supporter',\n    display_name: 'Supporter',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/icons/supporter.svg',\n  },\n  {\n    ..._BADGES_BASE,\n    name: 'pro',\n    display_name: 'PRO',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/icons/pro.svg',\n    premium_tier: 1,\n  },\n];\n\nconst seedUpgradeBadges = (badges: profile_badgesScalars[]): Partial<upgrade_badgeScalars>[] => {\n  const proBadge = badges.find((badge) => badge.name === 'pro');\n  // const supporterBadge = badges.find((badge) => badge.name === 'supporter');\n\n  const upgradeBadges: Partial<upgrade_badgeScalars>[] = [];\n\n  if (proBadge) {\n    upgradeBadges.push({\n      tenant_id,\n      action_label: 'Go PRO',\n      badge_id: proBadge.id,\n      is_space: true,\n      action_url: 'https://www.preciousplastic.com/pro-membership',\n    });\n  }\n\n  return upgradeBadges;\n};\n\n/// populates badges: 2/3 of profiles to have: 1 and 2 badges, others remain with no badge\nconst seedBadgesRelations = (\n  profiles: profilesScalars[],\n  badges: profile_badgesScalars[],\n): Partial<profile_badges_relationsScalars>[] => {\n  const relations: Partial<profile_badges_relationsScalars>[] = [];\n\n  const profilesPerGroup = Math.ceil(profiles.length / 3);\n  const oneBadgeGroup = profiles.slice(profilesPerGroup, profilesPerGroup * 2);\n  const twoBadgesGroup = profiles.slice(profilesPerGroup * 2);\n\n  // 1 badge each\n  for (const profile of oneBadgeGroup) {\n    relations.push({\n      ..._BADGES_RELATIONS_BASE,\n      profile_id: profile.id,\n      profile_badge_id: badges[0].id,\n    });\n  }\n\n  // all (2) badges each\n  for (const profile of twoBadgesGroup) {\n    for (let i = 0; i < badges.length; i++) {\n      relations.push({\n        ..._BADGES_RELATIONS_BASE,\n        profile_id: profile.id,\n        profile_badge_id: badges[i].id,\n      });\n    }\n  }\n\n  return relations;\n};\n\nconst seedCategories = (): categoriesChildInputs => [\n  { ..._CATEGORIES_BASE, name: 'Questions', type: 'questions' },\n  { ..._CATEGORIES_BASE, name: 'Research', type: 'research' },\n  { ..._CATEGORIES_BASE, name: 'Guides', type: 'projects' },\n  { ..._CATEGORIES_BASE, name: 'Machines', type: 'projects' },\n  { ..._CATEGORIES_BASE, name: 'Moulds', type: 'projects' },\n  { ..._CATEGORIES_BASE, name: 'Products', type: 'projects' },\n  { ..._CATEGORIES_BASE, name: 'Important Updates', type: 'news' },\n];\n\nconst seedQuestions = (profile: profilesInputs): questionsChildInputs =>\n  questionsJson[profile.username!.toString()]?.map((q) => ({\n    ..._QUESTIONS_BASE,\n    slug: convertToSlug(q.title),\n    title: q.title,\n    description: q.description,\n    comment_count: q.comments?.length || 0,\n  })) || [];\n\nconst seedSubscribers = (questions: questionsScalars[]): subscribersChildInputs =>\n  questions.map((question) => ({\n    ..._SUBSCRIBERS_BASE,\n    content_id: question.id,\n  }));\n\nconst seedUsefulVotes = (questions: questionsScalars[]): useful_votesChildInputs =>\n  questions.map((question) => ({\n    ..._USEFUL_VOTES_BASE,\n    content_id: question.id,\n  }));\n\nconst bigBlockOfMarkdown = `# h1 Heading 8-)\n\n## h2 Heading\n\n### h3 Heading\n\n#### h4 Heading\n\n##### h5 Heading\n\n###### h6 Heading\n\n## Horizontal Rule\n\n***\n\n## Typographic replacements\n\nEnable typographer option to see result.\n\n(c) (C) (r) (R) (tm) (TM) (p) (P) +-\n\ntest.. test... test..... test?..... test!....\n\n!!!!!! ???? ,,  -- ---\n\n\"Smartypants, double quotes\" and 'single quotes'\n\n![Image](https://firebasestorage.googleapis.com/v0/b/precious-plastics-v4-dev.appspot.com/o/uploads%2Fusers%2Fbenfurber%2F_DSC4756-19271d67d1f-19610bd3504.jpg?alt=media&token=27751401-8000-4592-9f7e-800497463dce)\n\n## Emphasis\n\n**This is bold text**\n\n**This is bold text**\n\n*This is italic text*\n\n*This is italic text*\n\n~~Strikethrough~~\n\n## Blockquotes\n\n> Blockquotes can also be nested...> ...by using additional greater-than signs right next to each other...> ...or with spaces between arrows.\n\n## Lists\n\nUnordered\n\n* Sub-lists are made by indenting 2 spaces:\n  * Marker character change forces new list start:\n    * Ac tristique libero volutpat at\n    - Facilisis in pretium nisl aliquet\n    * Nulla volutpat aliquam velit\n* Very easy!\n\nOrdered\n\n1. Lorem ipsum dolor sit amet\n2. Consectetur adipiscing elit\n3. Integer molestie lorem at massa\n4. You can use sequential numbers...\n\nStart numbering with offset:\n\n1. foo\n2. bar\n\n| ddd  | ddd | 333 |\n| ---- | --- | --- |\n| rthd | dfb | rr  |\n| fbnv | gf  | r   |\n\n## Links\n\n[link text](http://dev.nodeca.com)\n\n[link with title](http://nodeca.github.io/pica/demo/ \"title text!\")\n\nAutoconverted link [https://github.com/nodeca/pica](https://github.com/nodeca/pica) (enable linkify to see)\n`;\n\nconst base_news: Partial<newsScalars> = {\n  comment_count: 0,\n  hero_image: null,\n  moderation: null,\n  previous_slugs: [],\n  tenant_id,\n  total_views: 0,\n};\n\nconst seedNews: Partial<newsScalars>[] = [\n  {\n    ...base_news,\n    title: 'First news article!',\n    body: bigBlockOfMarkdown,\n    slug: 'first-news-article',\n    summary: 'The first',\n  },\n];\n\nconst seedLibrary = (): Partial<projectsScalars>[] => [\n  ...libraryJson.map((p: any) => ({\n    ..._PROJECT_BASE,\n    slug: convertToSlug(p.title),\n    title: p.title,\n    description: p.description,\n    difficulty_level: p.difficulty_level,\n    time: p.time,\n    moderation: p.moderation,\n    file_link: p.file_link,\n    comment_count: p.comments?.length || 0,\n  })),\n];\n\nconst seedProjectSteps = (projects: projectsScalars[]): project_stepsChildInputs => {\n  const steps: any[] = [];\n\n  libraryJson.forEach((p: any, index: number) => {\n    if (p.steps && Array.isArray(p.steps)) {\n      p.steps.forEach((step: any) => {\n        steps.push({\n          ..._PROJECT_STEP_BASE,\n          project_id: projects[index]?.id,\n          title: step.title,\n          description: step.description,\n          images: step.images || null,\n          video_url: step.video_url || null,\n        });\n      });\n    }\n  });\n\n  return steps;\n};\n\nconst baseResearch: Partial<researchScalars> = {\n  status: 'in-progress',\n  previous_slugs: [],\n  tenant_id,\n  total_views: 0,\n  collaborators: [],\n};\n\nconst mapPins = [\n  {\n    lat: 38.7223,\n    lng: -9.1393,\n    administrative: 'Lisbon',\n    country: 'Portugal',\n    country_code: 'pt',\n    moderation: 'accepted',\n    name: 'Lisbon',\n    post_code: '1700',\n  },\n  {\n    lat: 40.7128,\n    lng: -74.006,\n    administrative: 'New York',\n    country: 'United States',\n    country_code: 'us',\n    moderation: 'accepted',\n    name: 'New York City',\n    post_code: '10001',\n  },\n  {\n    lat: 51.5074,\n    lng: -0.1278,\n    administrative: 'London',\n    country: 'United Kingdom',\n    country_code: 'gb',\n    moderation: 'accepted',\n    name: 'London',\n    post_code: 'SW1A',\n  },\n  {\n    lat: 48.8566,\n    lng: 2.3522,\n    administrative: 'Paris',\n    country: 'France',\n    country_code: 'fr',\n    moderation: 'accepted',\n    name: 'Paris',\n    post_code: '75001',\n  },\n  {\n    lat: 35.6762,\n    lng: 139.6503,\n    administrative: 'Tokyo',\n    country: 'Japan',\n    country_code: 'jp',\n    moderation: 'accepted',\n    name: 'Tokyo',\n    post_code: '100-0001',\n  },\n  {\n    lat: -33.8688,\n    lng: 151.2093,\n    administrative: 'Sydney',\n    country: 'Australia',\n    country_code: 'au',\n    moderation: 'accepted',\n    name: 'Sydney',\n    post_code: '2000',\n  },\n  {\n    lat: 52.52,\n    lng: 13.405,\n    administrative: 'Berlin',\n    country: 'Germany',\n    country_code: 'de',\n    moderation: 'accepted',\n    name: 'Berlin',\n    post_code: '10115',\n  },\n  {\n    lat: 19.4326,\n    lng: -99.1332,\n    administrative: 'Mexico City',\n    country: 'Mexico',\n    country_code: 'mx',\n    moderation: 'accepted',\n    name: 'Mexico City',\n    post_code: '06000',\n  },\n  {\n    lat: -22.9068,\n    lng: -43.1729,\n    administrative: 'Rio de Janeiro',\n    country: 'Brazil',\n    country_code: 'br',\n    moderation: 'accepted',\n    name: 'Rio de Janeiro',\n    post_code: '20040',\n  },\n];\n\nconst seedMapPins = (profiles: profilesScalars[]): map_pinsChildInputs =>\n  mapPins.slice(0, profiles.length).map((pin, index) => ({\n    tenant_id,\n    profile_id: profiles[index].id,\n    lat: String(pin.lat),\n    lng: String(pin.lng),\n    country: pin.country!,\n    country_code: pin.country_code!,\n    moderation: pin.moderation!,\n    administrative: pin.administrative,\n    post_code: pin.post_code,\n    moderation_feedback: (pin as any).moderation_feedback,\n    name: pin.name,\n  }));\n\nconst seedResearch: Partial<researchScalars>[] = [\n  {\n    ...baseResearch,\n    title: 'The First Big Old Research Topic',\n    description: 'This is a super important area to investigate.',\n    slug: 'the-first-big-old-research-topic',\n  },\n];\n\nconst main = async () => {\n  const seed = await createSeedClient();\n\n  await seed.$resetDatabase();\n\n  await seed.buckets([\n    {\n      id: tenant_id,\n      name: tenant_id,\n      public: true,\n      allowed_mime_types: [],\n    },\n    {\n      id: `${tenant_id}-documents`,\n      name: `${tenant_id}-documents`,\n      allowed_mime_types: [],\n    },\n  ]);\n\n  await seed.tenant_settings([\n    {\n      site_name: 'Local Development Community',\n      site_description:\n        'A series of tools to collaborate around the world. Connect, share and meet each other to tackle problems.',\n      site_url: 'http://localhost:3000',\n      message_sign_off: 'The Dev Team',\n      email_from: 'platform@onearmy.earth',\n      site_image:\n        'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/precious-plastic/pp-logo.png',\n      site_favicon:\n        'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/precious-plastic/pp-logo.png',\n      academy_resource: 'https://onearmy.github.io/academy/',\n      no_messaging: false,\n      library_heading: 'The largest open source library of plastic recycling tools',\n      profile_guidelines: 'https://community.preciousplastic.com/academy/guides/platform',\n      questions_guidelines:\n        'https://community.preciousplastic.com/academy/guides/guidelines-questions',\n      supported_modules: 'library,map,research,academy,questions,news',\n      donation_settings: {\n        defaultDescription:\n          'All of the content here is free. Your donation supports this library of open source recycling knowledge. Making it possible for everyone in the world to use it and start recycling.',\n        defaultCampaignId: 'ppcpdonor',\n        spaceDescription:\n          'Support this space so they can continue to help recycling plastic and share more Open Source information. Your support fuels the small scale network of plastic recyclers.',\n        defaultImageUrl: '/assets/img/precious-plastic/donation-banner.jpg',\n      },\n      tenant_id,\n      color_primary: '#fee77b',\n      color_primary_hover: '#ffde45',\n      color_accent: '#fee77b',\n      color_accent_hover: '#ffde45',\n      show_impact: true,\n      create_research_roles: ['admin', 'research_creator'],\n      ga_tracking_id: 'test_ga_id',\n      pwa_icons: {\n        '16': '',\n        '32': '',\n        '192': '',\n        '256': '',\n        '512': '',\n      },\n    },\n  ]);\n\n  const { users } = await seed.users(usersSeed());\n  // await seed.profile_tags(seedProfileTags())\n  const { profile_types } = await seed.profile_types(seedProfileTypes());\n\n  const { profiles } = await seed.profiles(\n    (profilesSeed(tenant_id) as any[]).map((profile: profilesInputs, index) => ({\n      ...profile,\n      auth_id: users[index].id,\n      profile_type: (\n        profile_types.find(\n          (t) =>\n            t.name ===\n            (['member', 'workspace', 'machine-builder', 'community-point', 'collection-point'][\n              index\n            ] ?? 'member'),\n        ) ?? profile_types[0]\n      ).id,\n    })),\n  );\n\n  const { profile_badges } = await seed.profile_badges(seedBadges());\n  await seed.profile_badges_relations(seedBadgesRelations(profiles, profile_badges));\n  await seed.upgrade_badge(seedUpgradeBadges(profile_badges));\n\n  await seed.map_pins(seedMapPins(profiles));\n  const { tags } = await seed.tags(seedTags());\n  const { categories } = await seed.categories(seedCategories());\n\n  const questionsAcc: questionsScalars[] = [];\n\n  for (const profile of profiles) {\n    const { questions } = await seed.questions(seedQuestions(profile), {\n      connect: { profiles: [profile] },\n    });\n    questionsAcc.push(...questions);\n  }\n\n  for (const profile of profiles) {\n    const questionsExceptMine = questionsAcc.filter((q) => q.created_by !== profile.id);\n\n    // await seed.comments(seedComments(profile, questionsExceptMine), {\n    //   connect: { profiles: [profile] },\n    // })\n\n    await seed.subscribers(seedSubscribers(questionsExceptMine), {\n      connect: { profiles: [profile] },\n    });\n\n    await seed.useful_votes(seedUsefulVotes(questionsExceptMine), {\n      connect: { profiles: [profile] },\n    });\n  }\n\n  await seed.news(\n    seedNews.map((item) => ({\n      ...item,\n      category: categories.find((cat) => cat.type === 'news')?.id,\n      created_by: profiles[0].id,\n      tags: [tags[0].id],\n    })),\n  );\n\n  const { projects } = await seed.projects(\n    seedLibrary().map((item) => ({\n      ...item,\n      category: categories.find((cat) => cat.type === 'projects')?.id,\n      created_by: profiles[0].id,\n      tags: [tags[0].name],\n    })),\n  );\n\n  await seed.project_steps(seedProjectSteps(projects));\n\n  await seed.research(\n    seedResearch.map((item) => ({\n      ...item,\n      category: categories.find((cat) => cat.type === 'research')?.id,\n      created_by: profiles[0].id,\n      tags: [tags[0].id.toString()],\n    })),\n  );\n\n  process.exit();\n};\n\nmain();\n"
  },
  {
    "path": "server.js",
    "content": "import { Hono } from 'hono';\nimport { bodyLimit } from 'hono/body-limit';\nimport { serveStatic } from 'hono/bun';\nimport { compress } from 'hono/compress';\nimport { HTTPException } from 'hono/http-exception';\nimport { secureHeaders } from 'hono/secure-headers';\nimport { createRequestHandler } from 'react-router';\n\nconst isProd = process.env.NODE_ENV === 'production';\n\nconst viteDevServer = isProd\n  ? undefined\n  : await import('vite').then((vite) =>\n      vite.createServer({\n        server: { middlewareMode: true },\n      }),\n    );\n\nconst app = new Hono();\n\napp.onError((err, c) => {\n  // React Router handles all route actions internally and we're now catching HTTPException within the actions themselves, app.onError won't catch those errors.\n  // However, it's still a good safety net for:\n  // - Errors in Hono middleware (compression, secure headers)\n  // - Errors in static file serving\n  // - Any unexpected errors at the Hono layer\n\n  // Handle HTTPException (from hono/http-exception)\n  if (err instanceof HTTPException) {\n    return err.getResponse();\n  }\n\n  // Log unexpected errors\n  console.error('Unexpected error:', err);\n\n  // Return generic error response\n  return c.json(\n    {\n      error: 'Internal Server Error',\n      status: 500,\n    },\n    500,\n  );\n});\n\n// Compression\napp.use(compress());\n\napp.use('*', async (c, next) => {\n  if (c.req.path.startsWith('/api/documents')) {\n    return next(); // Skip limit — Bun's 300MB cap is the backstop\n  }\n  return bodyLimit({ maxSize: 10 * 1024 * 1024 })(c, next);\n});\n\n// Security headers (replaces helmet)\nconst wsUrls = process.env.WS_URLS?.split(',').map((url) => url.trim()) ?? [];\n\nconst imgSrc = [\n  \"'self'\",\n  'data:',\n  'blob:',\n  'google.com',\n  '*.openstreetmap.org',\n  'onearmy.github.io',\n  'cdn.jsdelivr.net',\n  '*.google-analytics.com',\n  '*.patreonusercontent.com',\n  '*.basemaps.cartocdn.com',\n  '*.supabase.co',\n  process.env.SUPABASE_API_URL,\n].filter(Boolean);\n\napp.use(\n  secureHeaders({\n    contentSecurityPolicy: {\n      styleSrc: [\"'self'\", \"'unsafe-inline'\", 'fonts.googleapis.com'],\n      fontSrc: [\"'self'\", 'fonts.gstatic.com', 'fonts.googleapis.com'],\n      connectSrc: [\n        \"'self'\",\n        '*.run.app',\n        'securetoken.googleapis.com',\n        'identitytoolkit.googleapis.com',\n        '*.openstreetmap.org',\n        '*.google-analytics.com',\n        '*.cloudfunctions.net',\n        'sentry.io',\n        '*.sentry.io',\n        ...wsUrls,\n      ],\n      defaultSrc: [\n        \"'self'\",\n        'googletagmanager.com',\n        '*.googletagmanager.com',\n        'analytics.google.com',\n        '*.analytics.google.com',\n        '*.google-analytics.com',\n        'googleapis.com',\n      ],\n      scriptSrc: [\n        \"'self'\",\n        'googletagmanager.com',\n        '*.googletagmanager.com',\n        'fonts.gstatic.com',\n        'fonts.googleapis.com',\n        '*.analytics.google.com',\n        '*.google-analytics.com',\n        'www.youtube.com',\n        'donorbox.org',\n        \"'unsafe-eval'\",\n        \"'unsafe-inline'\",\n      ],\n      workerSrc: [\"'self'\", 'blob:'],\n      frameSrc: [\n        \"'self'\",\n        'onearmy.github.io',\n        '*.youtube.com',\n        '*.donorbox.org',\n        'donorbox.org',\n        '*.run.app',\n        '*.netlify.app',\n        'projectkamp.com',\n        '*.projectkamp.com',\n        'preciousplastic.com',\n        '*.preciousplastic.com',\n        'fixing.fashion',\n        '*.fixing.fashion',\n      ],\n      imgSrc: imgSrc,\n      objectSrc: [\"'self'\"],\n      upgradeInsecureRequests: isProd ? [] : undefined,\n    },\n    strictTransportSecurity: isProd ? 'max-age=31536000; preload' : false,\n    xContentTypeOptions: 'nosniff',\n    referrerPolicy: 'origin',\n    xXssProtection: '1; mode=block',\n    xDnsPrefetchControl: 'on',\n  }),\n);\n\n// React Router request handler\nconst handler = createRequestHandler(\n  viteDevServer\n    ? () => viteDevServer.ssrLoadModule('virtual:react-router/server-build')\n    : await import('./build/server/index.js'),\n);\n\nconst port = Number(process.env.PORT) || 3456; // 3456 is default port for ci\n\nif (isProd) {\n  // Fingerprinted assets — cache forever\n  app.use(\n    '/assets/*',\n    serveStatic({\n      root: './build/client',\n      onFound: (_path, c) => {\n        c.header('Cache-Control', 'public, max-age=31536000, immutable');\n      },\n    }),\n  );\n\n  // Other static files — cache for 1 hour\n  app.use(\n    '*',\n    serveStatic({\n      root: './build/client',\n      onFound: (_path, c) => {\n        c.header('Cache-Control', 'public, max-age=3600');\n      },\n    }),\n  );\n\n  // All remaining requests go to React Router\n  app.all('*', (c) => handler(c.req.raw));\n\n  Bun.serve({\n    port,\n    hostname: '0.0.0.0',\n    fetch: app.fetch,\n    maxRequestBodySize: 300 * 1024 * 1024, // Must accommodate /api/documents - protected by Hono middleware\n  });\n\n  console.log(`Hono server started on http://0.0.0.0:${port}`);\n} else {\n  // Development: Node-compatible HTTP server so Vite middleware works\n  const http = await import('node:http');\n  const { getRequestListener } = await import('@hono/node-server');\n\n  // All requests go to React Router\n  app.all('*', (c) => handler(c.req.raw));\n\n  const honoListener = getRequestListener(app.fetch);\n\n  const server = http.createServer((req, res) => {\n    // Vite middleware handles HMR, module transforms, and client assets.\n    // When it doesn't handle the request it calls next(), falling through to Hono.\n    viteDevServer.middlewares(req, res, () => {\n      honoListener(req, res);\n    });\n  });\n\n  server.listen(port, '0.0.0.0', () => {\n    console.log(`Hono dev server started on http://0.0.0.0:${port}`);\n  });\n}\n"
  },
  {
    "path": "shared/.gitignore",
    "content": "lib"
  },
  {
    "path": "shared/README.md",
    "content": "## Shared\n\nVarious content needs to be shared between different parts of the app, e.g.\n\n- Frontend src\n- Storybook components\n- Cypress e2e tests\n\nThe shared workspace can be used to share functions and constants between other projects\n\n### Known Issues\n\n- Typescript will not be compiled. This might be resolved in the future if migrating to lerna or tweaks to tsconfig files. For now will have to just use untyped constants and functions\n\nFor a more general example of workspaces working across typescript projects see:\nhttps://stackoverflow.com/questions/57679322/how-to-use-yarn-workspaces-with-typescript-and-out-folders\n"
  },
  {
    "path": "shared/index.ts",
    "content": "export * from './messages';\nexport * from './models';\nexport * from './utils';\n\n/*************************************************************************************\n * Shared Constants and Generators\n *\n * As constants cannot be directly imported from one workspace to another put them here\n **************************************************************************************/\n"
  },
  {
    "path": "shared/messages.ts",
    "content": "// HACK - in order to be able to lookup any key in `getFriendlyMessage` we need to include\n// a random key so that full typing can't be inferred (leads to index signature error)\nconst randomKey = Math.random().toString();\n\n// set of mappings to use for system->friendly messages\n// some are pulled from error messages, others are hardcoded into the platform but kept\n// here to make easier to change in the future\nexport const FRIENDLY_MESSAGES = {\n  '': '',\n  'auth/argument-error': 'Please provide a valid email',\n  'auth/email-already-in-use': 'The email address is already in use',\n  'auth/email-changed':\n    \"Roger that. We've sent you an email, please click the confirmation link to make the change happen\",\n  'auth/invalid-email': `That email address doesn't quite look right`,\n  'auth/password-changed': 'All done. Password changed',\n  'auth/user-not-found': 'No account found, typo maybe?',\n  'auth/wrong-password': 'Password does not match the user account',\n  'generic-error': 'Oops, something went wrong!',\n  required: 'Required field',\n  'reset email sent': 'Reset email sent, check your inbox/spam',\n  'profile saved': 'Profile Saved',\n  'sign-up/email-required': 'Need an email address',\n  'sign-up/password-short': 'Password must be at least 6 characters',\n  'sign-up/password-required': 'A password is required unfortunately',\n  'sign-up/password-mismatch': 'Your new password does not match',\n  'sign-up/password-weak': \"Your password isn't strong enough, try something else?\",\n  'sign-up/terms': 'Consent is required. Gotta tick that box',\n  'sign-up/username-short': 'Username must be at least 2 characters',\n  'sign-up/username-taken': 'Oh wow, that username is already taken!',\n  'sign-up/username-required': 'Username required, like, who are you?',\n  [randomKey]: randomKey,\n};\n\n/**\n * Conversion for default error messages.\n * @param systemMessage - the message text for lookup in the table.\n * This can either be a status code or full message (depending on how saved above)\n */\nexport const getFriendlyMessage = (systemMessage = '') => {\n  const messageKey = systemMessage.toLowerCase();\n  if (Object.hasOwn(FRIENDLY_MESSAGES, messageKey)) {\n    return FRIENDLY_MESSAGES[messageKey];\n  } else {\n    return systemMessage;\n  }\n};\n"
  },
  {
    "path": "shared/mocks/auth/index.ts",
    "content": "export * from './users';\n"
  },
  {
    "path": "shared/mocks/auth/users.ts",
    "content": "import { UserRole } from '../../models';\n\nexport interface IMockAuthUser {\n  uid: string;\n  label: string;\n  email?: string;\n  password?: string;\n  roles: UserRole[];\n}\n\ntype IMockUsers = { [key in UserRole]: IMockAuthUser };\n/** A list of specific demo/mock users that are prepopulated onto testing sites for use in development */\n\nexport const MOCK_AUTH_USERS: IMockUsers = {\n  subscriber: {\n    uid: 'demo_user',\n    label: 'User',\n    email: 'demo_user@example.com',\n    password: 'demo_user',\n    roles: [],\n  },\n  'beta-tester': {\n    uid: 'demo_beta_tester',\n    label: 'Beta-Tester',\n    email: 'demo_beta_tester@example.com',\n    password: 'demo_beta_tester',\n    roles: [UserRole.BETA_TESTER],\n  },\n  admin: {\n    uid: 'demo_admin',\n    label: 'Admin',\n    email: 'demo_admin@example.com',\n    password: 'demo_admin',\n    roles: [UserRole.ADMIN],\n  },\n  research_creator: {\n    uid: 'research_creator',\n    label: 'Research-Creator',\n    email: 'research_creator@test.com',\n    password: 'research_creator',\n    roles: [UserRole.RESEARCH_CREATOR],\n  },\n};\n"
  },
  {
    "path": "shared/mocks/data/badges.ts",
    "content": "import type { DBProfileBadge } from '../../models/profileBadge';\n\nexport const badges: Partial<DBProfileBadge>[] = [\n  {\n    name: 'pro',\n    display_name: 'PRO',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/icons/pro.svg',\n    action_url: '',\n    premium_tier: 1,\n  },\n  {\n    name: 'supporter',\n    display_name: 'Supporter',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/icons/supporter.svg',\n    action_url: '',\n    premium_tier: null,\n  },\n];\n"
  },
  {
    "path": "shared/mocks/data/categories.ts",
    "content": "import type { DBCategory } from '../../models';\n\nexport const categories: Partial<DBCategory>[] = [\n  {\n    name: 'Machines',\n    type: 'projects',\n  },\n  {\n    name: 'Moulds',\n    type: 'projects',\n  },\n  {\n    name: 'Guides',\n    type: 'projects',\n  },\n  {\n    name: 'Other',\n    type: 'projects',\n  },\n  // Add for other content types and update specs!\n];\n"
  },
  {
    "path": "shared/mocks/data/discussions.ts",
    "content": "export const discussions = {\n  '06fjiZWeyxxMkE0brp6n': {\n    _created: '2022-02-10T20:22:15.500Z',\n    _deleted: false,\n    _modified: '2023-02-15T02:45:15.500Z',\n    contributorIds: ['howto_creator', 'benfurber', 'davehakkens'],\n    sourceId: '3s7Fyn6Jf8ryANJM6Jf6',\n    sourceType: 'question',\n    comments: [\n      {\n        _created: '2022-02-10T20:22:15.500Z',\n        _creatorId: 'howto_creator',\n        _edited: '2022-02-10T20:22:15.500Z',\n        creatorCountry: 'af',\n        creatorName: 'howto_creator',\n        creatorImage:\n          'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/784.jpg',\n        parentCommentId: null,\n        text: \"@@{a0dFrGVJTlQUA9BqH0QmnQM6flX2:demo_user} - I like your logo but I couldn't be connected for a while and I CANNOT tell you that anymore.\",\n      },\n      {\n        _created: '2023-02-10T20:22:15.500Z',\n        _creatorId: 'benfurber',\n        creatorCountry: 'uk',\n        creatorName: 'benfurber',\n        parentCommentId: null,\n        text: \"Love what you're doing. Keep it up.\",\n      },\n      {\n        _created: '2023-02-11T02:22:15.500Z',\n        _creatorId: 'benfurber',\n        creatorCountry: 'uk',\n        creatorName: 'benfurber',\n        parentCommentId: 'ZAB2SoUZtzUyeYLg9Cro',\n        text: 'Annoyingly, I think I agree with @@{YhBD7LB22EXsHz40sTSENWUSa9u1:howto_creator}.',\n      },\n      {\n        _created: '2023-02-12T02:22:19.500Z',\n        _edited: '2023-02-12T02:22:43.500Z',\n        _creatorId: 'davehakkens',\n        creatorCountry: '',\n        creatorName: 'davehakkens',\n        parentCommentId: 'ZAB2SoUZtzUyeYLg9Cro',\n        text: 'Me too @benfurber!',\n      },\n      {\n        _created: '2023-02-15T02:45:15.500Z',\n        _creatorId: 'benfurber',\n        _id: 'fBtzUAB23royeYLgZ',\n        creatorCountry: 'uk',\n        creatorName: 'benfurber',\n        parentCommentId: 'ZAB2SoUZtzUyeYLg9Cro',\n        text: '<3',\n      },\n    ],\n  },\n  jj97weruogbewr: {\n    _created: '2022-03-27T22:10:11.271Z',\n    _deleted: false,\n    _id: 'jj97weruogbewr',\n    _modified: '2022-03-27T22:10:12.271Z',\n    contributorIds: ['Q1sgtnNTPBSYCKEXCOWaOMcPqZD3', 'TPBSYCKEXCOWaOMcPqZD3Q1sgtnN', 'demo_admin'],\n    sourceId: 'ERO3RibAuvz7Wt12LfTb',\n    sourceType: 'researchUpdate',\n    primaryContentId: '0up6oJCPP3M9bDYx34Et',\n    comments: [\n      {\n        _created: '2022-03-27T22:10:12.271Z',\n        _creatorId: 'demo_admin',\n        _edited: '2022-03-27T22:10:12.271Z',\n        _id: 'fZA234tzUyeYLg9Cro',\n        creatorCountry: 'af',\n        creatorName: 'demo_admin',\n        parentCommentId: null,\n        text: 'Interesting research so far, have you thought about experimenting with putting it in the bath?',\n      },\n    ],\n  },\n  cxv123ehgd25: {\n    _created: '2022-03-27T22:10:11.271Z',\n    _deleted: false,\n    _id: 'cxv123ehgd25',\n    _modified: '2022-03-27T22:10:12.271Z',\n    contributorIds: [],\n    sourceId: 'random-id-8888',\n    sourceType: 'researchUpdate',\n    primaryContentId: '0up6oJCPP3M9bDYx34Et',\n    comments: [],\n  },\n  h5346xdhgh: {\n    _created: '2022-03-27T22:10:11.271Z',\n    _deleted: false,\n    _id: 'h5346xdhgh',\n    _modified: '2022-03-27T22:10:12.271Z',\n    contributorIds: ['Q1sgtnNTPBSYCKEXCOWaOMcPqZD3'],\n    sourceId: 'random-id-7856',\n    sourceType: 'researchUpdate',\n    primaryContentId: '0up6oJCPP3M9bDYx34Et',\n    comments: [\n      {\n        _created: '2022-03-27T22:10:12.271Z',\n        _creatorId: 'demo_admin',\n        _edited: '2022-03-27T22:10:12.271Z',\n        _deleted: true,\n        _id: 'fZA234tzUyeYLg9Cro',\n        creatorCountry: 'af',\n        creatorName: 'demo_admin',\n        parentCommentId: null,\n        text: 'Interesting research so far, have you thought about experimenting with putting it in the bath?',\n      },\n    ],\n  },\n  w21vbdxc4t3: {\n    _created: '2022-03-27T22:10:11.271Z',\n    _deleted: false,\n    _id: 'w21vbdxc4t3',\n    _modified: '2022-03-27T22:10:12.271Z',\n    contributorIds: ['demo_admin'],\n    sourceId: '213hrandom-id',\n    sourceType: 'researchUpdate',\n    primaryContentId: '0up6oJCPP3M9bDYx34Et',\n    comments: [\n      {\n        _created: '2022-03-27T22:10:12.271Z',\n        _creatorId: 'demo_admin',\n        _edited: '2022-03-27T22:10:12.271Z',\n        _deleted: true,\n        _id: 'fZA234tzUyeYLg9Cro',\n        creatorCountry: 'af',\n        creatorName: 'demo_admin',\n        parentCommentId: null,\n        text: 'Interesting research so far, have you thought about experimenting with putting it in the bath?',\n      },\n    ],\n  },\n  k32497erqwv: {\n    _created: '2022-03-27T22:10:11.271Z',\n    _deleted: false,\n    _id: 'k32497erqwv',\n    _modified: '2022-04-27T22:10:12.271Z',\n    contributorIds: [],\n    sourceId: 'random-id-324',\n    sourceType: 'researchUpdate',\n    primaryContentId: '0up6oJCPP3M9bDYx34Et',\n    comments: [],\n  },\n  lkjsad897234b: {\n    _created: '2022-03-27T22:10:11.271Z',\n    _deleted: false,\n    _id: 'lkjsad897234b',\n    _modified: '2022-03-27T22:10:12.271Z',\n    contributorIds: ['demo_admin'],\n    sourceId: 'random-23456',\n    sourceType: 'researchUpdate',\n    primaryContentId: '0up6oJCPP3M9bDYx34Et',\n    comments: [\n      {\n        _created: '2022-03-27T22:10:12.271Z',\n        _creatorId: 'demo_admin',\n        _edited: '2022-03-27T22:10:12.271Z',\n        _id: 'fZA234tzUyeYLg9Cro',\n        creatorCountry: 'af',\n        creatorName: 'demo_admin',\n        parentCommentId: null,\n        text: 'Cool research',\n      },\n      {\n        _created: '2022-03-29T22:10:12.271Z',\n        _creatorId: 'demo_admin',\n        _id: 'fZA234tzUyeYLg9Cro',\n        creatorCountry: 'af',\n        creatorName: 'demo_admin',\n        parentCommentId: null,\n        text: 'Still love this idea so much.',\n      },\n    ],\n  },\n  ljerqw7234asdf: {\n    _created: '2022-04-27T22:10:11.271Z',\n    _deleted: false,\n    _id: 'ljerqw7234asdf',\n    contributorIds: ['demo_admin', 'benfurber'],\n    sourceId: 'gPpPDEvfNT9a6w5FWzaj',\n    sourceType: 'project',\n    primaryContentId: 'gPpPDEvfNT9a6w5FWzaj',\n    comments: [\n      {\n        _created: '2022-03-27T22:10:12.271Z',\n        _creatorId: 'demo_admin',\n        _edited: '2022-03-27T22:10:12.271Z',\n        _id: 'm324vysdq71',\n        creatorCountry: 'bo',\n        creatorName: 'demo_admin',\n        parentCommentId: null,\n        text: \"Thanks for this project, it's taught me loads.\",\n      },\n      {\n        _created: '2022-03-29T22:10:12.271Z',\n        _creatorId: 'benfurber',\n        _id: 'sgdfjk67123dx',\n        creatorCountry: 'gb',\n        creatorName: 'ben',\n        parentCommentId: 'm324vysdq71',\n        text: 'Same!',\n      },\n      {\n        _created: '2022-03-30T22:10:12.271Z',\n        _creatorId: 'benfurber',\n        _id: 'jk67123dx',\n        creatorCountry: 'gb',\n        creatorName: 'ben',\n        parentCommentId: null,\n        text: 'The third step confused me a bit...',\n      },\n    ],\n  },\n};\n"
  },
  {
    "path": "shared/mocks/data/index.ts",
    "content": "export { badges } from './badges';\nexport { categories } from './categories';\nexport { discussions } from './discussions';\nexport { mapPins } from './mappins';\nexport { messages } from './messages';\nexport { news } from './news';\nexport { profileTags } from './profileTags';\nexport { profileTypes } from './profileTypes';\nexport { projects } from './projects';\nexport { questions } from './questions';\nexport { research } from './research';\nexport { researchUpdates } from './researchUpdates';\nexport { tags } from './tags';\nexport { users } from './users';\n"
  },
  {
    "path": "shared/mocks/data/mappins.ts",
    "content": "import type { DBMapPin } from '../../models/profile';\n\nexport const mapPins: Partial<DBMapPin>[] = [\n  {\n    lat: 38.7223,\n    lng: -9.1393,\n    administrative: 'Lisbon',\n    country: 'Portugal',\n    country_code: 'pt',\n    moderation: 'accepted',\n    name: 'Lisbon',\n    post_code: '1700',\n  },\n  {\n    lat: 40.7128,\n    lng: -74.006,\n    administrative: 'New York',\n    country: 'United States',\n    country_code: 'us',\n    moderation: 'awaiting-moderation',\n    name: 'New York City',\n    post_code: '10001',\n  },\n  {\n    lat: 51.5074,\n    lng: -0.1278,\n    administrative: 'London',\n    country: 'United Kingdom',\n    country_code: 'gb',\n    moderation: 'improvements-needed',\n    moderation_feedback: 'missing info',\n    name: 'London',\n    post_code: 'SW1A',\n  },\n  {\n    lat: 48.8566,\n    lng: 2.3522,\n    administrative: 'Paris',\n    country: 'France',\n    country_code: 'fr',\n    moderation: 'rejected',\n    moderation_feedback: 'innacurate info',\n    name: 'Paris',\n    post_code: '75001',\n  },\n  {\n    lat: 35.6762,\n    lng: 139.6503,\n    administrative: 'Tokyo',\n    country: 'Japan',\n    country_code: 'jp',\n    moderation: 'accepted',\n    name: 'Tokyo',\n    post_code: '100-0001',\n  },\n  {\n    lat: -33.8688,\n    lng: 151.2093,\n    administrative: 'Sydney',\n    country: 'Australia',\n    country_code: 'au',\n    moderation: 'accepted',\n    name: 'Sydney',\n    post_code: '2000',\n  },\n  {\n    lat: 52.52,\n    lng: 13.405,\n    administrative: 'Berlin',\n    country: 'Germany',\n    country_code: 'de',\n    moderation: 'accepted',\n    name: 'Berlin',\n    post_code: '10115',\n  },\n  {\n    lat: 19.4326,\n    lng: -99.1332,\n    administrative: 'Mexico City',\n    country: 'Mexico',\n    country_code: 'mx',\n    moderation: 'accepted',\n    name: 'Mexico City',\n    post_code: '06000',\n  },\n  {\n    lat: 19.4325,\n    lng: -99.1332,\n    administrative: 'Mexico City',\n    country: 'Mexico',\n    country_code: 'mx',\n    moderation: 'accepted',\n    name: 'Mexico City',\n    post_code: '06000',\n  },\n  {\n    lat: 19.4327,\n    lng: -99.1332,\n    administrative: 'Mexico City',\n    country: 'Mexico',\n    country_code: 'mx',\n    moderation: 'accepted',\n    name: 'Mexico City',\n    post_code: '06000',\n  },\n  {\n    lat: -22.9068,\n    lng: -43.1729,\n    administrative: 'Rio de Janeiro',\n    country: 'Brazil',\n    country_code: 'br',\n    moderation: 'accepted',\n    name: 'Rio de Janeiro',\n    post_code: '20040',\n  },\n];\n"
  },
  {
    "path": "shared/mocks/data/messages.ts",
    "content": "export const messages = {\n  '36hWyka3OckrLSH1ehdIE': {\n    _modified: '2012-10-27T01:47:57.948Z',\n    email: 'demo_user@example.com',\n    name: 'Bob',\n    text: 'Message test',\n    toUserName: 'settings_machine_new',\n  },\n};\n"
  },
  {
    "path": "shared/mocks/data/news.ts",
    "content": "import type { DBNews } from '../../models/news';\n\nexport const news: Partial<DBNews>[] = [\n  {\n    body: 'Test info with a link to [OneArmy](https://www.onearmy.earth/).\\n![test-img](https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/precious-plastic/pp-logo.png)',\n    category: null,\n    comment_count: 2,\n    created_at: new Date(),\n    deleted: false,\n    // hero_image: {\n    //   id: '30',\n    //   path: '',\n    //   fullPath: '',\n    // },\n    modified_at: null,\n    previous_slugs: [],\n    slug: 'the-first-test-news',\n    summary: 'So first, the very first.',\n    title: 'The First Test News',\n    total_views: 3,\n  },\n  {\n    body: 'This is a test mock for the filtering question.',\n    category: null,\n    comment_count: 2,\n    created_at: new Date(),\n    deleted: false,\n    // hero_image: {\n    //   id: '31',\n    //   path: '',\n    //   fullPath: '',\n    // },\n    modified_at: null,\n    previous_slugs: [],\n    summary: 'Filtering at its best.',\n    slug: 'filtering-question',\n    title: 'The Filtering Question',\n    total_views: 12,\n  },\n  {\n    body: \"What's the deal with screenings?\",\n    category: null,\n    comment_count: 2,\n    created_at: new Date(),\n    deleted: false,\n    // hero_image: {\n    //   id: '32',\n    //   path: '',\n    //   fullPath: '',\n    // },\n    modified_at: null,\n    previous_slugs: ['whats-the-deal-with-screenings'],\n    slug: 'intro-screenings-update',\n    summary: 'Important deal info.',\n    title: 'Intro screenings Update',\n    total_views: 4,\n  },\n];\n"
  },
  {
    "path": "shared/mocks/data/profileTags.ts",
    "content": "import type { DBProfileTag } from '../../models/profileTag';\n\nexport const profileTags: Partial<DBProfileTag>[] = [\n  {\n    name: 'Sheetpress',\n    profile_type: 'space',\n  },\n  {\n    name: 'Injection',\n    profile_type: 'space',\n  },\n  {\n    name: 'Extrusion',\n    profile_type: 'space',\n  },\n  {\n    name: 'Shredder',\n    profile_type: 'space',\n  },\n  {\n    name: 'Mixed',\n    profile_type: 'space',\n  },\n  {\n    name: 'Collection',\n    profile_type: 'space',\n  },\n  {\n    name: 'Drop Off Point',\n    profile_type: 'space',\n  },\n  {\n    name: 'Granulating',\n    profile_type: 'space',\n  },\n  {\n    name: 'Host Events',\n    profile_type: 'space',\n  },\n  {\n    name: 'Research & Development',\n    profile_type: 'space',\n  },\n  {\n    name: 'Consultancy',\n    profile_type: 'space',\n  },\n  {\n    name: 'Documentation',\n    profile_type: 'space',\n  },\n  {\n    name: 'Meetups',\n    profile_type: 'space',\n  },\n  {\n    name: 'Drop Off Point',\n    profile_type: 'space',\n  },\n  {\n    name: 'Wire Electronics',\n    profile_type: 'space',\n  },\n  {\n    name: 'Machining',\n    profile_type: 'space',\n  },\n  {\n    name: 'Welding',\n    profile_type: 'space',\n  },\n  {\n    name: 'Assembling',\n    profile_type: 'space',\n  },\n  {\n    name: 'Mould Making',\n    profile_type: 'space',\n  },\n  {\n    name: 'PET',\n    profile_type: 'space',\n  },\n  {\n    name: 'HDPE',\n    profile_type: 'space',\n  },\n  {\n    name: 'PVC',\n    profile_type: 'space',\n  },\n  {\n    name: 'LDPE',\n    profile_type: 'space',\n  },\n  {\n    name: 'PP',\n    profile_type: 'space',\n  },\n  {\n    name: 'PS',\n    profile_type: 'space',\n  },\n  {\n    name: 'Collecting',\n    profile_type: 'member',\n  },\n  {\n    name: 'Melting',\n    profile_type: 'member',\n  },\n  {\n    name: 'UX/UI Design',\n    profile_type: 'member',\n  },\n  {\n    name: 'Product Design',\n    profile_type: 'member',\n  },\n  {\n    name: 'Graphic Design',\n    profile_type: 'member',\n  },\n  {\n    name: 'Accounting',\n    profile_type: 'member',\n  },\n  {\n    name: 'Engineering',\n    profile_type: 'member',\n  },\n  {\n    name: 'Metal Work',\n    profile_type: 'member',\n  },\n  {\n    name: 'Coding',\n    profile_type: 'member',\n  },\n  {\n    name: 'Fundraising',\n    profile_type: 'member',\n  },\n  {\n    name: 'Organise Meetups',\n    profile_type: 'member',\n  },\n  {\n    name: 'Managing',\n    profile_type: 'member',\n  },\n  {\n    name: 'Video Maker',\n    profile_type: 'member',\n  },\n  {\n    name: 'Photographer',\n    profile_type: 'member',\n  },\n  {\n    name: 'Illustrator',\n    profile_type: 'member',\n  },\n  {\n    name: 'Community Builder',\n    profile_type: 'member',\n  },\n  {\n    name: 'Entrepreneur',\n    profile_type: 'member',\n  },\n  {\n    name: 'Marketing',\n    profile_type: 'member',\n  },\n];\n"
  },
  {
    "path": "shared/mocks/data/profileTypes.ts",
    "content": "import type { DBProfileType } from '../../models/profileType';\n\nexport const profileTypes: Partial<DBProfileType>[] = [\n  {\n    name: 'member',\n    display_name: 'Member',\n    is_space: false,\n    description: '',\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-member.svg',\n    map_pin_name: 'Want to get started',\n    order: 1,\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/map-member.svg',\n  },\n  {\n    name: 'machine-builder',\n    display_name: 'Machine Builder',\n    order: 3,\n    description: '',\n    map_pin_name: 'Machine Builder',\n    is_space: true,\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-machine.svg',\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-machine-small.svg',\n  },\n  {\n    name: 'workspace',\n    display_name: 'Workspace',\n    order: 2,\n    description: '',\n    map_pin_name: 'Workspace',\n    is_space: true,\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-workspace.svg',\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-workspace-small.svg',\n  },\n  {\n    name: 'community-point',\n    display_name: 'Community Point',\n    order: 4,\n    description: '',\n    map_pin_name: 'Community Point',\n    is_space: true,\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-community.svg',\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-community-small.svg',\n  },\n  {\n    name: 'collection-point',\n    display_name: 'Collection Point',\n    order: 5,\n    description: '',\n    map_pin_name: 'Collection Point',\n    is_space: true,\n    image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-collection.svg',\n    small_image_url:\n      'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/profile-types/pp-collection-small.svg',\n  },\n];\n"
  },
  {
    "path": "shared/mocks/data/projects.ts",
    "content": "export const projects = [\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'settings_workplace_new',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'show you how to make a brick using the injection machine',\n    time: '3-4 weeks',\n    difficultyLevel: 'hard',\n    slug: 'make-an-interlocking-brick',\n    category: {\n      name: 'Machines',\n      createdAt: new Date('2018-11-29T12:56:47.901Z'),\n      modifiedAt: new Date('2018-11-29T12:56:47.901Z'),\n      id: 1,\n      type: 'projects',\n    },\n    previousSlugs: [],\n    moderation: 'accepted',\n    title: 'Make an interlocking brick',\n    steps: [\n      {\n        title: 'The first step!',\n        description: 'more later',\n      },\n      {\n        title: 'Explore the possibilities!',\n        description: 'more for a partition or the wall',\n        video_url: 'https://www.youtube.com/embed/dP1s7viFZHY',\n      },\n    ],\n  },\n  {\n    createdAt: '2022-03-27T22:08:25.999Z',\n    createdBy: 'settings_workplace_new',\n    deleted: false,\n    modifiedAt: '2022-03-27T22:10:11.271Z',\n    description: 'qwertyefew',\n    slug: 'qwerty',\n    previousSlugs: ['qwerty'],\n    totalCommentCount: 1,\n    title: 'Qwerty',\n    files: [\n      {\n        name: 'art final 2.skp',\n        id: 647225,\n        size: 647225,\n      },\n    ],\n    fileLink: '',\n    steps: [\n      {\n        title: 'qwerty',\n        description: 'qwerty',\n        images: [\n          {\n            publicUrl:\n              'https://firebasestorage.googleapis.com/v0/b/precious-plastics-v4-dev.appspot.com/o/uploads%2Fproject%2F0up6oJCPP3M9bDYx34Et%2F1426018318_414579695-17fcd6de5f7.jpg?alt=media&token=9f18315b-a1ad-410b-b31c-2161fb1d7142',\n            id: 109985,\n          },\n        ],\n      },\n      {\n        title: 'qwerty',\n        description: 'qwerty',\n        images: [\n          {\n            id: 109985,\n            publicUrl:\n              'https://firebasestorage.googleapis.com/v0/b/precious-plastics-v4-dev.appspot.com/o/uploads%2Fproject%2F0up6oJCPP3M9bDYx34Et%2F1426018318_414579695-17fcd6de5f7.jpg?alt=media&token=9f18315b-a1ad-410b-b31c-2161fb1d7142',\n          },\n        ],\n      },\n      {\n        title: 'qwerty',\n        description: 'qwerty',\n        images: [\n          {\n            publicUrl:\n              'https://firebasestorage.googleapis.com/v0/b/precious-plastics-v4-dev.appspot.com/o/uploads%2Fproject%2F0up6oJCPP3M9bDYx34Et%2F1426018318_414579695-17fcd6de5f7.jpg?alt=media&token=9f18315b-a1ad-410b-b31c-2161fb1d7142',\n            id: 109985,\n          },\n        ],\n      },\n      {\n        title: 'qwerty',\n        description: 'qwerty',\n        images: [\n          {\n            publicUrl:\n              'https://firebasestorage.googleapis.com/v0/b/precious-plastics-v4-dev.appspot.com/o/uploads%2Fproject%2F0up6oJCPP3M9bDYx34Et%2F1426018318_414579695-17fcd6de5f7.jpg?alt=media&token=9f18315b-a1ad-410b-b31c-2161fb1d7142',\n            id: 109985,\n          },\n        ],\n      },\n      {\n        title: 'qwerty',\n        description: 'qwerty',\n        images: [\n          {\n            publicUrl:\n              'https://firebasestorage.googleapis.com/v0/b/precious-plastics-v4-dev.appspot.com/o/uploads%2Fproject%2F0up6oJCPP3M9bDYx34Et%2F1426018318_414579695-17fcd6de5f7.jpg?alt=media&token=9f18315b-a1ad-410b-b31c-2161fb1d7142',\n            id: 109985,\n          },\n        ],\n      },\n      {\n        title: 'qwerty',\n        description: 'qwerty',\n        images: [\n          {\n            publicUrl:\n              'https://firebasestorage.googleapis.com/v0/b/precious-plastics-v4-dev.appspot.com/o/uploads%2Fproject%2F0up6oJCPP3M9bDYx34Et%2F1426018318_414579695-17fcd6de5f7.jpg?alt=media&token=9f18315b-a1ad-410b-b31c-2161fb1d7142',\n            id: 109985,\n          },\n        ],\n      },\n    ],\n  },\n  {\n    description: 'A test!',\n    slug: 'a-test-project',\n    previousSlugs: [],\n    title: 'A test project',\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'settings_workplace_new',\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    files: [\n      {\n        name: 'art final 2.skp',\n        id: 647225,\n        size: 647225,\n      },\n    ],\n    steps: [\n      {\n        title: 'qwerty',\n        description: 'qwerty',\n        images: [\n          {\n            publicUrl:\n              'https://firebasestorage.googleapis.com/v0/b/precious-plastics-v4-dev.appspot.com/o/uploads%2Fproject%2F0up6oJCPP3M9bDYx34Et%2F1426018318_414579695-17fcd6de5f7.jpg?alt=media&token=9f18315b-a1ad-410b-b31c-2161fb1d7142',\n            id: 109985,\n          },\n        ],\n      },\n    ],\n  },\n  {\n    title: 'A deleted test project',\n    description: 'A deleted project test!',\n    slug: 'a-deleted-test-project',\n    previousSlugs: ['a-deleted-test-project'],\n    steps: [],\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'settings_workplace_new',\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n  },\n  {\n    title: 'Just test data',\n    description: 'Workspace contribution tab test!',\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'settings_workplace_new',\n    deleted: true,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    slug: 'workspace-test-project',\n    previousSlugs: ['workspace-test-project'],\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'a-project-test-2',\n    previousSlugs: [],\n    title: 'A project test 2',\n    moderation: 'accepted',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'beams',\n    slug: 'make-glass-like-beams',\n    previousSlugs: [],\n    moderation: 'accepted',\n    title: 'Make glass-like beams',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'a-project-test-5',\n    previousSlugs: [],\n    moderation: 'accepted',\n    title: 'A project test 5',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'a-project-test-6',\n    previousSlugs: [],\n    moderation: 'accepted',\n    title: 'A project test 6',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'a-project-test-7',\n    previousSlugs: [],\n    moderation: 'accepted',\n    title: 'A project test 7',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'a-project-test-8',\n    moderation: 'accepted',\n    previousSlugs: [],\n    title: 'A project test 8',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'a-project-test-9',\n    previousSlugs: [],\n    moderation: 'accepted',\n    title: 'A project test 9',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'a-project-test-10',\n    previousSlugs: [],\n    moderation: 'accepted',\n    title: 'A project test 10',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'event_reader',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'a-project-test-11',\n    previousSlugs: [],\n    moderation: 'accepted',\n    title: 'A project test 11',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'a-project-test-12',\n    previousSlugs: [],\n    moderation: 'accepted',\n    title: 'A project test 12',\n    steps: [],\n  },\n  {\n    createdAt: '2023-02-27T22:08:25.999Z',\n    createdBy: 'demo_user',\n    deleted: false,\n    modifiedAt: '2023-03-01T19:12:11.271Z',\n    description: 'Workspace contribution tab test!',\n    slug: 'rubbish-title',\n    previousSlugs: ['rubbish-title'],\n    moderation: 'improvements-needed',\n    moderationFeedback: \"The title isn't very descriptive.\",\n    title: 'Rubbish Title',\n    steps: [],\n  },\n];\n"
  },
  {
    "path": "shared/mocks/data/questions.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nexport const questions = [\n  {\n    created_at: new Date().toUTCString(),\n    deleted: false,\n    comment_count: 3,\n    description: 'test info with a link to https://www.onearmy.earth/',\n    slug: 'the-first-test-question',\n    title: 'The first test question?',\n    total_views: 3,\n  },\n  {\n    created_at: new Date().toUTCString(),\n    created_by: 'demo_user',\n    deleted: false,\n    comment_count: 3,\n    description: 'This is a test mock for the filtering question.',\n    slug: 'filtering-question',\n    title: 'The Filtering Question',\n    total_views: 43,\n  },\n  {\n    created_at: new Date().toUTCString(),\n    deleted: false,\n    comment_count: 0,\n    description: \"What's the deal with screenings?\",\n    slug: 'whats-the-deal-with-screenings',\n    title: 'Intro screenings question',\n    total_views: 1,\n  },\n];\n\nfor (let i = 0; i < 20; i++) {\n  questions.push({\n    created_at: faker.date.past().toUTCString(),\n    deleted: false,\n    comment_count: 12,\n    description: faker.lorem.sentence(),\n    slug: faker.lorem.slug(),\n    title: faker.lorem.sentence(),\n    total_views: faker.number.int(),\n  });\n}\n"
  },
  {
    "path": "shared/mocks/data/research.ts",
    "content": "import type { DBResearchItem } from '../../models/research';\n\ntype ResearchSeed = Partial<DBResearchItem> & {\n  created_by_username: string;\n};\n\nexport const research: ResearchSeed[] = [\n  {\n    created_at: new Date('2022-03-27T22:08:25.999Z'),\n    created_by_username: 'event_reader',\n    deleted: false,\n    modified_at: new Date('2022-03-27T22:10:11.271Z'),\n    description: 'qwertyefew. Super qwerty. Gotta keep saying qwerty. qwerty qwerty. Qwerty!',\n    slug: 'qwerty',\n    previous_slugs: ['qwerty'],\n    title: 'Qwerty',\n    status: 'in-progress',\n    is_draft: false,\n    updates: [],\n  },\n  {\n    created_at: new Date('2025-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: false,\n    modified_at: new Date('2025-03-01T19:12:11.271Z'),\n    description: 'A test!',\n    slug: 'a-test-research',\n    previous_slugs: [],\n    title: 'A test research',\n    status: 'in-progress',\n    is_draft: false,\n    updates: [],\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: true,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'A deleted research test!',\n    slug: 'a-deleted-test-research',\n    previous_slugs: ['a-deleted-test-research'],\n    status: 'in-progress',\n    title: 'A deleted test research',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'settings_workplace_new',\n    deleted: true,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'workspace-test-research',\n    previous_slugs: ['workspace-test-research'],\n    title: 'Just test data',\n    status: 'in-progress',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'a-research-test-2',\n    previous_slugs: [],\n    title: 'A research test 2',\n    status: 'complete',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'a-research-test-3',\n    previous_slugs: [],\n    status: 'in-progress',\n    title: 'A research test 3',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'settings_workplace_new',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'a-research-test-4',\n    previous_slugs: [],\n    status: 'in-progress',\n    title: 'A research test 4',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Five is a magic number',\n    slug: 'a-research-test-5',\n    previous_slugs: [],\n    status: 'in-progress',\n    title: 'A research test 5',\n    is_draft: true,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: '666',\n    slug: 'a-research-test-6',\n    previous_slugs: [],\n    status: 'in-progress',\n    title: 'A research test 6',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'a-research-test-7',\n    previous_slugs: [],\n    status: 'in-progress',\n    title: 'A research test 7',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'a-research-test-8',\n    status: 'in-progress',\n    previous_slugs: [],\n    title: 'A research test 8',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'a-research-test-9',\n    previous_slugs: [],\n    status: 'in-progress',\n    title: 'A research test 9',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'demo_user',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'a-research-test-10',\n    previous_slugs: [],\n    status: 'in-progress',\n    title: 'A research test 10',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'event_reader',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'a-research-test-11',\n    previous_slugs: [],\n    status: 'complete',\n    title: 'A research test 11',\n    is_draft: false,\n  },\n  {\n    created_at: new Date('2023-02-27T22:08:25.999Z'),\n    created_by_username: 'event_reader',\n    deleted: false,\n    modified_at: new Date('2023-03-01T19:12:11.271Z'),\n    description: 'Workspace contribution tab test!',\n    slug: 'a-research-test-12',\n    previous_slugs: [],\n    status: 'complete',\n    title: 'A research test 12',\n    is_draft: false,\n  },\n];\n"
  },
  {
    "path": "shared/mocks/data/researchUpdates.ts",
    "content": "import type { DBResearchUpdate } from '../../models';\n\nexport const researchUpdates: Partial<DBResearchUpdate>[] = [\n  {\n    created_at: new Date('2022-03-27T22:10:11.271Z'),\n    deleted: false,\n    modified_at: new Date('2022-03-27T22:10:11.271Z'),\n    description: 'qwerty',\n    comment_count: 1,\n    title: 'qwerty 1',\n    files: [\n      {\n        name: 'art final 2.skp',\n        id: '647225',\n        size: 647225,\n      },\n    ],\n    file_download_count: 2555,\n  },\n  {\n    created_at: new Date('2022-04-27T22:10:11.271Z'),\n    deleted: false,\n    modified_at: new Date('2022-04-27T22:10:11.271Z'),\n    description: 'qwerty',\n    comment_count: 0,\n    title: 'qwerty 2',\n  },\n  {\n    created_at: new Date('2022-05-27T22:10:11.271Z'),\n    deleted: false,\n    modified_at: new Date('2022-05-27T22:10:11.271Z'),\n    description: 'qwerty',\n    comment_count: 0,\n    title: 'qwerty 3',\n  },\n  {\n    created_at: new Date('2022-06-27T22:10:11.271Z'),\n    deleted: false,\n    modified_at: new Date('2022-06-27T22:10:11.271Z'),\n    description: 'qwerty',\n    comment_count: 0,\n    title: 'qwerty 4',\n  },\n];\n"
  },
  {
    "path": "shared/mocks/data/tags.ts",
    "content": "import type { DBTag } from '../../models/tag';\n\nexport const tags: Partial<DBTag>[] = [\n  {\n    created_at: new Date(),\n    name: 'product',\n  },\n  {\n    created_at: new Date(),\n    name: 'exhibition',\n  },\n  {\n    created_at: new Date(),\n    name: 'howto_testing',\n  },\n  {\n    created_at: new Date(),\n    name: 'brainstorm',\n  },\n  {\n    created_at: new Date(),\n    name: 'compression',\n  },\n  {\n    created_at: new Date(),\n    name: 'mould',\n  },\n  {\n    created_at: new Date(),\n    name: 'injection',\n  },\n  {\n    created_at: new Date(),\n    name: 'workshop',\n  },\n  {\n    created_at: new Date(),\n    name: 'extrusion',\n  },\n  {\n    created_at: new Date(),\n    name: 'screening',\n  },\n];\n"
  },
  {
    "path": "shared/mocks/data/users.ts",
    "content": "import type { Profile } from '../../models';\nimport { UserRole } from '../../models';\n\ntype Users = {\n  [id: string]: Partial<Profile> & {\n    email: string;\n    password: string;\n    username: string;\n    profileType: string;\n  };\n};\nexport const users: Users = {\n  subscriber: {\n    createdAt: new Date('2022-01-30T18:51:57.719Z'),\n    displayName: 'demo_user',\n    username: 'demo_user',\n    roles: [],\n    coverImages: [\n      {\n        id: '',\n        path: 'uploads/v3_users/demo_user/images/profile-cover-1.jpg',\n        fullPath: 'uploads/v3_users/demo_user/images/profile-cover-1.jpg',\n        publicUrl: 'uploads/v3_users/demo_user/images/profile-cover-1.jpg',\n      },\n    ],\n    website: 'http://demo_user.example.com',\n    about: \"Hi! I'm a robot 🤖 Beep boop\",\n    email: 'demo_user@example.com',\n    password: 'demo_user',\n    profileType: 'member',\n  },\n  'beta-tester': {\n    createdAt: new Date('2022-01-30T18:51:57.719Z'),\n    displayName: 'demo_beta_tester',\n    username: 'demo_beta_tester',\n    roles: [UserRole.BETA_TESTER],\n    email: 'demo_beta_tester@example.com',\n    password: 'demo_beta_tester',\n    about: '',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    lastActive: new Date('2022-01-30T18:51:57.719Z'),\n    profileType: 'member',\n  },\n  admin: {\n    createdAt: new Date('2022-01-30T18:51:57.719Z'),\n    displayName: 'demo_admin',\n    username: 'demo_admin',\n    roles: [UserRole.ADMIN],\n    email: 'demo_admin@example.com',\n    password: 'demo_admin',\n    about: 'admin',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'member',\n  },\n  event_reader: {\n    username: 'event_reader',\n    createdAt: new Date('2019-08-15T00:00:00.000Z'),\n    displayName: 'event_reader',\n    email: 'event_reader@test.com',\n    password: 'test1234',\n    roles: [UserRole.BETA_TESTER],\n    about: '',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'member',\n  },\n  howto_creator: {\n    email: 'howto_creator@test.com',\n    username: 'howto_creator',\n    password: 'test1234',\n    createdAt: new Date('2020-01-07T15:46:00.297Z'),\n    displayName: 'howto_creator',\n    roles: [],\n    about: 'howto_creator stuff',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'member',\n  },\n  research_creator: {\n    email: 'research_creator@test.com',\n    password: 'research_creator',\n    username: 'research_creator',\n    createdAt: new Date('2020-01-07T15:46:00.297Z'),\n    displayName: 'research_creator',\n    roles: [UserRole.RESEARCH_CREATOR],\n    about: 'research_creator research_creator',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'member',\n  },\n  settings_machine_new: {\n    badges: [\n      {\n        id: 1,\n        displayName: 'PRO',\n        name: 'pro',\n        imageUrl: 'svg',\n        premiumTier: 1,\n      },\n    ],\n    createdAt: new Date('2020-01-07T12:14:50.354Z'),\n    displayName: 'settings_machine_new',\n    email: 'settings_machine_new@test.com',\n    username: 'settings_machine_new',\n    password: 'settings_machine_new',\n    roles: [],\n    about: '',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'machine-builder',\n  },\n  settings_member_new: {\n    country: 'Poland',\n    username: 'settings_member_new',\n    email: 'settings_member_new@test.com',\n    password: 'test1234',\n    badges: [\n      {\n        id: 1,\n        displayName: 'PRO',\n        name: 'pro',\n        imageUrl: 'svg',\n        premiumTier: 1,\n      },\n    ],\n    createdAt: new Date('2020-01-07T12:14:30.030Z'),\n    displayName: 'settings_member_new',\n    roles: [],\n    about: '',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'member',\n  },\n  settings_workplace_empty: {\n    badges: [\n      {\n        id: 1,\n        displayName: 'PRO',\n        name: 'pro',\n        imageUrl: 'svg',\n        premiumTier: 1,\n      },\n    ],\n    createdAt: new Date('2020-01-07T12:15:42.218Z'),\n    displayName: 'settings_workplace_empty',\n    username: 'settings_workplace_empty',\n    email: 'settings_workplace_empty@test.com',\n    password: 'settings_workplace_empty',\n    id: 13,\n    about: '',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'workspace',\n  },\n  settings_workplace_new: {\n    badges: [\n      {\n        id: 1,\n        displayName: 'PRO',\n        name: 'pro',\n        imageUrl: 'svg',\n        premiumTier: 1,\n      },\n    ],\n    createdAt: new Date('2020-01-07T12:14:15.081Z'),\n    displayName: 'settings_workplace_new',\n    username: 'settings_workplace_new',\n    website: 'http://settings_workplace_new.example.com',\n    id: 14,\n    email: 'settings_workplace_new@test.com',\n    password: 'test1234',\n    roles: [UserRole.BETA_TESTER],\n    impact: {\n      2022: [\n        {\n          id: 'plastic',\n          value: 43000,\n          isVisible: true,\n        },\n        {\n          id: 'revenue',\n          value: 36000,\n          isVisible: true,\n        },\n        {\n          id: 'employees',\n          value: 3,\n          isVisible: true,\n        },\n        {\n          id: 'volunteers',\n          value: 45,\n          isVisible: false,\n        },\n        {\n          id: 'machines',\n          value: 2,\n          isVisible: true,\n        },\n      ],\n    },\n    isContactable: false,\n    about: '',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'workspace',\n  },\n  mapview_testing_rejected: {\n    badges: [\n      {\n        id: 1,\n        displayName: 'PRO',\n        name: 'pro',\n        imageUrl: 'svg',\n        premiumTier: 1,\n      },\n    ],\n    createdAt: new Date('2020-01-07T12:14:15.081Z'),\n    displayName: 'mapview_testing_rejected',\n    username: 'mapview_testing_rejected',\n    id: 15,\n    email: 'mapview_testing_rejected@test.com',\n    password: 'mapview_testing_rejected@test.com',\n    roles: [],\n    about: '',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'member',\n  },\n  profile_views: {\n    id: 16,\n    createdAt: new Date('2022-01-30T18:51:57.719Z'),\n    displayName: 'profile_views',\n    username: 'profile_views',\n    email: 'profile_views@test.com',\n    password: 'test1234',\n    roles: [],\n    coverImages: [\n      {\n        id: '',\n        path: 'uploads/v3_users/demo_user/images/profile-cover-1.jpg',\n        fullPath: 'uploads/v3_users/demo_user/images/profile-cover-1.jpg',\n        publicUrl: 'uploads/v3_users/demo_user/images/profile-cover-1.jpg',\n      },\n    ],\n    website: 'http://profile_views.example.com',\n    about: 'Hi! I have 99 views',\n    country: 'nl',\n    photo: {\n      id: 'string',\n      path: 'string',\n      fullPath: 'string',\n      publicUrl: 'string',\n    },\n    profileType: 'member',\n  },\n};\n"
  },
  {
    "path": "shared/mocks/index.ts",
    "content": "import * as auth from './auth';\nimport * as data from './data';\n\nexport const MOCKS = {\n  auth,\n  data,\n};\n"
  },
  {
    "path": "shared/models/author.ts",
    "content": "import type { DBMedia, Image } from './media';\nimport type { DBProfileBadge, DBProfileBadgeJoin } from './profileBadge';\nimport { ProfileBadge } from './profileBadge';\nimport type { DBProfileType } from './profileType';\nimport { ProfileType } from './profileType';\n\n// TODO: derive from DBProfile - not doing because was causing circular dependencies\nexport type DBAuthor = {\n  readonly id: number;\n  readonly username: string | null;\n  readonly country: string;\n  readonly display_name: string;\n  readonly photo: DBMedia | null;\n  readonly badges?: DBProfileBadgeJoin[] | DBProfileBadge[];\n  readonly donations_enabled?: boolean;\n  readonly profile_type?: DBProfileType;\n};\n\nexport class Author {\n  id: number;\n  country?: string;\n  displayName: string;\n  badges?: ProfileBadge[];\n  photo: Image | null;\n  username: string | null;\n  donationsEnabled?: boolean;\n  profileType?: ProfileType;\n\n  constructor(author: Author) {\n    Object.assign(this, author);\n  }\n\n  static fromDB(dbAuthor: DBAuthor, photo?: Image) {\n    const badges =\n      dbAuthor.badges?.map((badge) =>\n        (badge as any).profile_badges\n          ? ProfileBadge.fromDBJoin(badge as DBProfileBadgeJoin)\n          : ProfileBadge.fromDB(badge as DBProfileBadge),\n      ) || [];\n\n    return new Author({\n      ...dbAuthor,\n      badges,\n      displayName: dbAuthor.display_name,\n      photo: photo ?? null,\n      donationsEnabled: dbAuthor.donations_enabled,\n      profileType: dbAuthor.profile_type ? ProfileType.fromDB(dbAuthor.profile_type) : undefined,\n    });\n  }\n}\n"
  },
  {
    "path": "shared/models/banner.ts",
    "content": "import type { IDBDocSB, IDoc } from './document';\n\nexport class DBBanner implements IDBDocSB {\n  id: number;\n  created_at: Date;\n  modified_at: Date | null;\n\n  text: string;\n  url: string;\n\n  constructor(obj: any) {\n    Object.assign(this, obj);\n  }\n}\n\nexport class Banner implements IDoc {\n  id: number;\n  createdAt: Date;\n  modifiedAt: Date | null;\n  text: string;\n  url: string;\n\n  constructor(obj: any) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(banner: DBBanner) {\n    const { created_at, id, modified_at, text, url } = banner;\n    return new Banner({\n      id,\n      createdAt: new Date(created_at),\n      modifiedAt: modified_at ? new Date(modified_at) : null,\n      text,\n      url,\n    });\n  }\n}\n"
  },
  {
    "path": "shared/models/category.ts",
    "content": "import type { ContentType } from './common';\nimport type { IDBDocSB, IDoc } from './document';\n\nexport class DBCategory implements IDBDocSB {\n  id: number;\n  created_at: Date;\n  modified_at: Date | null;\n\n  name: string;\n  type: ContentType;\n\n  constructor(obj: any) {\n    Object.assign(this, obj);\n  }\n\n  static toDB(category: Category) {\n    const { createdAt, id, modifiedAt, name, type } = category;\n    return new DBCategory({\n      id,\n      created_at: new Date(createdAt),\n      modified_at: modifiedAt ? new Date(modifiedAt) : null,\n      name,\n      type,\n    });\n  }\n}\n\nexport class Category implements IDoc {\n  id: number;\n  createdAt: Date;\n  modifiedAt: Date | null;\n  name: string;\n  type: ContentType;\n\n  constructor(obj: any) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(category: DBCategory) {\n    const { created_at, id, modified_at, name, type } = category;\n    return new Category({\n      id,\n      createdAt: new Date(created_at),\n      modifiedAt: modified_at ? new Date(modified_at) : null,\n      name,\n      type,\n    });\n  }\n}\n"
  },
  {
    "path": "shared/models/comment.ts",
    "content": "import type { DBAuthor } from './author';\nimport { Author } from './author';\nimport type { DiscussionContentType } from './common';\nimport type { IDBDocSB, IDoc } from './document';\n\nexport class DBComment implements IDBDocSB {\n  readonly id: number;\n  readonly created_at: Date;\n  readonly modified_at: Date | null;\n  readonly created_by: number | null;\n  readonly deleted: boolean;\n\n  readonly profile?: DBAuthor;\n  readonly comment: string;\n  readonly source_id: number | null;\n  readonly source_type: DiscussionContentType;\n  readonly source_id_legacy: string | null;\n  readonly parent_id: number | null;\n  readonly vote_count?: number;\n  readonly has_voted?: boolean;\n\n  constructor(comment: DBComment) {\n    Object.assign(this, comment);\n  }\n}\n\nexport class Comment implements IDoc {\n  id: number;\n  createdAt: Date;\n  modifiedAt: Date | null;\n  deleted: boolean;\n\n  createdBy: Author | null;\n  comment: string;\n  sourceId: number | string;\n  sourceType: DiscussionContentType;\n  parentId: number | null;\n  highlighted?: boolean;\n  voteCount?: number;\n  hasVoted?: boolean;\n  replies?: Reply[];\n\n  constructor(comment: Comment) {\n    Object.assign(this, comment);\n  }\n\n  static fromDB(obj: DBComment, replies?: Reply[]) {\n    return new Comment({\n      id: obj.id,\n      createdAt: new Date(obj.created_at),\n      createdBy: obj.profile ? Author.fromDB(obj.profile) : null,\n      modifiedAt: obj.modified_at ? new Date(obj.modified_at) : null,\n      comment: obj.comment,\n      sourceId: obj.source_id || obj.source_id_legacy || 0,\n      sourceType: obj.source_type,\n      parentId: obj.parent_id,\n      deleted: obj.deleted,\n      voteCount: obj.vote_count || 0,\n      hasVoted: obj.has_voted,\n      replies: replies,\n    });\n  }\n}\n\nexport type Reply = Omit<Comment, 'replies'>;\n"
  },
  {
    "path": "shared/models/common.ts",
    "content": "// A reminder that dates should be saved in the ISOString format\n// i.e. new Date().toISOString() => 2011-10-05T14:48:00.000Z\n// This is more consistent than others and allows better querying\nexport type ISODateString = string;\n\nexport type FetchState = 'idle' | 'fetching' | 'completed';\n\nexport interface ILatLng {\n  lat: number;\n  lng: number;\n}\n\nexport type Collaborator = {\n  countryCode?: string | null;\n  userName: string | null;\n  isVerified: boolean;\n};\n\nexport type ContentType = 'questions' | 'projects' | 'research' | 'news';\n\nexport type UsefulContentType = 'questions' | 'projects' | 'research' | 'news' | 'comments';\n\nexport type ContentFormType = 'questions' | 'projects' | 'research' | 'researchUpdate' | 'news';\n\nexport const DiscussionContentTypes = [\n  'questions',\n  'projects',\n  'research_updates',\n  'news',\n] as const;\nexport type DiscussionContentType = (typeof DiscussionContentTypes)[number];\n\nexport type SubscribableContentTypes =\n  | 'comments'\n  | 'questions'\n  | 'projects'\n  | 'research'\n  | 'research_updates'\n  | 'news';\n"
  },
  {
    "path": "shared/models/content.ts",
    "content": "import type { Author, DBAuthor } from './author';\nimport type { Category, DBCategory } from './category';\nimport type { IDBDocSB, IDoc } from './document';\nimport type { Tag } from './tag';\n\nexport interface IDBContentDoc extends IDBDocSB {\n  readonly author?: DBAuthor;\n  readonly comment_count?: number;\n  readonly category: DBCategory | null;\n  readonly category_id?: number;\n  readonly created_by: number | null;\n  readonly deleted: boolean | null;\n  is_draft: boolean | null;\n  readonly subscriber_count?: number;\n  readonly title: string;\n  readonly total_views?: number;\n  readonly previous_slugs: string[] | null;\n  readonly slug: string;\n  readonly tags: number[];\n  readonly useful_count?: number;\n}\n\nexport interface IContentDoc extends IDoc {\n  author: Author | null;\n  category: Category | null;\n  commentCount: number;\n  deleted: boolean;\n  isDraft: boolean;\n  title: string;\n  previousSlugs: string[];\n  slug: string;\n  subscriberCount: number;\n  tags: Tag[];\n  tagIds?: number[];\n  totalViews: number;\n  usefulCount: number;\n}\n"
  },
  {
    "path": "shared/models/db.ts",
    "content": "/*************************************************************************************\n * Generate a list of DB endpoints used in the app\n *\n * @param prefix - provide optional prefix to simplify large-scale schema changes or\n * multisite hosting and allow multiple sites to use one DB (used for parallel test seed DBs)\n * e.g. oa_\n *\n * NOTE - these are a bit messy due to various migrations and changes\n * In the future all endpoints should try to just retain prefix-base-revision, e.g. oa_users_rev20201012\n **************************************************************************************/\nexport const generateDBEndpoints = () => ({\n  users: `v3_users`,\n  user_notifications: `user_notifications_rev20221209`,\n  mappins: `v3_mappins`,\n  emails: `emails`,\n  tags: `v3_tags`,\n  user_integrations: `user_integrations`,\n});\n\n/**\n * A list of known subcollections used in endpoints, e.g.\n * /library/my-project-id/stats\n */\nexport const dbEndpointSubcollections = {\n  user: ['revisions'],\n  library: ['stats'],\n  research: ['stats'],\n};\n// React apps populate a process variable, however it might not always be accessible outside\n// (e.g. cypress will instead use it's own env to populate a prefix)\n\n// Hack - allow calls to process from cypress testing (react polyfills process.env otherwise)\nif (!('process' in globalThis)) {\n  globalThis.process = {} as any;\n}\n\n/**\n * Mapping of generic database endpoints to specific prefixed and revisioned versions for the\n * current implementation\n * @example\n * ```\n * const allLibraryItems = await db.get(DB_ENDPOINTS.library)\n * ```\n */\nexport const DB_ENDPOINTS = generateDBEndpoints();\n\nexport type DBEndpoint = keyof typeof DB_ENDPOINTS;\n"
  },
  {
    "path": "shared/models/document.ts",
    "content": "// Base level for all content on supabase\nexport interface IDBDocSB {\n  readonly id: number;\n  readonly created_at: Date;\n  readonly modified_at: Date | null;\n}\n\nexport interface IDoc {\n  id: number;\n  createdAt: Date;\n  modifiedAt: Date | null;\n}\n\nexport interface IDBDownloadable {\n  file_download_count?: number | null;\n  file_link: string | null;\n  files: { id: string; name: string; size: number }[] | null;\n}\n\nexport interface IDownloadable {\n  fileDownloadCount: number | null;\n  files: { id: string; name: string; size: number }[] | null;\n  hasFileLink: boolean | null;\n}\n"
  },
  {
    "path": "shared/models/filesForm.ts",
    "content": "import type { IMediaFile } from './media';\n\nexport interface IFilesForm {\n  files: IMediaFile[] | null;\n  fileLink: string | null;\n}\n"
  },
  {
    "path": "shared/models/index.ts",
    "content": "export * from './author';\nexport * from './banner';\nexport * from './category';\nexport * from './comment';\nexport * from './common';\nexport * from './content';\nexport * from './db';\nexport * from './document';\nexport * from './filesForm';\nexport * from './library';\nexport * from './maps';\nexport * from './media';\nexport * from './messages';\nexport * from './moderation';\nexport * from './news';\nexport * from './notifications';\nexport * from './notificationsPreferences';\nexport * from './patreon';\nexport * from './patreonSettings';\nexport * from './profile';\nexport * from './profileBadge';\nexport * from './profileTag';\nexport * from './profileType';\nexport * from './question';\nexport * from './research';\nexport * from './selectValue';\nexport * from './subscriber';\nexport * from './tag';\nexport * from './tags';\nexport * from './tenantSettings';\nexport * from './upgradeBadge';\nexport * from './user';\nexport * from './userCreatedDocs';\nexport * from './userEmailData';\nexport * from './voteUseful';\nexport * from './webmanifest';\n"
  },
  {
    "path": "shared/models/library.ts",
    "content": "import type { DBAuthor } from './author';\nimport { Author } from './author';\nimport type { DBCategory } from './category';\nimport { Category } from './category';\nimport type { IContentDoc, IDBContentDoc } from './content';\nimport type { IDBDownloadable, IDownloadable } from './document';\nimport type { IFilesForm } from './filesForm';\nimport type { DBMedia, IMediaFile, Image, MediaWithPublicUrl } from './media';\nimport type { IDBModeration, IModeration, Moderation } from './moderation';\nimport type { SelectValue } from './selectValue';\nimport type { Tag } from './tag';\n\nexport type DifficultyLevel = 'easy' | 'medium' | 'hard' | 'very-hard';\nexport const DifficultyLevelRecord: Record<DifficultyLevel, string> = {\n  easy: 'Easy',\n  medium: 'Medium',\n  hard: 'Hard',\n  'very-hard': 'Very Hard',\n};\n\nexport class DBProject implements IDBContentDoc, IDBDownloadable, IDBModeration {\n  readonly id: number;\n  readonly created_at: Date;\n  readonly deleted: boolean | null;\n  readonly published_at: Date | null;\n  readonly author?: DBAuthor;\n  readonly update_count?: number;\n  readonly useful_count?: number;\n  readonly useful_votes_last_week?: number;\n  readonly subscriber_count?: number;\n  readonly comment_count?: number;\n  readonly total_views?: number;\n  readonly category: DBCategory | null;\n  readonly steps: DBProjectStep[] | null;\n  created_by: number | null;\n  modified_at: Date | null;\n  title: string;\n  slug: string;\n  previous_slugs: string[] | null;\n  description: string;\n  difficulty_level: DifficultyLevel;\n  cover_image: DBMedia | null;\n  file_link: string | null;\n  files: IMediaFile[] | null;\n  category_id?: number;\n  tags: number[];\n  is_draft: boolean | null;\n  time?: string;\n  file_download_count?: number;\n  moderation: Moderation;\n  moderation_feedback: string;\n\n  constructor(obj: Omit<DBProject, 'id'>) {\n    Object.assign(this, obj);\n  }\n\n  static toFormData(obj: DBProject, images: Image[]) {\n    const publicCoverImage = images?.find((x) => x.id === obj.cover_image?.id);\n\n    return {\n      title: obj.title,\n      description: obj.description,\n      coverImage:\n        obj.cover_image && publicCoverImage ? { ...obj.cover_image, ...publicCoverImage } : null,\n      category: obj.category\n        ? { value: obj.category.id.toString(), label: obj.category.name }\n        : null,\n      tags: obj.tags,\n      difficultyLevel: obj.difficulty_level,\n      files: obj.files,\n      fileLink: obj.file_link,\n      time: obj.time ?? null,\n      steps: obj.steps\n        ? obj.steps\n            .sort((a, b) => a.order - b.order)\n            .map((x) => DBProjectStep.toFormData(x, images))\n        : [],\n    } satisfies ProjectFormData;\n  }\n}\n\nexport class Project implements IContentDoc, IDownloadable, IModeration {\n  id: number;\n  createdAt: Date;\n  author: Author | null;\n  modifiedAt: Date | null;\n  publishedAt: Date | null;\n  title: string;\n  slug: string;\n  previousSlugs: string[];\n  description: string;\n  coverImage: Image | null;\n  deleted: boolean;\n  category: Category | null;\n  totalViews: number;\n  files: IMediaFile[] | null;\n  hasFileLink: boolean;\n  tags: Tag[];\n  tagIds?: number[];\n  difficultyLevel: DifficultyLevel;\n  steps: ProjectStep[];\n  isDraft: boolean;\n  usefulCount: number;\n  usefulVotesLastWeek?: number;\n  subscriberCount: number;\n  commentCount: number;\n  fileDownloadCount: number;\n  moderation: Moderation;\n  moderationFeedback?: string;\n  time?: string;\n\n  constructor(obj: Project) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(obj: DBProject, tags: Tag[], images: Image[] = []) {\n    const steps = obj.steps?.map((update) => ProjectStep.fromDB(update, images)) || [];\n\n    return new Project({\n      id: obj.id,\n      createdAt: new Date(obj.created_at),\n      author: obj.author ? Author.fromDB(obj.author) : null,\n      modifiedAt: obj.modified_at ? new Date(obj.modified_at) : null,\n      publishedAt: obj.published_at ? new Date(obj.published_at) : null,\n      title: obj.title,\n      slug: obj.slug,\n      previousSlugs: obj.previous_slugs || [],\n      description: obj.description,\n      coverImage: images?.find((x) => x.id === obj.cover_image?.id) || null,\n      deleted: obj.deleted || false,\n      category: obj.category ? Category.fromDB(obj.category) : null,\n      totalViews: obj.total_views || 0,\n      tagIds: obj.tags?.map((x) => Number(x)),\n      tags: tags,\n      difficultyLevel: obj.difficulty_level,\n      subscriberCount: obj.subscriber_count || 0,\n      commentCount: obj.comment_count || 0,\n      usefulCount: obj.useful_count || 0,\n      usefulVotesLastWeek: obj.useful_votes_last_week || 0,\n      isDraft: obj.is_draft || false,\n      fileDownloadCount: obj.file_download_count || 0,\n      files: obj.files,\n      moderation: obj.moderation,\n      moderationFeedback: obj.moderation_feedback,\n      // no fileLink as it must be shown only for authenticated users\n      hasFileLink: !!obj.file_link,\n      time: obj.time,\n      steps,\n    });\n  }\n}\n\nexport class DBProjectStep {\n  readonly id: number;\n  readonly project_id: number;\n  title: string;\n  description: string;\n  images: DBMedia[] | null;\n  video_url: string | null;\n  order: number;\n\n  constructor(obj: Omit<DBProjectStep, 'id'>) {\n    Object.assign(this, obj);\n  }\n\n  static toFormData(obj: DBProjectStep, images: Image[]) {\n    return {\n      id: obj.id,\n      title: obj.title,\n      description: obj.description,\n      images: obj.images\n        ? obj.images\n            .map((dbImage) => {\n              const publicImage = images.find((img) => img.id === dbImage.id);\n              return publicImage ? { ...dbImage, ...publicImage } : null;\n            })\n            .filter((img) => img !== null)\n        : null,\n      videoUrl: obj.video_url,\n    } satisfies ProjectStepFormData;\n  }\n}\n\nexport class ProjectStep {\n  id: number;\n  projectId: number;\n  title: string;\n  description: string;\n  images: Image[] | null;\n  videoUrl: string | null;\n  order: number;\n\n  constructor(obj: ProjectStep) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(obj: DBProjectStep, images?: Image[]) {\n    const imageIds = obj.images?.map((x) => x.id) || [];\n    const filteredImages = images?.filter((x) => imageIds.includes(x.id)) || [];\n    // Deduplicate by id\n    const uniqueImagesMap = new Map(filteredImages.map((img) => [img.id, img]));\n    const uniqueImages = Array.from(uniqueImagesMap.values());\n\n    return new ProjectStep({\n      id: obj.id,\n      projectId: obj.project_id,\n      title: obj.title,\n      description: obj.description,\n      images: uniqueImages,\n      videoUrl: obj.video_url,\n      order: obj.order,\n    });\n  }\n}\n\nexport interface ProjectFormData extends IFilesForm {\n  title: string;\n  description: string;\n  category: SelectValue | null;\n  tags: number[] | null;\n  difficultyLevel: DifficultyLevel | null;\n  time: string | null;\n  coverImage: MediaWithPublicUrl | null;\n  steps: ProjectStepFormData[];\n}\n\nexport type ProjectStepFormData = {\n  id: number | null;\n  title: string;\n  description: string;\n  images: MediaWithPublicUrl[] | null;\n  videoUrl: string | null;\n};\n\nexport type ProjectDTO = {\n  title: string;\n  description: string;\n  category: number | null;\n  tags: number[] | null;\n  difficultyLevel: DifficultyLevel | null;\n  time: string | null;\n  coverImage: DBMedia | null;\n  isDraft: boolean;\n  stepCount: number;\n  files: IMediaFile[] | null;\n  fileLink: string | null;\n};\n\nexport type ProjectStepDTO = {\n  title: string;\n  description: string;\n  images: DBMedia[] | null;\n  videoUrl: string | null;\n};\n"
  },
  {
    "path": "shared/models/maps.ts",
    "content": "import type { ILatLng } from './common';\nimport type { ProfileBadge } from './profileBadge';\nimport type { ProfileTag } from './profileTag';\nimport type { ProfileType } from './profileType';\nimport type { WorkspaceType } from './user';\n\nexport interface IMapGrouping {\n  _count?: number;\n  grouping: IPinGrouping;\n  displayName: string;\n  type: string;\n  subType?: WorkspaceType;\n  icon: string;\n  hidden?: boolean;\n}\n\nexport interface IBoundingBox {\n  _northEast: ILatLng;\n  _southWest: ILatLng;\n}\n\nexport enum IPinGrouping {\n  INDIVIDUAL = 'individual',\n  PLACE = 'place',\n}\n\nexport type MapFilters = {\n  tags?: ProfileTag[];\n  badges?: ProfileBadge[];\n  types?: ProfileType[];\n  settings?: string[];\n};\n\nexport type DBMapSettings = {\n  default_type_filters: string[] | null;\n  setting_filters: string[] | null;\n};\n\nexport type DefaultMapFilters = {\n  types?: string[];\n};\n\nexport type FilterResponse = {\n  filters: MapFilters;\n  defaultFilters: DefaultMapFilters;\n};\n"
  },
  {
    "path": "shared/models/media.ts",
    "content": "export class DBMedia {\n  id: string;\n  path: string;\n  fullPath: string;\n\n  constructor(obj: DBMedia) {\n    this.id = obj.id;\n    this.path = obj.path;\n    this.fullPath = obj.fullPath;\n  }\n\n  static fromPublicMedia(obj: MediaWithPublicUrl) {\n    return new DBMedia({\n      id: obj.id,\n      path: obj.path,\n      fullPath: obj.fullPath,\n    });\n  }\n}\n\ninterface IMedia {\n  id: string;\n  publicUrl: string;\n}\n\nexport interface IMediaFile {\n  id: string;\n  name: string;\n  size: number;\n}\n\nexport class Image implements IMedia {\n  id: string;\n  publicUrl: string;\n\n  constructor(obj: Image) {\n    Object.assign(this, obj);\n  }\n}\n\nexport class MediaFile implements IMediaFile {\n  id: string;\n  name: string;\n  size: number;\n  url?: string;\n\n  constructor(obj: MediaFile) {\n    Object.assign(this, obj);\n  }\n}\n\nexport type MediaWithPublicUrl = DBMedia & Image;\n"
  },
  {
    "path": "shared/models/messages.ts",
    "content": "export type SendMessage = {\n  to: string;\n  message: string;\n  name: string;\n};\n"
  },
  {
    "path": "shared/models/moderation.ts",
    "content": "export interface IModeration {\n  moderation: Moderation;\n  moderationFeedback?: string;\n}\n\nexport interface IDBModeration {\n  moderation: Moderation;\n  moderation_feedback: string | null;\n}\n\nexport type Moderation = 'awaiting-moderation' | 'improvements-needed' | 'rejected' | 'accepted';\n"
  },
  {
    "path": "shared/models/news.ts",
    "content": "import { marked } from 'marked';\nimport { processStandaloneYouTubeUrls, processYouTubeLinks } from '../utils/markdown';\nimport type { DBAuthor } from './author';\nimport { Author } from './author';\nimport type { DBCategory } from './category';\nimport { Category } from './category';\nimport type { IContentDoc, IDBContentDoc } from './content';\nimport { DBMedia, Image, MediaWithPublicUrl } from './media';\nimport type { DBProfileBadge } from './profileBadge';\nimport { ProfileBadge } from './profileBadge';\nimport type { SelectValue } from './selectValue';\nimport type { Tag } from './tag';\n\nexport class DBNews implements IDBContentDoc {\n  readonly id: number;\n  readonly created_at: Date;\n  readonly modified_at: Date | null;\n  readonly published_at: Date | null;\n  readonly author?: DBAuthor;\n  readonly comment_count?: number;\n  readonly category: DBCategory | null;\n  readonly category_id?: number;\n  readonly created_by: number | null;\n  readonly deleted: boolean | null;\n  is_draft: boolean | null;\n  readonly subscriber_count?: number;\n  readonly title: string;\n  readonly total_views?: number;\n  readonly previous_slugs: string[];\n  readonly profile_badge: DBProfileBadge | null;\n  readonly slug: string;\n  readonly summary: string | null;\n  readonly tags: number[];\n  readonly useful_count?: number;\n  readonly body: string;\n  readonly hero_image: DBMedia | null;\n\n  static toFormData(news: DBNews, publicHeroImage: Image | null) {\n    let htmlBody = marked(news.body, {\n      breaks: true,\n      gfm: true,\n    }) as string;\n\n    htmlBody = processYouTubeLinks(htmlBody);\n    htmlBody = processStandaloneYouTubeUrls(htmlBody);\n\n    return {\n      body: news.body,\n      category: news.category\n        ? { value: news.category.id.toString(), label: news.category.name }\n        : null,\n      isDraft: news.is_draft || false,\n      heroImage:\n        news.hero_image && publicHeroImage ? { ...news.hero_image, ...publicHeroImage } : null,\n      profileBadge: news.profile_badge\n        ? { value: news.profile_badge.id.toString(), label: news.profile_badge.name }\n        : null,\n      tags: news.tags,\n      title: news.title,\n    } satisfies NewsFormData;\n  }\n}\n\nexport type EditNews = Omit<News, 'heroImage'> & {\n  heroImage: DBMedia | null;\n};\n\nexport class News implements IContentDoc {\n  id: number;\n  author: Author | null;\n  body: string;\n  bodyHtml: string;\n  category: Category | null;\n  commentCount: number;\n  createdAt: Date;\n  deleted: boolean;\n  heroImage: Image | null;\n  isDraft: boolean;\n  modifiedAt: Date | null;\n  profileBadge: ProfileBadge | null;\n  previousSlugs: string[];\n  publishedAt: Date | null;\n  slug: string;\n  subscriberCount: number;\n  summary: string | null;\n  tags: Tag[];\n  tagIds?: number[];\n  title: string;\n  totalViews: number;\n  usefulCount: number;\n\n  constructor(news: Partial<News>) {\n    Object.assign(this, news);\n  }\n\n  static fromDB(news: DBNews, tags: Tag[], heroImage?: Image | null) {\n    let htmlBody = marked(news.body, {\n      breaks: true,\n      gfm: true,\n    }) as string;\n\n    htmlBody = processYouTubeLinks(htmlBody);\n    htmlBody = processStandaloneYouTubeUrls(htmlBody);\n\n    return new News({\n      id: news.id,\n      author: news.author ? Author.fromDB(news.author) : null,\n      body: news.body,\n      bodyHtml: htmlBody,\n      category: news.category ? Category.fromDB(news.category) : null,\n      commentCount: news.comment_count || 0,\n      createdAt: new Date(news.created_at),\n      deleted: news.deleted || false,\n      isDraft: news.is_draft || false,\n      heroImage: heroImage || null,\n      modifiedAt: news.modified_at ? new Date(news.modified_at) : null,\n      profileBadge: news.profile_badge ? ProfileBadge.fromDB(news.profile_badge) : null,\n      previousSlugs: news.previous_slugs,\n      publishedAt: news.published_at ? new Date(news.published_at) : null,\n      slug: news.slug,\n      subscriberCount: news.subscriber_count || 0,\n      summary: news.summary || null,\n      tagIds: news.tags,\n      tags,\n      title: news.title,\n      totalViews: news.total_views || 0,\n      usefulCount: news.useful_count || 0,\n    });\n  }\n}\n\nexport type NewsFormData = {\n  body: string | null;\n  category: SelectValue | null;\n  heroImage: MediaWithPublicUrl | null;\n  isDraft: boolean | null;\n  profileBadge: SelectValue | null;\n  tags?: number[];\n  title: string;\n};\n\nexport type NewsDTO = {\n  title: string;\n  body: string | null;\n  category: number | null;\n  heroImage: DBMedia | null;\n  isDraft: boolean | null;\n  profileBadge: number | null;\n  tags: number[] | null;\n};\n"
  },
  {
    "path": "shared/models/notifications.ts",
    "content": "// Do not use\n// Old firebase database type\n\nexport enum EmailNotificationFrequency {\n  NEVER = 'never',\n  DAILY = 'daily',\n  WEEKLY = 'weekly',\n  MONTHLY = 'monthly',\n}\n\nexport const NotificationTypes = [\n  'new_comment', // legacy format, should use new_comment_discussion\n  'howto_useful',\n  'howto_mention',\n  'howto_approved',\n  'howto_needs_updates',\n  'map_pin_approved',\n  'map_pin_needs_updates',\n  'new_comment_discussion',\n  'new_comment_research', // legacy format, should use new_comment_discussion\n  'research_useful',\n  'research_mention',\n  'research_update',\n  'research_approved',\n  'research_needs_updates',\n] as const;\n\nexport type NotificationType = (typeof NotificationTypes)[number];\n\nexport type UserNotificationItem = {\n  type: NotificationType;\n  children: React.ReactNode;\n};\n\nexport interface INotification {\n  _id: string;\n  _created: string;\n  triggeredBy: {\n    displayName: string;\n    // this field is the userName of the user, which we use as a unique id as of https://github.com/ONEARMY/community-platform/pull/2479/files\n    userId: string;\n  };\n  relevantUrl?: string;\n  type: NotificationType;\n  read: boolean;\n  notified: boolean;\n  // email contains the id of the doc in the emails collection if the notification was included in\n  // an email or 'failed' if an email with this notification was attempted and encountered an error\n  email?: string;\n  title?: string;\n}\n\nexport type INotificationSettings = {\n  enabled?: {\n    [T in NotificationType]: boolean;\n  };\n  emailFrequency: EmailNotificationFrequency;\n};\n\nexport interface IPendingEmails {\n  _authID: string;\n  _userId: string;\n  emailFrequency?: INotificationSettings['emailFrequency'];\n  notifications: INotification[];\n}\n"
  },
  {
    "path": "shared/models/notificationsPreferences.ts",
    "content": "export class NotificationsPreferences {\n  id?: number;\n  user_id?: number;\n  comments: boolean;\n  replies: boolean;\n  researchUpdates: boolean;\n  isUnsubscribed: boolean;\n}\n\nexport class DBNotificationsPreferencesFields {\n  comments: boolean;\n  replies: boolean;\n  research_updates: boolean;\n  is_unsubscribed: boolean;\n}\n\nexport class DBNotificationsPreferences extends DBNotificationsPreferencesFields {\n  id: number;\n  user_id: number;\n}\n\nexport interface DBPreferencesWithProfileContact {\n  preferences: DBNotificationsPreferences;\n  is_contactable: undefined | boolean;\n}\n\nexport type NotificationsPreferenceTypes = 'comments' | 'replies' | 'research_updates';\n\nexport interface NotificationsPreferencesFormData {\n  comments: boolean;\n  replies: boolean;\n  research_updates: boolean;\n  id?: number;\n}\n\nexport interface NotificationsPreferencesViaEmailFormData {\n  comments: boolean;\n  replies: boolean;\n  research_updates: boolean;\n  userCode: string;\n}\n"
  },
  {
    "path": "shared/models/patreon.ts",
    "content": "export interface IPatreonUserAttributes {\n  about: string;\n  created: string;\n  email: string;\n  first_name: string;\n  full_name: string;\n  image_url: string;\n  last_name: string;\n  thumb_url: string;\n  url: string;\n}\n\nexport interface IPatreonMembershipAttributes {\n  campaign_lifetime_support_cents: number;\n  currently_entitled_amount_cents: number;\n  is_follower: boolean;\n  last_charge_date: string;\n  last_charge_status: string;\n  lifetime_support_cents: number;\n  next_charge_date: string;\n  note: string;\n  patron_status: string;\n  pledge_cadence: string;\n  pledge_relationship_start: string;\n  will_pay_amount_cents: number;\n}\n\nexport interface IPatreonTierAttributes {\n  amount_cents: number;\n  created_at: string;\n  description: string;\n  edited_at: string;\n  image_url: string;\n  patron_count: number;\n  published: boolean;\n  published_at: string;\n  title: string;\n  url: string;\n}\n\ninterface IPatreonTier {\n  id: string;\n  attributes: IPatreonTierAttributes;\n}\n\nexport interface IPatreonMembership {\n  id: string;\n  attributes: IPatreonMembershipAttributes;\n  tiers: IPatreonTier[];\n}\n\nexport interface IPatreonUser {\n  id: string;\n  attributes: IPatreonUserAttributes;\n  link: string;\n  membership?: IPatreonMembership;\n}\n"
  },
  {
    "path": "shared/models/patreonSettings.ts",
    "content": "export type PatreonTier = {\n  id: string;\n  name: string;\n  description: string;\n};\n\nexport type PatreonSettings = {\n  id: string;\n  created_at: Date;\n  tiers: PatreonTier[];\n  tenant_id: string;\n};\n"
  },
  {
    "path": "shared/models/profile.ts",
    "content": "import type { Comment } from './comment';\nimport type { SubscribableContentTypes } from './common';\nimport type { IDBDocSB, IDoc } from './document';\nimport type { DBMedia, MediaWithPublicUrl } from './media';\nimport type { IDBModeration, IModeration, Moderation } from './moderation';\nimport type { News } from './news';\nimport type { IPatreonUser } from './patreon';\nimport type { DBProfileBadgeJoin } from './profileBadge';\nimport { ProfileBadge } from './profileBadge';\nimport type { DBProfileTagJoin } from './profileTag';\nimport { ProfileTag } from './profileTag';\nimport type { DBProfileType } from './profileType';\nimport { ProfileType } from './profileType';\nimport type { Question } from './question';\nimport type { ResearchUpdate } from './research';\nimport type { IUserImpact, UserVisitorPreference } from './user';\n\nexport class DBProfile {\n  readonly id: number;\n  readonly created_at: Date;\n  readonly tags?: DBProfileTagJoin[];\n  readonly badges?: DBProfileBadgeJoin[];\n  readonly pin?: DBMapPin;\n  readonly type?: DBProfileType;\n  username: string | null;\n  display_name: string;\n  photo: DBMedia | null;\n  cover_images: DBMedia[] | null;\n  country: string;\n  patreon?: IPatreonUser;\n  roles: string[] | null;\n  visitor_policy: string | null;\n  is_blocked_from_messaging: boolean | null;\n  about: string | null;\n  impact: string | null;\n  is_contactable: boolean | null;\n  last_active: Date | null;\n  website: string | null;\n  total_views: number;\n  auth_id: string;\n  profile_type: number;\n  donations_enabled: boolean;\n\n  constructor(obj: DBProfile) {\n    Object.assign(this, obj);\n  }\n}\n\nexport class Profile {\n  id: number;\n  createdAt: Date;\n  username: string | null;\n  displayName: string;\n  country: string;\n  about: string | null;\n  type: ProfileType | null;\n  impact: IUserImpact | null;\n  photo: MediaWithPublicUrl | null;\n  isContactable: boolean | null;\n  isBlockedFromMessaging: boolean;\n  visitorPolicy: UserVisitorPreference | null;\n  website: string | null;\n  tags?: ProfileTag[];\n  badges?: ProfileBadge[];\n  totalViews: number;\n  roles: string[] | null;\n  lastActive: Date | null;\n  coverImages: MediaWithPublicUrl[] | null;\n  patreon: IPatreonUser | null;\n  authorUsefulVotes?: AuthorVotes[];\n  donationsEnabled: boolean;\n\n  constructor(obj: Profile) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(\n    dbProfile: DBProfile,\n    photo: MediaWithPublicUrl | null = null,\n    coverImages: MediaWithPublicUrl[] | null = null,\n    authorVotes?: AuthorVotes[],\n  ) {\n    let impact = null;\n\n    try {\n      impact = dbProfile.impact ? JSON.parse(dbProfile.impact) : null;\n    } catch (_) {\n      console.error('error parsing impact');\n    }\n\n    return new Profile({\n      id: dbProfile.id,\n      createdAt: dbProfile.created_at,\n      country: dbProfile.country,\n      displayName: dbProfile.display_name,\n      username: dbProfile.username,\n      photo: photo ?? null,\n      roles: dbProfile.roles || null,\n      type: dbProfile.type ? ProfileType.fromDB(dbProfile.type) : null,\n      visitorPolicy: dbProfile.visitor_policy\n        ? (JSON.parse(dbProfile.visitor_policy) as UserVisitorPreference)\n        : null,\n      isBlockedFromMessaging: !!dbProfile.is_blocked_from_messaging,\n      about: dbProfile.about,\n      coverImages: coverImages ?? null,\n      impact,\n      isContactable: dbProfile.is_contactable || null,\n      lastActive: dbProfile.last_active,\n      website: dbProfile.website,\n      patreon: dbProfile.patreon ?? null,\n      totalViews: dbProfile.total_views,\n      authorUsefulVotes: authorVotes,\n      donationsEnabled: dbProfile.donations_enabled,\n      badges: dbProfile.badges?.map((x) => ProfileBadge.fromDBJoin(x)),\n      tags: dbProfile.tags?.map((x) => ProfileTag.fromDBJoin(x)),\n    });\n  }\n}\n\n// Notifications here to avoid circular dependencies\n\nexport type NotificationActionType = 'newContent' | 'newComment' | 'newReply';\nexport const NotificationContentTypes = ['research_updates', 'comments'] as const;\nexport type NotificationContentType = (typeof NotificationContentTypes)[number];\nexport type BasicAuthorDetails = Pick<Profile, 'id' | 'username' | 'photo'>;\nexport type ProfileListItem = Pick<\n  Profile,\n  'id' | 'username' | 'displayName' | 'photo' | 'country' | 'badges' | 'type'\n>;\n\ntype NotificationContent = News | Comment | Question | ResearchUpdate;\ntype NotificationSourceContentType = SubscribableContentTypes;\n\nexport class DBNotification implements IDBDocSB {\n  readonly id: number;\n  readonly title: string;\n  readonly action_type: NotificationActionType;\n  readonly content_id: number;\n  readonly content_type: NotificationContentType;\n  readonly created_at: Date;\n  is_read: boolean;\n  modified_at: Date | null;\n  readonly source_content_type: NotificationSourceContentType;\n  readonly source_content_id: number;\n  readonly owned_by: DBProfile;\n  readonly owned_by_id: number;\n  readonly triggered_by: DBProfile;\n  readonly triggered_by_id: number;\n  readonly tenant_id: string;\n\n  constructor(obj: Partial<DBNotification>) {\n    Object.assign(this, obj);\n  }\n}\n\nexport class Notification implements IDoc {\n  id: number;\n  actionType: NotificationActionType;\n  title: string;\n  contentId: number;\n  contentType: NotificationContentType;\n  createdAt: Date;\n  modifiedAt: Date | null;\n  ownedById: number;\n  isRead: boolean;\n  sourceContentType: NotificationSourceContentType;\n  sourceContentId: number;\n\n  content?: NotificationContent;\n  ownedBy?: BasicAuthorDetails;\n  triggeredBy?: BasicAuthorDetails;\n\n  constructor(obj: Notification) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(dbNotification: DBNotification) {\n    return new Notification({\n      id: dbNotification.id,\n      title: dbNotification.title,\n      actionType: dbNotification.action_type,\n      contentType: dbNotification.content_type,\n      contentId: dbNotification.content_id,\n      sourceContentId: dbNotification.source_content_id,\n      sourceContentType: dbNotification.source_content_type,\n      createdAt: new Date(dbNotification.created_at),\n      modifiedAt: dbNotification.modified_at ? new Date(dbNotification.modified_at) : null,\n      ownedById: dbNotification.owned_by_id,\n      isRead: dbNotification.is_read,\n      triggeredBy: dbNotification.triggered_by\n        ? Profile.fromDB(dbNotification.triggered_by)\n        : undefined,\n      ownedBy: dbNotification.owned_by ? Profile.fromDB(dbNotification.owned_by) : undefined,\n    });\n  }\n}\n\nexport class NotificationDisplay {\n  id: number;\n  isRead: boolean;\n  contentType: NotificationContentType;\n  body?: string;\n  date: Date;\n  email: {\n    body: string | undefined;\n    buttonLabel: string;\n    preview: string;\n    subject: string;\n  };\n  link: string;\n  sidebar: {\n    icon?: string;\n    image?: string;\n  };\n  title: string;\n  triggeredBy: string;\n\n  constructor(obj: NotificationDisplay) {\n    Object.assign(this, obj);\n  }\n\n  static setEmailBody(notification: Notification): string {\n    switch (notification.contentType) {\n      case 'research_updates': {\n        return `${(notification.content as ResearchUpdate)?.title}:\\n\\n${(notification.content as ResearchUpdate)?.description}`;\n      }\n      default: {\n        return this.setBody(notification) || '';\n      }\n    }\n  }\n\n  static setEmailButtonLabel(notification: Notification) {\n    switch (notification.contentType) {\n      case 'research_updates': {\n        return 'Join the discussion';\n      }\n      case 'comments': {\n        return 'See the full discussion';\n      }\n      default: {\n        return 'View now';\n      }\n    }\n  }\n\n  static setEmailPreview(notification: Notification) {\n    switch (notification.actionType) {\n      case 'newContent': {\n        return `New research update on ${notification.title}`;\n      }\n      case 'newComment': {\n        if (notification.triggeredBy && notification.triggeredBy.username) {\n          return `${notification.triggeredBy.username} has left a new comment`;\n        }\n        return 'A new comment notification';\n      }\n      case 'newReply': {\n        if (notification.triggeredBy && notification.triggeredBy.username) {\n          return `${notification.triggeredBy.username} has left a new reply`;\n        }\n        return 'A new reply notification';\n      }\n      default: {\n        return 'A new notification';\n      }\n    }\n  }\n\n  static setEmailSubject(notification: Notification) {\n    switch (notification.actionType) {\n      case 'newContent': {\n        return `New update on ${notification.title}`;\n      }\n      case 'newComment': {\n        return `New comment on ${notification.title}`;\n      }\n      case 'newReply': {\n        return `You have a new comment reply!`;\n      }\n      default: {\n        return 'You have a new notification!';\n      }\n    }\n  }\n\n  static setBody(notification: Notification): string | undefined {\n    switch (notification.contentType) {\n      case 'research_updates': {\n        return (notification.content as ResearchUpdate)?.title;\n      }\n      case 'comments': {\n        return (notification.content as Comment).comment;\n      }\n      default: {\n        return '';\n      }\n    }\n  }\n\n  static setDate(notification: Notification) {\n    return notification.modifiedAt\n      ? new Date(notification.modifiedAt)\n      : new Date(notification.createdAt);\n  }\n\n  static setTitle(notification: Notification) {\n    switch (notification.actionType) {\n      case 'newContent': {\n        return `published a new update on ${notification.title}`;\n      }\n      case 'newComment': {\n        return `left a comment on ${notification.title}`;\n      }\n      case 'newReply': {\n        return `left a reply`;\n      }\n      default: {\n        return notification.title;\n      }\n    }\n  }\n\n  static setSidebarIcon(contentType: NotificationContentType): string {\n    switch (contentType) {\n      case 'comments': {\n        return 'comment';\n      }\n      case 'research_updates': {\n        return 'update';\n      }\n      default: {\n        return 'thunderbolt';\n      }\n    }\n  }\n\n  static setSidebarImage(author: BasicAuthorDetails | undefined): string {\n    return author?.photo?.publicUrl || '';\n  }\n\n  static setLink(notification: Notification) {\n    return `/redirect?id=${notification.contentId}&ct=${notification.contentType}`;\n  }\n\n  static fromNotification(notification: Notification): NotificationDisplay {\n    return new NotificationDisplay({\n      id: notification.id,\n      isRead: notification.isRead,\n      body: this.setBody(notification),\n      contentType: notification.contentType,\n      date: this.setDate(notification),\n      email: {\n        body: this.setEmailBody(notification),\n        buttonLabel: this.setEmailButtonLabel(notification),\n        preview: this.setEmailPreview(notification),\n        subject: this.setEmailSubject(notification),\n      },\n      sidebar: {\n        icon: this.setSidebarIcon(notification.contentType),\n        image: this.setSidebarImage(notification.triggeredBy),\n      },\n      title: this.setTitle(notification),\n      triggeredBy: notification.triggeredBy?.username || '',\n      link: this.setLink(notification),\n    });\n  }\n}\n\nexport type ProfileFormData = {\n  username: string;\n  displayName: string;\n  tagIds: number[] | null;\n  about: string;\n  country: string;\n  website: string;\n  isContactable: boolean;\n  type: string;\n  photo?: MediaWithPublicUrl;\n  coverImages?: MediaWithPublicUrl[];\n  showVisitorPolicy: boolean;\n  visitorPreferencePolicy?: UserVisitorPreference['policy'];\n  visitorPreferenceDetails?: UserVisitorPreference['details'];\n};\n\nexport type ProfileDTO = {\n  displayName: string;\n  about: string;\n  isContactable: boolean;\n  type: string;\n  country: string | null;\n  website: string | null;\n  photo: DBMedia | null;\n  coverImages: DBMedia[] | null;\n  tagIds: number[] | null;\n  showVisitorPolicy: boolean;\n  visitorPreferencePolicy: UserVisitorPreference['policy'] | null;\n  visitorPreferenceDetails: UserVisitorPreference['details'] | null;\n};\n\nexport class DBMapPin implements IDBModeration {\n  readonly id: number;\n  readonly profile: DBPinProfile;\n  profile_id: number;\n  country: string; // check if necessary\n  country_code: string;\n  name: string | null;\n  administrative: string | null;\n  post_code: string | null;\n  lat: number;\n  lng: number;\n  moderation: Moderation;\n  moderation_feedback: string;\n}\n\nexport class MapPin implements IModeration {\n  readonly id: number;\n  readonly profileId: number;\n  readonly profile: PinProfile;\n  country: string;\n  countryCode: string;\n  name: string | null;\n  administrative: string | null;\n  postCode: string | null;\n  lat: number;\n  lng: number;\n  moderation: Moderation;\n  moderationFeedback?: string;\n\n  constructor(obj: MapPin) {\n    Object.assign(this, obj);\n  }\n}\n\nexport type MapPinFormData = {\n  lat: number;\n  lng: number;\n  country: string;\n  countryCode: string;\n  administrative: string;\n  postCode: string;\n  name: string;\n};\n\nexport interface DBAuthorVotes {\n  content_type: string;\n  vote_count: number;\n}\n\nexport class AuthorVotes {\n  contentType: string;\n  voteCount: number;\n\n  constructor(obj: AuthorVotes) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(dbVotes: DBAuthorVotes) {\n    return new AuthorVotes({\n      contentType: dbVotes.content_type,\n      voteCount: dbVotes.vote_count,\n    });\n  }\n}\n\nexport type DBPinProfile = Pick<\n  DBProfile,\n  | 'id'\n  | 'display_name'\n  | 'username'\n  | 'country'\n  | 'cover_images'\n  | 'photo'\n  | 'tags'\n  | 'type'\n  | 'visitor_policy'\n  | 'about'\n  | 'is_contactable'\n  | 'last_active'\n> & { badges: DBProfileBadgeJoin[] };\n\nexport type PinProfile = Pick<\n  Profile,\n  | 'id'\n  | 'displayName'\n  | 'username'\n  | 'country'\n  | 'coverImages'\n  | 'photo'\n  | 'tags'\n  | 'type'\n  | 'visitorPolicy'\n  | 'about'\n  | 'isContactable'\n  | 'lastActive'\n> & { badges: ProfileBadge[] };\n\nexport type UpsertPin = Omit<DBMapPin, 'id' | 'profile' | 'moderation' | 'moderation_feedback'>;\n\nexport type SubscribedUser = {\n  profile_id: number;\n  profile_created_at: string;\n  email: string;\n  is_unsubscribed: boolean;\n  replies: boolean;\n  comments: boolean;\n  research_updates: boolean;\n};\n"
  },
  {
    "path": "shared/models/profileBadge.ts",
    "content": "export enum PremiumTier {\n  ONE = 1,\n}\n\nexport class DBProfileBadge {\n  id: number;\n  name: string;\n  display_name: string;\n  image_url: string;\n  action_url: string | null;\n  premium_tier: number | null;\n\n  constructor(obj: Partial<DBProfileBadge>) {\n    Object.assign(this, obj);\n  }\n}\n\nexport class DBProfileBadgeJoin {\n  profile_badges: DBProfileBadge;\n}\n\nexport class ProfileBadge {\n  id: number;\n  name: string;\n  displayName: string;\n  imageUrl: string;\n  actionUrl?: string;\n  premiumTier?: number;\n\n  constructor(obj: Partial<ProfileBadge>) {\n    Object.assign(this, obj);\n  }\n\n  static fromDBJoin(value: DBProfileBadgeJoin): ProfileBadge {\n    const badge = value.profile_badges;\n    return new ProfileBadge({\n      id: badge.id,\n      name: badge.name,\n      displayName: badge.display_name,\n      imageUrl: badge.image_url,\n      actionUrl: badge.action_url || undefined,\n      premiumTier: badge.premium_tier || undefined,\n    });\n  }\n\n  static fromDB(value: DBProfileBadge) {\n    return new ProfileBadge({\n      id: value.id,\n      name: value.name,\n      displayName: value.display_name,\n      imageUrl: value.image_url,\n      actionUrl: value.action_url || undefined,\n      premiumTier: value.premium_tier || undefined,\n    });\n  }\n}\n"
  },
  {
    "path": "shared/models/profileTag.ts",
    "content": "export type ProfileCategory = 'member' | 'space';\n\nexport class DBProfileTag {\n  id: number;\n  created_at: Date;\n  name: string;\n  profile_type: string;\n\n  constructor(obj: Partial<DBProfileTag>) {\n    Object.assign(this, obj);\n  }\n}\n\nexport class DBProfileTagJoin {\n  profile_tags: DBProfileTag;\n}\n\nexport class ProfileTag {\n  id: number;\n  createdAt: Date;\n  name: string;\n  profileType: string;\n\n  constructor(obj: Partial<ProfileTag>) {\n    Object.assign(this, obj);\n  }\n\n  static fromDBJoin(value: DBProfileTagJoin) {\n    const tag = value.profile_tags;\n    return new ProfileTag({\n      id: tag.id,\n      createdAt: new Date(tag.created_at),\n      name: tag.name,\n      profileType: tag.profile_type,\n    });\n  }\n\n  static fromDB(tag: DBProfileTag) {\n    return new ProfileTag({\n      id: tag.id,\n      createdAt: new Date(tag.created_at),\n      name: tag.name,\n      profileType: tag.profile_type,\n    });\n  }\n}\n"
  },
  {
    "path": "shared/models/profileType.ts",
    "content": "export class DBProfileType {\n  id: number;\n  description: string;\n  display_name: string;\n  image_url: string;\n  small_image_url: string;\n  map_pin_name: string;\n  name: string;\n  order: number;\n  is_space: boolean;\n\n  constructor(obj: Partial<DBProfileType>) {\n    Object.assign(this, obj);\n  }\n}\n\nexport class ProfileType {\n  id: number;\n  description: string;\n  displayName: string;\n  imageUrl: string;\n  smallImageUrl: string;\n  mapPinName: string;\n  name: string;\n  order: number;\n  isSpace: boolean;\n\n  constructor(obj: Partial<ProfileType>) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(value: DBProfileType) {\n    return new ProfileType({\n      id: value.id,\n      description: value.description,\n      displayName: value.display_name,\n      imageUrl: value.image_url,\n      smallImageUrl: value.small_image_url,\n      mapPinName: value.map_pin_name,\n      name: value.name,\n      order: value.order,\n      isSpace: value.is_space,\n    });\n  }\n}\n"
  },
  {
    "path": "shared/models/question.ts",
    "content": "import type { DBAuthor } from './author';\nimport { Author } from './author';\nimport type { DBCategory } from './category';\nimport { Category } from './category';\nimport type { IContentDoc, IDBContentDoc } from './content';\nimport type { DBMedia, Image, MediaWithPublicUrl } from './media';\nimport type { SelectValue } from './selectValue';\nimport type { Tag } from './tag';\n\nexport class DBQuestion implements IDBContentDoc {\n  readonly id: number;\n  is_draft: boolean;\n  readonly created_at: Date;\n  readonly modified_at: Date | null;\n  readonly published_at: Date | null;\n  readonly author?: DBAuthor;\n  readonly comment_count?: number;\n  readonly category: DBCategory | null;\n  readonly category_id?: number;\n  readonly created_by: number | null;\n  readonly deleted: boolean | null;\n  readonly subscriber_count?: number;\n  readonly title: string;\n  readonly total_views?: number;\n  readonly previous_slugs: string[];\n  readonly slug: string;\n  readonly tags: number[];\n  readonly useful_count?: number;\n\n  readonly description: string;\n  readonly images: DBMedia[] | null;\n\n  constructor(question: DBQuestion) {\n    Object.assign(this, question);\n  }\n\n  static toFormData(obj: DBQuestion, images: Image[]) {\n    return {\n      category: obj.category\n        ? { value: obj.category.id.toString(), label: obj.category.name }\n        : null,\n      description: obj.description,\n      images: obj.images\n        ? obj.images\n            .map((dbImage) => {\n              const publicImage = images.find((img) => img.id === dbImage.id);\n              return publicImage ? { ...dbImage, ...publicImage } : null;\n            })\n            .filter((img) => !!img)\n        : null,\n      isDraft: obj.is_draft || false,\n      tags: obj.tags,\n      title: obj.title,\n    } satisfies QuestionFormData;\n  }\n}\n\nexport class Question implements IContentDoc {\n  id: number;\n  author: Author | null;\n  category: Category | null;\n  commentCount: number;\n  createdAt: Date;\n  deleted: boolean;\n  isDraft: boolean;\n  modifiedAt: Date | null;\n  previousSlugs: string[];\n  publishedAt: Date | null;\n  slug: string;\n  subscriberCount: number;\n  tags: Tag[];\n  tagIds?: number[];\n  title: string;\n  totalViews: number;\n  usefulCount: number;\n\n  description: string;\n  images: Image[] | null;\n\n  constructor(question: Question) {\n    Object.assign(this, question);\n  }\n\n  static fromDB(obj: DBQuestion, tags: Tag[], images?: Image[]) {\n    return new Question({\n      id: obj.id,\n      author: obj.author ? Author.fromDB(obj.author) : null,\n      category: obj.category ? Category.fromDB(obj.category) : null,\n      createdAt: new Date(obj.created_at),\n      commentCount: obj.comment_count || 0,\n      deleted: obj.deleted || false,\n      description: obj.description,\n      images: images || [],\n      isDraft: obj.is_draft || false,\n      modifiedAt: obj.modified_at ? new Date(obj.modified_at) : null,\n      previousSlugs: obj.previous_slugs,\n      publishedAt: obj.published_at ? new Date(obj.published_at) : null,\n      slug: obj.slug,\n      subscriberCount: obj.subscriber_count || 0,\n      tagIds: obj.tags,\n      tags: tags,\n      title: obj.title,\n      totalViews: obj.total_views || 0,\n      usefulCount: obj.useful_count || 0,\n    });\n  }\n}\n\nexport type QuestionFormData = {\n  category: SelectValue | null;\n  description: string;\n  images: MediaWithPublicUrl[] | null;\n  isDraft: boolean | null;\n  tags: number[] | null;\n  title: string;\n};\n\nexport type QuestionDTO = {\n  title: string;\n  description: string;\n  category: number | null;\n  images: DBMedia[] | null;\n  isDraft: boolean | null;\n  tags: number[] | null;\n};\n"
  },
  {
    "path": "shared/models/research.ts",
    "content": "import type { DBAuthor } from './author';\nimport { Author } from './author';\nimport type { DBCategory } from './category';\nimport { Category } from './category';\nimport type { IContentDoc, IDBContentDoc } from './content';\nimport type { IDBDocSB, IDBDownloadable, IDoc, IDownloadable } from './document';\nimport type { IFilesForm } from './filesForm';\nimport type { DBMedia, IMediaFile, Image, MediaWithPublicUrl } from './media';\nimport type { SelectValue } from './selectValue';\nimport type { Tag } from './tag';\n\nexport type ResearchStatus = 'in-progress' | 'complete';\nexport const ResearchStatusRecord: Record<ResearchStatus, string> = {\n  'in-progress': 'In Progress',\n  complete: 'Completed',\n};\n\nexport class DBResearchItem implements IDBContentDoc {\n  readonly id: number;\n  readonly created_at: Date;\n  readonly deleted: boolean | null;\n  readonly published_at: Date | null;\n  readonly author?: DBAuthor;\n  readonly update_count?: number;\n  readonly useful_count?: number;\n  readonly useful_votes_last_week?: number;\n  readonly subscriber_count?: number;\n  readonly comment_count?: number;\n  readonly total_views?: number;\n  readonly category: DBCategory | null;\n  readonly updates: DBResearchUpdate[];\n  created_by: number | null;\n  modified_at: Date | null;\n  title: string;\n  slug: string;\n  previous_slugs: string[] | null;\n  description: string;\n  image: DBMedia | null;\n  category_id?: number;\n  tags: number[];\n  status: ResearchStatus;\n  is_draft: boolean;\n  collaborators: string[] | null;\n\n  constructor(obj: Omit<DBResearchItem, 'id'>) {\n    Object.assign(this, obj);\n  }\n\n  static toFormData(obj: DBResearchItem, publicImage: Image | null) {\n    return {\n      title: obj.title,\n      description: obj.description,\n      coverImage: obj.image && publicImage ? { ...obj.image, ...publicImage } : null,\n      category: obj.category\n        ? { value: obj.category.id.toString(), label: obj.category.name }\n        : null,\n      tags: obj.tags,\n      collaborators: obj.collaborators || [],\n    } satisfies ResearchFormData;\n  }\n}\n\nexport class ResearchItem implements IContentDoc {\n  id: number;\n  createdAt: Date;\n  author: Author | null;\n  modifiedAt: Date | null;\n  publishedAt: Date | null;\n  title: string;\n  slug: string;\n  previousSlugs: string[];\n  description: string;\n  image: Image | null;\n  deleted: boolean;\n  usefulCount: number;\n  usefulVotesLastWeek?: number;\n  subscriberCount: number;\n  commentCount: number;\n  updateCount: number;\n  category: Category | null;\n  totalViews: number;\n  tags: Tag[];\n  tagIds?: number[];\n  status: ResearchStatus;\n  collaborators: Author[];\n  collaboratorsUsernames: string[] | null;\n  updates: ResearchUpdate[];\n  isDraft: boolean;\n\n  constructor(obj: ResearchItem) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(\n    obj: DBResearchItem,\n    tags: Tag[],\n    images: Image[] = [],\n    collaborators: Author[] = [],\n    currentUser?: { id: number; username: string | null },\n  ) {\n    const filteredUpdates = obj.updates?.filter((update) => {\n      if (update.deleted) {\n        return false;\n      }\n\n      if (!update.is_draft) {\n        return true;\n      }\n\n      if (!currentUser) {\n        return false;\n      }\n\n      const isAuthor = currentUser.id === obj.author?.id;\n      const isCollaborator = !!(\n        currentUser.username && (obj.collaborators || []).includes(currentUser.username)\n      );\n\n      return isAuthor || isCollaborator;\n    });\n\n    const processedUpdates =\n      filteredUpdates\n        ?.map((update) => ResearchUpdate.fromDB(update, images))\n        .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) || [];\n\n    return new ResearchItem({\n      id: obj.id,\n      createdAt: new Date(obj.created_at),\n      author: obj.author ? Author.fromDB(obj.author) : null,\n      modifiedAt: obj.modified_at ? new Date(obj.modified_at) : null,\n      publishedAt: obj.published_at ? new Date(obj.published_at) : null,\n      title: obj.title,\n      slug: obj.slug,\n      previousSlugs: obj.previous_slugs || [],\n      description: obj.description,\n      image: images?.find((x) => x.id === obj.image?.id) || null,\n      deleted: obj.deleted || false,\n      category: obj.category ? Category.fromDB(obj.category) : null,\n      totalViews: obj.total_views || 0,\n      tagIds: obj.tags?.map((x) => Number(x)),\n      tags: tags,\n      status: obj.status,\n      updateCount: obj.update_count || obj.updates?.length || 0,\n      subscriberCount: obj.subscriber_count || 0,\n      commentCount: calculateUpdateCommentCount(obj),\n      usefulCount: obj.useful_count || 0,\n      usefulVotesLastWeek: obj.useful_votes_last_week || 0,\n      isDraft: obj.is_draft || false,\n      collaboratorsUsernames: obj.collaborators,\n      collaborators: collaborators || [],\n      // never show deleted updates; only show draft updates if current user is the author or collaborator\n      updates: processedUpdates,\n    });\n  }\n}\n\nexport class DBResearchUpdate implements IDBDocSB, IDBDownloadable {\n  readonly id: number;\n  readonly research_id: number;\n  readonly created_at: Date;\n  readonly deleted: boolean | null;\n  readonly is_draft: boolean | null;\n  readonly published_at: Date | null;\n  readonly comment_count?: number;\n  readonly file_download_count?: number;\n  readonly update_author?: DBAuthor;\n  created_by: number | null;\n  modified_at: Date | null;\n  title: string;\n  description: string;\n  images: DBMedia[] | null;\n  file_link: string | null;\n  files: IMediaFile[] | null;\n  video_url: string | null;\n\n  constructor(obj: Omit<DBResearchUpdate, 'id'>) {\n    Object.assign(this, obj);\n  }\n\n  static toFormData(obj: DBResearchUpdate, images: Image[]) {\n    return {\n      title: obj.title,\n      description: obj.description,\n      images: obj.images\n        ? obj.images\n            .map((dbImage) => {\n              const publicImage = images.find((img) => img.id === dbImage.id);\n              return publicImage ? { ...dbImage, ...publicImage } : null;\n            })\n            .filter((img) => !!img)\n        : null,\n      files: obj.files,\n      fileLink: obj.file_link,\n      videoUrl: obj.video_url,\n    } satisfies ResearchUpdateFormData;\n  }\n}\n\nexport class ResearchUpdate implements IDoc, IDownloadable {\n  id: number;\n  author: Author | null;\n  commentCount: number;\n  createdAt: Date;\n  deleted: boolean;\n  description: string;\n  fileDownloadCount: number;\n  files: IMediaFile[] | null;\n  images: Image[] | null;\n  isDraft: boolean;\n  hasFileLink: boolean;\n  modifiedAt: Date | null;\n  publishedAt: Date | null;\n  researchId: number;\n  title: string;\n  videoUrl: string | null;\n  research?: DBResearchItem;\n\n  constructor(obj: ResearchUpdate) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(obj: DBResearchUpdate, images?: Image[]) {\n    return new ResearchUpdate({\n      id: obj.id,\n      createdAt: new Date(obj.created_at),\n      modifiedAt: obj.modified_at ? new Date(obj.modified_at) : null,\n      publishedAt: obj.published_at ? new Date(obj.published_at) : null,\n      author: obj.update_author ? Author.fromDB(obj.update_author) : null,\n      title: obj.title,\n      description: obj.description,\n      images: images?.filter((x) => obj.images?.map((x) => x.id)?.includes(x.id)) || [],\n      files: obj.files,\n      // no fileLink as it must be shown only for authenticated users\n      hasFileLink: !!obj.file_link,\n      videoUrl: obj.video_url,\n      deleted: obj.deleted || false,\n      commentCount: obj.comment_count || 0,\n      fileDownloadCount: obj.file_download_count || 0,\n      isDraft: !!obj.is_draft,\n      researchId: obj.research_id,\n    });\n  }\n}\n\nfunction calculateUpdateCommentCount(research: DBResearchItem): number {\n  if (research.comment_count) {\n    return research.comment_count;\n  }\n\n  return research.updates\n    ?.filter((x) => x.deleted !== true && x.is_draft !== true)\n    .reduce((acc, x) => acc + (x.comment_count || 0), 0);\n}\n\nexport type ResearchFormData = {\n  title: string;\n  description: string;\n  category: SelectValue | null;\n  tags: number[] | null;\n  collaborators: string[] | null;\n  coverImage: MediaWithPublicUrl | null;\n};\n\nexport interface ResearchUpdateFormData extends IFilesForm {\n  title: string;\n  description: string;\n  images: MediaWithPublicUrl[] | null;\n  videoUrl: string | null;\n}\n\nexport type ResearchDTO = {\n  title: string;\n  description: string;\n  category: number | null;\n  tags: number[] | null;\n  collaborators: string[] | null;\n  coverImage: DBMedia | null;\n  isDraft: boolean;\n};\n\nexport type ResearchUpdateDTO = {\n  title: string;\n  description: string;\n  images: DBMedia[] | null;\n  videoUrl: string | null;\n  files: IMediaFile[] | null;\n  fileLink: string | null;\n  isDraft: boolean;\n};\n"
  },
  {
    "path": "shared/models/selectValue.ts",
    "content": "export type SelectValue = { label: string; value: string };\n"
  },
  {
    "path": "shared/models/subscriber.ts",
    "content": "import type { ContentType } from './common';\n\nexport class DBSubscriber {\n  id: number;\n  created_at: Date;\n  user_id: number;\n  content_id: number;\n  content_type: ContentType;\n}\n"
  },
  {
    "path": "shared/models/tag.ts",
    "content": "import type { IDBDocSB, IDoc } from './document';\n\nexport class DBTag implements IDBDocSB {\n  id: number;\n  created_at: Date;\n  modified_at: Date | null;\n\n  name: string;\n\n  constructor(obj: any) {\n    Object.assign(this, obj);\n  }\n\n  static toDB(tag: Tag) {\n    const { createdAt, id, modifiedAt, name } = tag;\n    return new DBTag({\n      id,\n      created_at: new Date(createdAt),\n      modified_at: modifiedAt ? new Date(modifiedAt) : null,\n      name,\n    });\n  }\n}\n\nexport class Tag implements IDoc {\n  id: number;\n  createdAt: Date;\n  modifiedAt: Date | null;\n\n  name: string;\n\n  constructor(obj: any) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(tag: DBTag) {\n    const { created_at, id, modified_at, name } = tag;\n    return new Tag({\n      id,\n      createdAt: new Date(created_at),\n      modified_at: modified_at ? new Date(modified_at) : null,\n      name,\n    });\n  }\n}\n"
  },
  {
    "path": "shared/models/tags.ts",
    "content": "export interface ISelectedTags {\n  [key: string]: boolean;\n}\n"
  },
  {
    "path": "shared/models/tenantSettings.ts",
    "content": "import { UserRole } from './user';\n\nexport class TenantSettings {\n  siteName: string;\n  siteDescription: string;\n  siteUrl: string;\n  messageSignOff: string;\n  emailFrom: string;\n  siteImage: string;\n  noMessaging: boolean;\n  libraryHeading: string;\n  academyResource: string;\n  profileGuidelines: string;\n  questionsGuidelines: string;\n  supportedModules: string;\n  patreonId: string;\n  colorPrimary: string;\n  colorPrimaryHover: string;\n  colorAccent: string;\n  colorAccentHover: string;\n  showImpact: boolean;\n  createResearchRoles: UserRole[];\n  gaTrackingId: string;\n  pwaIcons?: PWAIcons;\n\n  constructor(obj: Partial<TenantSettings>) {\n    Object.assign(this, obj);\n  }\n}\n\nexport interface PWAIcons {\n  16: string;\n  32: string;\n  192: string;\n  256: string;\n  512: string;\n}\n"
  },
  {
    "path": "shared/models/upgradeBadge.ts",
    "content": "import type { DBProfileBadge } from './profileBadge';\nimport { ProfileBadge } from './profileBadge';\n\nexport class DBUpgradeBadge {\n  id: number;\n  action_label: string;\n  badge_id: number;\n  is_space: boolean;\n  action_url: string;\n  tenant_id: string;\n  badge?: DBProfileBadge;\n\n  constructor(obj: Partial<DBUpgradeBadge>) {\n    Object.assign(this, obj);\n  }\n}\n\nexport class UpgradeBadge {\n  id: number;\n  actionLabel: string;\n  badgeId: number;\n  isSpace: boolean;\n  actionUrl: string;\n  readonly badge?: ProfileBadge;\n\n  constructor(obj: Partial<UpgradeBadge>) {\n    Object.assign(this, obj);\n  }\n\n  static fromDB(value: DBUpgradeBadge) {\n    return new UpgradeBadge({\n      id: value.id,\n      actionLabel: value.action_label,\n      badgeId: value.badge_id,\n      isSpace: value.is_space,\n      actionUrl: value.action_url,\n      badge: value.badge ? ProfileBadge.fromDB(value.badge) : undefined,\n    });\n  }\n}\n"
  },
  {
    "path": "shared/models/user.ts",
    "content": "import type { INotification } from './notifications';\n\nexport enum UserRole {\n  SUBSCRIBER = 'subscriber',\n  ADMIN = 'admin',\n  BETA_TESTER = 'beta-tester',\n  RESEARCH_CREATOR = 'research_creator',\n}\n\n// Below are primarily used for PP\n\nexport type WorkspaceType = 'shredder' | 'sheetpress' | 'extrusion' | 'injection' | 'mix';\n\nexport interface IWorkspaceType {\n  label: WorkspaceType;\n  imageSrc?: string;\n  textLabel: string;\n  subText?: string;\n}\n\nexport type UserMention = {\n  username: string;\n  location: string;\n};\n\nexport const userVisitorPreferencePolicies = ['open', 'appointment', 'closed'] as const;\n\nexport type UserVisitorPreferencePolicy = (typeof userVisitorPreferencePolicies)[number];\n\nexport type UserVisitorPreference = {\n  policy: UserVisitorPreferencePolicy;\n  details?: string | null;\n};\n\nexport interface IUserBadges {\n  verified?: boolean;\n  supporter?: boolean;\n}\n\nexport interface IImpactDataField {\n  id: string;\n  value: number;\n  isVisible: boolean;\n}\n\nexport interface IUserImpact {\n  [year: number]: IImpactDataField[];\n}\n\nexport type IImpactYear = 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025;\n\nexport type INotificationUpdate = {\n  _id: string;\n  notifications?: INotification[];\n};\n"
  },
  {
    "path": "shared/models/userCreatedDocs.ts",
    "content": "import type { Project } from './library';\nimport type { Question } from './question';\nimport type { ResearchItem } from './research';\n\nexport interface UserCreatedDocs {\n  projects: Partial<Project>[];\n  research: Partial<ResearchItem>[];\n  questions: Partial<Question>[];\n}\n"
  },
  {
    "path": "shared/models/userEmailData.ts",
    "content": "export type UserEmailData = {\n  id: string;\n  email: string;\n  code: string;\n  new_email?: string;\n};\n"
  },
  {
    "path": "shared/models/voteUseful.ts",
    "content": "export interface IVotedUseful {\n  votedUsefulBy?: string[];\n}\n\nexport interface ISharedFeatures extends IVotedUseful {\n  total_views?: number;\n  previousSlugs?: string[];\n}\n\nexport type IVotedUsefulUpdate = {\n  _id: string;\n} & IVotedUseful;\n"
  },
  {
    "path": "shared/models/webmanifest.ts",
    "content": "export interface WebAppManifest {\n  background_color?: string;\n  categories?: string[];\n  description?: string;\n  dir?: 'auto' | 'ltr' | 'rtl';\n  display?: 'fullscreen' | 'standalone' | 'minimal-ui' | 'browser';\n  display_override?: (\n    | 'fullscreen'\n    | 'standalone'\n    | 'minimal-ui'\n    | 'browser'\n    | 'window-controls-overlay'\n  )[];\n  iarc_rating_id?: string;\n  icons?: ManifestIcon[];\n  id?: string;\n  lang?: string;\n  name?: string;\n  orientation?:\n    | 'any'\n    | 'natural'\n    | 'landscape'\n    | 'landscape-primary'\n    | 'landscape-secondary'\n    | 'portrait'\n    | 'portrait-primary'\n    | 'portrait-secondary';\n  scope?: string;\n  short_name?: string;\n  start_url?: string;\n  theme_color?: string;\n}\n\ninterface ManifestIcon {\n  src: string;\n  sizes?: string;\n  type?: string;\n  purpose?: 'any' | 'maskable' | 'monochrome' | 'badge';\n}\n"
  },
  {
    "path": "shared/package.json",
    "content": "{\n  \"name\": \"oa-shared\",\n  \"version\": \"1.0.0\",\n  \"exports\": {\n    \".\": {\n      \"bun\": \"./index.ts\",\n      \"types\": \"./lib/index.d.ts\",\n      \"import\": \"./lib/index.js\",\n      \"require\": \"./lib/index.js\",\n      \"default\": \"./lib/index.js\"\n    },\n    \"./mocks/data\": {\n      \"bun\": \"./mocks/data/index.ts\",\n      \"types\": \"./lib/mocks/data/index.d.ts\",\n      \"import\": \"./lib/mocks/data/index.js\",\n      \"require\": \"./lib/mocks/data/index.js\",\n      \"default\": \"./lib/mocks/data/index.js\"\n    },\n    \"./*\": {\n      \"bun\": \"./*.ts\",\n      \"types\": \"./lib/*.d.ts\",\n      \"import\": \"./lib/*.js\",\n      \"require\": \"./lib/*.js\",\n      \"default\": \"./lib/*.js\"\n    }\n  },\n  \"main\": \"./lib/index.js\",\n  \"types\": \"./lib/index.d.ts\",\n  \"typesVersions\": {\n    \"*\": {\n      \"*\": [\"./index.ts\", \"./*\"]\n    }\n  },\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"dev\": \"tsc --watch\"\n  },\n  \"devDependencies\": {\n    \"typescript\": \"^5.7.2\"\n  }\n}\n"
  },
  {
    "path": "shared/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"outDir\": \"./lib\",\n    \"lib\": [\"ESNext\"],\n    \"typeRoots\": [\"../node_modules/@types\"],\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"resolveJsonModule\": true,\n    \"strictPropertyInitialization\": false,\n    \"esModuleInterop\": true,\n    \"strict\": true,\n    \"declaration\": true\n  },\n  \"include\": [\"**/*.ts\"],\n  \"exclude\": [\"../node_modules\", \"./lib\"]\n}\n"
  },
  {
    "path": "shared/utils/file.utils.ts",
    "content": "export const bytesToSize = (bytes: number) => {\n  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n  if (bytes === 0) {\n    return '0 Bytes';\n  }\n  const i = Number(Math.floor(Math.log(bytes) / Math.log(1024)));\n  return (bytes / Math.pow(1024, i)).toPrecision(3) + ' ' + sizes[i];\n};\n"
  },
  {
    "path": "shared/utils/index.ts",
    "content": "export * from './file.utils';\n"
  },
  {
    "path": "shared/utils/markdown.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { extractYouTubeId, processStandaloneYouTubeUrls, processYouTubeLinks } from './markdown';\n\ndescribe('DisplayMarkdown utils', () => {\n  describe('extractYouTubeId', () => {\n    it('should extract video ID from youtube.com/watch URL', () => {\n      const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';\n      expect(extractYouTubeId(url)).toBe('dQw4w9WgXcQ');\n    });\n\n    it('should extract video ID from youtu.be URL', () => {\n      const url = 'https://youtu.be/dQw4w9WgXcQ';\n      expect(extractYouTubeId(url)).toBe('dQw4w9WgXcQ');\n    });\n\n    it('should return null for invalid URLs', () => {\n      const url = 'https://example.com/video';\n      expect(extractYouTubeId(url)).toBeNull();\n    });\n  });\n\n  describe('processYouTubeLinks', () => {\n    it('should convert YouTube links to iframe embeds', () => {\n      const html = '<a href=\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\">Video</a>';\n      const result = processYouTubeLinks(html);\n\n      expect(result).toContain('<iframe');\n      expect(result).toContain('https://www.youtube.com/embed/dQw4w9WgXcQ');\n      expect(result).toContain('allowfullscreen');\n    });\n\n    it('should preserve non-YouTube links', () => {\n      const html = '<a href=\"https://example.com\">Example</a>';\n      const result = processYouTubeLinks(html);\n\n      expect(result).toBe(html);\n    });\n  });\n\n  describe('processStandaloneYouTubeUrls', () => {\n    it('should convert standalone YouTube URLs to iframe embeds', () => {\n      const html = 'Check this out: https://www.youtube.com/watch?v=dQw4w9WgXcQ';\n      const result = processStandaloneYouTubeUrls(html);\n\n      expect(result).toContain('<iframe');\n      expect(result).toContain('https://www.youtube.com/embed/dQw4w9WgXcQ');\n    });\n\n    it('should not process URLs already in links', () => {\n      const html = '<a href=\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\">Video</a>';\n      const result = processStandaloneYouTubeUrls(html);\n\n      expect(result).toBe(html);\n    });\n  });\n});\n"
  },
  {
    "path": "shared/utils/markdown.ts",
    "content": "export const extractYouTubeId = (url: string): string | null => {\n  const patterns = [\n    /(?:youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)([^&\\n?#]+)/,\n    /youtube\\.com\\/v\\/([^&\\n?#]+)/,\n    /youtube\\.com\\/watch\\?.*v=([^&\\n?#]+)/,\n  ];\n\n  for (const pattern of patterns) {\n    const match = url.match(pattern);\n    if (match) {\n      return match[1];\n    }\n  }\n\n  return null;\n};\n\nexport const processYouTubeLinks = (html: string): string => {\n  // Match YouTube URLs in links (including links with nested HTML elements)\n  const youtubePattern =\n    /<a[^>]*href=[\"']([^\"']*(?:youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)[^\"']*)[\"'][^>]*>([\\s\\S]*?)<\\/a>/g;\n\n  return html.replace(youtubePattern, (match, url, _linkText) => {\n    const videoId = extractYouTubeId(url);\n\n    if (videoId) {\n      return `\n        <div style=\"position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; max-width: 100%; margin: 16px 0;\">\n          <iframe \n            src=\"https://www.youtube.com/embed/${videoId}\" \n            style=\"position: absolute; top: 0; left: 0; width: 100%; height: 100%; margin: 0 auto;\"\n            frameborder=\"0\" \n            allowfullscreen\n            title=\"YouTube video player\">\n          </iframe>\n        </div>\n      `;\n    }\n\n    return match; // Return original if no video ID found\n  });\n};\n\nexport const processStandaloneYouTubeUrls = (html: string): string => {\n  // Match YouTube URLs - Safari 15 compatible (no negative lookbehind)\n  const youtubePattern =\n    /https?:\\/\\/(?:www\\.)?(?:youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)([a-zA-Z0-9_-]{11})/g;\n\n  // Find all potential YouTube URLs\n  const matches = Array.from(html.matchAll(youtubePattern));\n\n  // Process matches in reverse order to avoid index shifting\n  for (let i = matches.length - 1; i >= 0; i--) {\n    const match = matches[i];\n    const fullMatch = match[0];\n    const videoId = match[1];\n    const startIndex = match.index!;\n\n    // Check if URL is already inside a link or iframe by examining context\n    const beforeMatch = html.substring(Math.max(0, startIndex - 200), startIndex);\n    const afterMatch = html.substring(\n      startIndex + fullMatch.length,\n      Math.min(html.length, startIndex + fullMatch.length + 200),\n    );\n\n    // Skip if already in a link\n    const isInLink =\n      /<a[^>]*href=[\"'][^\"']*$/.test(beforeMatch) && /[\"'][^>]*>[\\s\\S]*?<\\/a>/.test(afterMatch);\n\n    // Skip if already in an iframe src attribute\n    const isInIframe =\n      /<iframe[^>]*src=[\"'][^\"']*$/.test(beforeMatch) && /[\"'][^>]*>/.test(afterMatch);\n\n    // Skip if this is an embed URL that's already properly formatted\n    const isEmbedUrl = fullMatch.includes('/embed/');\n\n    // Skip if we're inside an existing YouTube embed div structure\n    const isInYouTubeEmbed =\n      beforeMatch.includes('<div style=\"position: relative; padding-bottom:') &&\n      afterMatch.includes('</iframe>') &&\n      afterMatch.includes('</div>');\n\n    if (!isInLink && !isInIframe && !isEmbedUrl && !isInYouTubeEmbed) {\n      const replacement = `\n        <div style=\"position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; max-width: 100%; margin: 16px 0;\">\n          <iframe \n            src=\"https://www.youtube.com/embed/${videoId}\" \n            style=\"position: absolute; top: 0; left: 0; width: 100%; height: 100%; margin: 0 auto;\"\n            frameborder=\"0\" \n            allowfullscreen\n            title=\"YouTube video player\">\n          </iframe>\n        </div>\n      `;\n\n      html =\n        html.substring(0, startIndex) + replacement + html.substring(startIndex + fullMatch.length);\n    }\n  }\n\n  return html;\n};\n"
  },
  {
    "path": "src/.server/models/messageSettings.ts",
    "content": "export type MessageSettings = {\n  siteUrl: string;\n  siteName: string;\n  siteImage: string;\n  messageSignOff: string;\n};\n"
  },
  {
    "path": "src/.server/resend.ts",
    "content": "import type { ReactNode } from 'react';\nimport { Resend } from 'resend';\n\ntype SendEmailArgs = {\n  to: string;\n  from: string;\n  subject: string;\n  emailTemplate: ReactNode;\n};\n\nexport async function sendEmail({ from, to, subject, emailTemplate }: SendEmailArgs) {\n  const resend = new Resend(process.env.RESEND_API_KEY);\n\n  const response = await resend.emails.send(\n    {\n      from,\n      to,\n      subject,\n      react: emailTemplate,\n    },\n    {\n      idempotencyKey: crypto.randomUUID(),\n    },\n  );\n\n  return { error: response.error?.message };\n}\n\ntype SendEmailsArgs = {\n  from: string;\n  subject: string;\n  emails: { template: ReactNode; to: string }[];\n};\n\nexport async function sendBatchEmails({ from, subject, emails }: SendEmailsArgs) {\n  const resend = new Resend(process.env.RESEND_API_KEY);\n\n  // Batches of 100 emails\n  // https://api.resend.com/emails/batch\n  for (let i = 0; i < emails.length; i += 100) {\n    const batch = emails.slice(i, i + 100);\n    const emailsToSend = batch.map((email) => ({\n      from,\n      to: email.to,\n      subject,\n      react: email.template,\n    }));\n\n    if (i > 0) {\n      // To avoid hitting rate limits\n      await new Promise((resolve) => setTimeout(resolve, 1000));\n    }\n\n    try {\n      const batchResult = await resend.batch.send(emailsToSend, {\n        idempotencyKey: crypto.randomUUID(),\n      });\n\n      if (batchResult.error) {\n        console.error('Error sending batch emails:', batchResult.error);\n      }\n    } catch (error) {\n      console.error('Error sending batch emails:', error);\n    }\n  }\n\n  return;\n}\n"
  },
  {
    "path": "src/.server/templates/Layout.tsx",
    "content": "// Similar to src/.server/templates/Layout.tsx\n\nimport { Body, Container, Head, Html, Img, Link, Preview, Section } from '@react-email/components';\nimport type { TenantSettings } from 'oa-shared';\nimport React from 'react';\nimport { Footer } from './components/footer';\n\nconst link = {\n  color: '#27272c',\n  fontWeight: 'bold',\n  textDecoration: 'underline',\n};\n\ntype EmailType = 'service' | 'moderation' | 'notification';\n\ntype LayoutArgs = {\n  children: React.ReactNode;\n  emailType: EmailType;\n  preview: string;\n  settings: TenantSettings;\n  userCode?: string;\n};\n\nexport const urlAppend = (path: string, emailType: EmailType) => {\n  const url = new URL(`${path}`);\n  url.searchParams.append('utm_source', emailType);\n  url.searchParams.append('utm_medium', 'email');\n  return url.toString();\n};\n\nexport const Layout = (props: LayoutArgs) => {\n  const { children, emailType, preview, settings, userCode } = props;\n\n  const basePreferencesPath = userCode\n    ? `${settings.siteUrl}/email-preferences?code=${userCode}`\n    : `${settings.siteUrl}/settings/notifications`;\n  const preferencesUpdatePath = urlAppend(basePreferencesPath, emailType);\n\n  const isNotificationEmail = emailType === 'notification';\n\n  return (\n    <Html lang=\"en\">\n      <Head />\n      <Preview>{preview}</Preview>\n      <Body\n        style={{\n          backgroundColor: '#f4f6f7',\n          fontFamily: '\"Varela Round\", Arial, sans-serif',\n          fontSize: '14px',\n          color: '#000000',\n          maxWidth: '100%',\n        }}\n      >\n        <Container\n          style={{\n            maxWidth: '100%',\n            width: '600px',\n          }}\n        >\n          <Img\n            alt={settings.siteName}\n            height=\"85px\"\n            width=\"85px\"\n            src={settings.siteImage}\n            style={{ margin: '30px auto' }}\n          />\n          <Section\n            style={{\n              background: '#fff',\n              border: '2px solid black',\n              borderRadius: '15px',\n              padding: '20px',\n              margin: '0 auto',\n            }}\n          >\n            {children}\n          </Section>\n          <Footer>\n            {!isNotificationEmail && <>This is a service email.</>}\n            {isNotificationEmail && (\n              <>\n                <Link href={preferencesUpdatePath} style={link}>\n                  Unsubscribe or update your email preferences.\n                </Link>\n              </>\n            )}\n            <br />\n            Something is not right? Send us{' '}\n            <Link href={`${settings.siteUrl}/feedback/#page=email`} style={link}>\n              feedback\n            </Link>\n            .\n          </Footer>\n        </Container>\n      </Body>\n    </Html>\n  );\n};\n"
  },
  {
    "path": "src/.server/templates/ReceiverMessage.tsx",
    "content": "import { Body, Container, Head, Html, Img, Preview, Section } from '@react-email/components';\nimport { MessageSettings } from '../models/messageSettings';\n\ntype ReceiverMessageArgs = {\n  messengerEmailAddress: string;\n  messengerName: string | undefined;\n  messengerUsername: string | null;\n  receiverName: string;\n  settings: MessageSettings;\n  text: string;\n};\n\nexport default function ReceiverMessage({\n  receiverName,\n  settings,\n  text,\n  messengerEmailAddress,\n  messengerName,\n  messengerUsername,\n}: ReceiverMessageArgs) {\n  const isMessengerName = messengerName && messengerName !== 'undefined';\n  const name = isMessengerName ? messengerName : messengerUsername || 'Someone';\n\n  const preview = `${name} wants to chat to you!`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{preview}</Preview>\n      <Body\n        style={{\n          backgroundColor: '#f4f6f7',\n          fontFamily: 'Varela Round\", Arial, sans-serif',\n          fontSize: '14px',\n          color: '#000000',\n        }}\n      >\n        <Container\n          style={{\n            maxWidth: '600px',\n          }}\n        >\n          <Container\n            style={{\n              background: '#fff',\n              border: '2px solid black',\n              borderRadius: '15px',\n              padding: '15px',\n              margin: '0 auto',\n            }}\n          >\n            <Section>\n              <Img width=\"85\" alt={settings.siteName} src={settings.siteImage} />\n              <p>Hey {receiverName},</p>\n              <p>\n                {messengerUsername ? (\n                  <a href={`${settings.siteUrl}/u/${messengerUsername}`}>{name}</a>\n                ) : (\n                  <strong>{name}</strong>\n                )}{' '}\n                has sent you a message through{' '}\n                <a href={settings.siteUrl} target=\"_blank\">\n                  {settings.siteName}\n                </a>\n                .\n              </p>\n              <p>Please reply directly to their email: {messengerEmailAddress}.</p>\n              ---\n              <p>\n                <strong>{text}</strong>\n              </p>\n              ---\n              <p>\n                Cheers,\n                <br />\n                {settings.messageSignOff}\n              </p>\n            </Section>\n          </Container>\n          <Container\n            style={{\n              maxWidth: '600px',\n            }}\n          >\n            <p style={{ textAlign: 'center' }}>\n              You've received this because you're opted in to be contacted by other community\n              members. If you want to opt out,{' '}\n              <a href={settings.siteUrl + '/settings'}>change that here</a>.\n            </p>\n          </Container>\n        </Container>\n      </Body>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "src/.server/templates/components/box-text.tsx",
    "content": "import { Section } from '@react-email/components';\nimport React from 'react';\n\nconst section = {\n  backgroundColor: '#f4f4f4',\n  borderRadius: '10px',\n  lineHeight: 1.66,\n  marginBottom: '15px',\n  padding: '20px',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const BoxText = ({ children }: IProps) => <Section style={section}>{children}</Section>;\n"
  },
  {
    "path": "src/.server/templates/components/button.tsx",
    "content": "import { Button as ButtonComp, Section } from '@react-email/components';\nimport React from 'react';\n\ninterface IProps {\n  children: React.ReactNode;\n  href: string;\n}\n\nexport const Button = ({ children, href }: IProps) => (\n  <Section style={{ textAlign: 'center' }}>\n    <ButtonComp\n      style={{\n        backgroundColor: '#e2edf7',\n        borderRadius: '15px',\n        border: '2px solid #27272c',\n        color: '#27272c',\n        fontSize: '16px',\n        padding: '19px 30px',\n        textDecoration: 'none',\n      }}\n      href={href}\n      target=\"_blank\"\n    >\n      {children}\n    </ButtonComp>\n  </Section>\n);\n"
  },
  {
    "path": "src/.server/templates/components/footer.tsx",
    "content": "import { Section, Text } from '@react-email/components';\nimport React from 'react';\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const Footer = ({ children }: IProps) => (\n  <Section style={{ textAlign: 'center' }}>\n    <Text\n      style={{\n        textAlign: 'center',\n        color: '#27272c',\n        fontSize: '14px',\n      }}\n    >\n      {children}\n    </Text>\n  </Section>\n);\n"
  },
  {
    "path": "src/.server/templates/components/header.tsx",
    "content": "import { Section } from '@react-email/components';\nimport React from 'react';\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const Header = ({ children }: IProps) => (\n  <Section\n    style={{\n      padding: '10px',\n    }}\n  >\n    {children}\n  </Section>\n);\n"
  },
  {
    "path": "src/.server/templates/components/heading.tsx",
    "content": "import { Heading as HeadingComp } from '@react-email/components';\nimport React from 'react';\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const Heading = ({ children }: IProps) => (\n  <HeadingComp\n    style={{\n      color: '#2e2e2e',\n      lineHeight: 1.2,\n      fontWeight: 'normal',\n      fontFamily: '\"Helvetica\", serif',\n      marginBottom: '12px',\n      padding: '0',\n    }}\n    as=\"h1\"\n  >\n    {children}\n  </HeadingComp>\n);\n"
  },
  {
    "path": "src/.server/templates/instant-notification-email.tsx",
    "content": "import { Text } from '@react-email/components';\nimport type { NotificationDisplay, TenantSettings } from 'oa-shared';\nimport { BoxText } from './components/box-text';\nimport { Button } from './components/button';\nimport { Header } from './components/header';\nimport { Heading } from './components/heading';\nimport { Layout, urlAppend } from './Layout';\n\nconst text = {\n  color: '#686868',\n  fontSize: '18px',\n  lineHeight: '30px',\n};\n\ninterface IProps {\n  notification: NotificationDisplay;\n  settings: TenantSettings;\n  userCode: string;\n}\n\nexport const InstantNotificationEmail = (props: IProps) => {\n  const { notification, settings, userCode } = props;\n\n  const buttonLink = urlAppend(`${settings.siteUrl}${notification.link}`, 'notification');\n\n  return (\n    <Layout\n      emailType=\"notification\"\n      preview={notification.email.preview}\n      settings={settings}\n      userCode={userCode}\n    >\n      <Header>\n        <Heading>\n          {notification.triggeredBy} {notification.title}\n        </Heading>\n      </Header>\n\n      <BoxText>\n        {notification.email.body ? (\n          <Text style={text}>{notification.email.body}</Text>\n        ) : (\n          <>\n            <Heading>They wrote:</Heading>\n            <Text style={text}>{notification.body}</Text>\n          </>\n        )}\n      </BoxText>\n      <Button href={buttonLink}>{notification.email.buttonLabel} →</Button>\n    </Layout>\n  );\n};\n"
  },
  {
    "path": "src/common/Alerts/AlertBanner.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { render, screen, waitFor } from '@testing-library/react';\nimport { bannerService } from 'src/pages/common/banner.service';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { AlertBanner } from './AlertBanner';\n\ndescribe('AlertBanner', () => {\n  const mockBanner = {\n    id: 1,\n    text: 'Test Banner',\n    url: 'https://example.com',\n    createdAt: new Date(),\n    modifiedAt: new Date(),\n  };\n\n  beforeEach(() => {\n    vi.spyOn(bannerService, 'getBanner').mockResolvedValue(mockBanner);\n  });\n\n  it('renders banner text', async () => {\n    render(<AlertBanner />);\n    await waitFor(() => {\n      expect(screen.getByText('Test Banner')).toBeInTheDocument();\n    });\n  });\n\n  it('renders link when url is present', async () => {\n    render(<AlertBanner />);\n    await waitFor(() => {\n      const link = screen.getByRole('link');\n      expect(link).toHaveAttribute('href', 'https://example.com');\n    });\n  });\n\n  it('renders text when url is not present', async () => {\n    vi.spyOn(bannerService, 'getBanner').mockResolvedValue({\n      id: 1,\n      text: 'text no url',\n      url: '',\n      createdAt: new Date(),\n      modifiedAt: new Date(),\n    });\n    render(<AlertBanner />);\n    await waitFor(() => {\n      expect(screen.getByText('text no url')).toBeInTheDocument();\n    });\n  });\n\n  it('renders nothing if no banner text', async () => {\n    vi.spyOn(bannerService, 'getBanner').mockResolvedValue({\n      id: 1,\n      text: '',\n      url: '',\n      createdAt: new Date(),\n      modifiedAt: new Date(),\n    });\n    const { container } = render(<AlertBanner />);\n    await waitFor(() => {\n      expect(container).toBeEmptyDOMElement();\n    });\n  });\n});\n"
  },
  {
    "path": "src/common/Alerts/AlertBanner.tsx",
    "content": "import { Banner, Icon } from 'oa-components';\nimport type { Banner as BannerModel } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { bannerService } from 'src/pages/common/banner.service';\nimport { IconButton, Text } from 'theme-ui';\n\nexport const AlertBanner = () => {\n  const [banner, setBanner] = useState<(BannerModel & { show: boolean }) | null>(null);\n\n  useEffect(() => {\n    const fetchBanner = async () => {\n      const banner = await bannerService.getBanner();\n\n      if (banner) {\n        const didUserCloseBefore = localStorage.getItem(`bannerClosed_${banner.id}`) === 'true';\n        if (!didUserCloseBefore) {\n          setBanner({ ...banner, show: true });\n        }\n      }\n    };\n\n    fetchBanner();\n  }, []);\n\n  const onClose = (\n    e: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>,\n  ) => {\n    e.preventDefault();\n    e.stopPropagation();\n    if (banner) {\n      localStorage.setItem(`bannerClosed_${banner.id}`, 'true');\n      setBanner({ ...banner, show: false });\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (e.key === 'Enter' || e.key === ' ') {\n      onClose(e);\n    }\n  };\n\n  if (!banner?.text || !banner.show) {\n    return null;\n  }\n\n  const bannerContent = (\n    <Banner\n      variant=\"accent\"\n      sx={{\n        cursor: 'pointer',\n        display: 'flex',\n        alignItems: 'center',\n        position: 'relative',\n        justifyContent: 'space-between',\n        gap: 1,\n      }}\n    >\n      <div style={{ width: '32px' }} />\n      <Text>{banner.text}</Text>\n      <IconButton\n        onClick={onClose}\n        onKeyDown={onKeyDown}\n        sx={{\n          cursor: 'pointer',\n          flexShrink: 0,\n        }}\n      >\n        <Icon glyph=\"close\" />\n      </IconButton>\n    </Banner>\n  );\n\n  if (banner.url) {\n    return (\n      <a href={banner.url} target=\"_blank\" rel=\"noreferrer\">\n        {bannerContent}\n      </a>\n    );\n  }\n\n  return bannerContent;\n};\n"
  },
  {
    "path": "src/common/Alerts/AlertIncompleteProfile.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Banner, InternalLink } from 'oa-components';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { Flex } from 'theme-ui';\n\nexport const AlertIncompleteProfile = observer(() => {\n  const { isComplete } = useProfileStore();\n\n  if (isComplete !== false) {\n    return null;\n  }\n\n  return (\n    <InternalLink to=\"/settings\">\n      <Flex data-cy=\"incompleteProfileBanner\">\n        <Banner sx={{ backgroundColor: 'softblue', color: 'black', cursor: 'hand' }}>\n          Hey there! 👋 Please complete your profile before posting!\n        </Banner>\n      </Flex>\n    </InternalLink>\n  );\n});\n"
  },
  {
    "path": "src/common/Alerts/Alerts.tsx",
    "content": "import { UserAction } from '../UserAction';\nimport { AlertBanner } from './AlertBanner';\nimport { AlertIncompleteProfile } from './AlertIncompleteProfile';\n\nexport const Alerts = () => {\n  return (\n    <>\n      <AlertBanner />\n      <UserAction loggedIn={<AlertIncompleteProfile />} loggedOut={null} />\n    </>\n  );\n};\n"
  },
  {
    "path": "src/common/Analytics/GoogleAnalytics.tsx",
    "content": "import { useContext, useEffect } from 'react';\nimport ReactGA from 'react-ga4';\nimport { useLocation } from 'react-router';\nimport { TenantContext } from 'src/pages/common/TenantContext';\n\nexport const GoogleAnalytics = () => {\n  const location = useLocation();\n  const env = useContext(TenantContext);\n\n  useEffect(() => {\n    if (env?.gaTrackingId) {\n      ReactGA.initialize([{ trackingId: env.gaTrackingId }]);\n    }\n  }, []);\n\n  useEffect(() => {\n    if (env?.gaTrackingId) {\n      sendPageView(location);\n    }\n  }, [location]);\n\n  const sendPageView = (location: any) => {\n    ReactGA.set({ page: location.pathname });\n    ReactGA.send({ hitType: 'pageview', page: location.pathname });\n  };\n\n  return null;\n};\n"
  },
  {
    "path": "src/common/Analytics/index.tsx",
    "content": "import type { SubscribableContentTypes } from 'oa-shared';\nimport ReactGA from 'react-ga4';\nimport type { UaEventOptions } from 'react-ga4/types/ga4';\nimport { GoogleAnalytics } from './GoogleAnalytics';\n\nexport type EventAction =\n  | 'donationModalOpened'\n  | 'unsubscribed'\n  | 'subscribed'\n  | 'deleted'\n  | 'useful'\n  | 'usefulRemoved'\n  | 'upgradeBadgeClicked';\n\nexport type EventCategory = 'profiles' | SubscribableContentTypes;\n\nexport type TrackEventOptions = Omit<UaEventOptions, 'action' | 'category'> & {\n  action: EventAction;\n  category: EventCategory;\n};\n\nexport const trackEvent = (options: TrackEventOptions) => {\n  ReactGA.event(options);\n};\n\nexport const Analytics = GoogleAnalytics;\n"
  },
  {
    "path": "src/common/AuthWrapper.test.tsx",
    "content": "import { render } from '@testing-library/react';\nimport { UserRole } from 'oa-shared';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { AuthWrapper } from './AuthWrapper';\n\nimport type { Profile } from 'oa-shared';\n\nconst { ProfileStore } = await vi.hoisted(async () => {\n  const actual = await import('src/stores/Profile/profile.store');\n  return { ProfileStore: actual.ProfileStore };\n});\n\nconst mockStore = new ProfileStore();\nmockStore.profile = FactoryUser({\n  roles: [UserRole.BETA_TESTER],\n}) as Profile;\n\nvi.mock('src/stores/Profile/profile.store', async (importOriginal) => {\n  const actual: any = await importOriginal();\n  return {\n    ...actual,\n    useProfileStore: () => mockStore,\n    ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n  };\n});\n\ndescribe('AuthWrapper', () => {\n  it('renders fallback when user is not authorized', () => {\n    const { getByText } = render(\n      <ProfileStoreProvider>\n        <AuthWrapper roleRequired={UserRole.ADMIN} fallback={<div>Fallback Content</div>}>\n          <div>Test Content</div>\n        </AuthWrapper>\n      </ProfileStoreProvider>,\n    );\n    expect(getByText('Fallback Content')).toBeTruthy();\n  });\n\n  it('renders child components when user is authorized with role array', () => {\n    const { getByText } = render(\n      <ProfileStoreProvider>\n        <AuthWrapper roleRequired={UserRole.BETA_TESTER}>\n          <div>Test Content</div>\n        </AuthWrapper>\n      </ProfileStoreProvider>,\n    );\n    expect(getByText('Test Content')).toBeTruthy();\n  });\n\n  it('renders child components when user is authorized with role string', () => {\n    const { getByText } = render(\n      <ProfileStoreProvider>\n        <AuthWrapper roleRequired={UserRole.BETA_TESTER}>\n          <div>Test Content</div>\n        </AuthWrapper>\n      </ProfileStoreProvider>,\n    );\n    expect(getByText('Test Content')).toBeTruthy();\n  });\n\n  it('renders child components when user exists but no roles specified', () => {\n    const { getByText } = render(\n      <ProfileStoreProvider>\n        <AuthWrapper roleRequired={[]}>\n          <div>Test Content</div>\n        </AuthWrapper>\n      </ProfileStoreProvider>,\n    );\n    expect(getByText('Test Content')).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "src/common/AuthWrapper.tsx",
    "content": "import { observer } from 'mobx-react';\nimport type { UserRole } from 'oa-shared';\nimport React from 'react';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\n\n/*\n    Simple wrapper to only render a component if the user is logged in (plus optional user role required)\n    Optionally provide a fallback component to render if not satisfied\n*/\ninterface IProps {\n  children: React.ReactNode;\n  borderLess?: boolean;\n  fallback?: React.ReactNode;\n  roleRequired?: UserRole | UserRole[];\n}\n\nexport const AuthWrapper = observer((props: IProps) => {\n  const { borderLess, children, roleRequired } = props;\n  const { isUserAuthorized } = useProfileStore();\n  const isAuthorized = isUserAuthorized(roleRequired);\n\n  const childElements =\n    roleRequired === 'beta-tester' && !borderLess ? (\n      <div className=\"beta-tester-feature\">{children}</div>\n    ) : (\n      props.children\n    );\n\n  return <>{isAuthorized ? childElements : props.fallback || <></>}</>;\n});\n"
  },
  {
    "path": "src/common/DonationRequestModalContainer.test.tsx",
    "content": "import { render, screen, waitFor } from '@testing-library/react';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { DonationRequestModalContainer } from './DonationRequestModalContainer';\n\nconst mockDonationSettings = {\n  spaceName: 'Test Space',\n  description: 'Test Description',\n  imageUrl: 'https://example.com/image.jpg',\n  campaignId: 'test-campaign-123',\n};\n\ndescribe('DonationRequestModalContainer', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('does not render modal when isOpen is false', () => {\n    global.fetch = vi.fn();\n\n    render(<DonationRequestModalContainer isOpen={false} onDidDismiss={vi.fn()} />);\n\n    expect(screen.queryByTestId('DonationRequest')).toBeNull();\n    expect(global.fetch).not.toHaveBeenCalled();\n  });\n\n  it('fetches settings and renders modal when isOpen is true', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve(mockDonationSettings),\n    });\n\n    render(<DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} />);\n\n    await waitFor(() => {\n      expect(screen.getByTestId('DonationRequest')).toBeTruthy();\n    });\n\n    expect(global.fetch).toHaveBeenCalledWith('/api/donation-settings/default');\n  });\n\n  it('fetches settings with profileId when provided', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve(mockDonationSettings),\n    });\n\n    render(<DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} profileId={42} />);\n\n    await waitFor(() => {\n      expect(global.fetch).toHaveBeenCalledWith('/api/donation-settings/42');\n    });\n  });\n\n  it('does not render modal when campaignId is missing', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve({ ...mockDonationSettings, campaignId: '' }),\n    });\n\n    render(<DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} />);\n\n    await waitFor(() => {\n      expect(global.fetch).toHaveBeenCalled();\n    });\n\n    expect(screen.queryByTestId('DonationRequest')).toBeNull();\n  });\n\n  it('renders children inside the modal', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve(mockDonationSettings),\n    });\n\n    render(\n      <DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()}>\n        <div data-testid=\"child-content\">Child Content</div>\n      </DonationRequestModalContainer>,\n    );\n\n    await waitFor(() => {\n      expect(screen.getByTestId('DonationRequest')).toBeTruthy();\n    });\n\n    expect(screen.getByTestId('child-content')).toBeTruthy();\n  });\n\n  it('does not refetch settings if already loaded', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve(mockDonationSettings),\n    });\n\n    const { rerender } = render(\n      <DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} />,\n    );\n\n    await waitFor(() => {\n      expect(screen.getByTestId('DonationRequest')).toBeTruthy();\n    });\n\n    expect(global.fetch).toHaveBeenCalledTimes(1);\n\n    rerender(<DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} />);\n\n    expect(global.fetch).toHaveBeenCalledTimes(1);\n  });\n\n  it('displays the space name in the modal title', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve(mockDonationSettings),\n    });\n\n    render(<DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Support ' + mockDonationSettings.spaceName)).toBeTruthy();\n    });\n  });\n\n  it('displays the description in the modal', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve(mockDonationSettings),\n    });\n\n    render(<DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} />);\n\n    await waitFor(() => {\n      expect(screen.getByText(mockDonationSettings.description)).toBeTruthy();\n    });\n  });\n\n  it('renders iframe with correct src including campaignId', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve(mockDonationSettings),\n    });\n\n    render(<DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} />);\n\n    await waitFor(() => {\n      const iframe = screen.getByTestId('donationRequestIframe');\n      expect(iframe).toBeTruthy();\n      expect(iframe.getAttribute('src')).toBe(\n        `https://donorbox.org/embed/${mockDonationSettings.campaignId}?hide_donation_meter=true`,\n      );\n    });\n  });\n\n  it('renders image when imageUrl is provided', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve(mockDonationSettings),\n    });\n\n    render(<DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} />);\n\n    await waitFor(() => {\n      const image = screen.getByTestId('donationRequestImage');\n      expect(image).toBeTruthy();\n      expect(image.getAttribute('src')).toBe('https://example.com/image.jpg');\n    });\n  });\n\n  it('displays generic title when spaceName is empty', async () => {\n    global.fetch = vi.fn().mockResolvedValue({\n      json: () => Promise.resolve({ ...mockDonationSettings, spaceName: '' }),\n    });\n\n    render(<DonationRequestModalContainer isOpen={true} onDidDismiss={vi.fn()} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Support our work')).toBeTruthy();\n    });\n  });\n});\n"
  },
  {
    "path": "src/common/DonationRequestModalContainer.tsx",
    "content": "import { DonationRequestModal } from 'oa-components';\nimport type { ReactNode } from 'react';\nimport { useEffect, useState } from 'react';\n\ntype DonationRequestModalContainerProps = {\n  isOpen: boolean;\n  onDidDismiss: () => void;\n  profileId?: number;\n  children?: ReactNode | ReactNode[];\n};\n\ntype DonationSettings = {\n  spaceName: string;\n  description: string;\n  imageUrl: string;\n  campaignId: string;\n};\n\nexport const DonationRequestModalContainer = (props: DonationRequestModalContainerProps) => {\n  const [settings, setSettings] = useState<DonationSettings>();\n  const embedUrl = `https://donorbox.org/embed/${settings?.campaignId}`;\n\n  useEffect(() => {\n    const fetchSettings = async () => {\n      const response = await fetch(`/api/donation-settings/${props.profileId || 'default'}`);\n      const { spaceName, description, imageUrl, campaignId } = await response.json();\n\n      setSettings({ spaceName, description, imageUrl, campaignId });\n    };\n\n    if (props.isOpen && !settings) {\n      fetchSettings();\n    }\n  }, [props.isOpen, settings]);\n\n  return (\n    <>\n      {settings?.campaignId && (\n        <DonationRequestModal\n          spaceName={settings.spaceName}\n          description={settings.description}\n          iframeSrc={embedUrl}\n          imageUrl={settings.imageUrl}\n          isOpen={props.isOpen}\n          onDidDismiss={props.onDidDismiss}\n        >\n          {props.children}\n        </DonationRequestModal>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "src/common/DownloadWrapper.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { render, screen } from '@testing-library/react';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { DownloadWrapper } from './DownloadWrapper';\n\nconst mockedUsedNavigate = vi.fn();\nvi.mock('react-router', () => ({\n  useNavigate: () => mockedUsedNavigate,\n}));\n\nvi.mock('./UserAction', () => ({\n  UserAction: ({ loggedOut }: { loggedOut: React.ReactNode }) => <>{loggedOut}</>,\n}));\n\nvi.mock('src/common/hooks/useCommonStores', () => ({\n  __esModule: true,\n  useCommonStores: vi.fn(),\n}));\n\ndescribe('DownloadWrapper', () => {\n  it('renders DownloadButton with isLoggedIn=false and shows DownloadCounter', () => {\n    render(\n      <DownloadWrapper\n        contentType=\"projects\"\n        fileDownloadCount={42}\n        fileLink=\"https://example.com/file.pdf\"\n        files={[]}\n      />,\n    );\n\n    expect(screen.getByText('Download files')).toBeInTheDocument();\n    expect(screen.getByText('42 downloads')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/common/DownloadWrapper.tsx",
    "content": "import { DownloadButton, DownloadCounter, DownloadStaticFile, ExternalLink } from 'oa-components';\nimport type { MediaFile } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { useNavigate } from 'react-router';\nimport { Button, Flex } from 'theme-ui';\nimport { trackEvent } from './Analytics';\nimport { DonationRequestModalContainer } from './DonationRequestModalContainer';\nimport { UserAction } from './UserAction';\n\ninterface IProps {\n  contentType: 'research' | 'projects';\n  fileDownloadCount: number;\n  fileLink?: string;\n  files?: MediaFile[];\n  authorProfileId?: number;\n}\n\nexport const DownloadWrapper = (props: IProps) => {\n  const { fileLink, files, fileDownloadCount, authorProfileId, contentType } = props;\n  const hasFiles = files && files.length > 0;\n  const [openModal, setOpenModal] = useState(false);\n  const [link, setLink] = useState('');\n\n  const navigate = useNavigate();\n\n  if (!fileLink && !hasFiles) {\n    return null;\n  }\n\n  useEffect(() => {\n    if (sessionStorage.getItem('loginRedirect') && (fileLink || hasFiles)) {\n      sessionStorage.removeItem('loginRedirect');\n      if (files && files?.length === 1) {\n        setOpenModal(true);\n      }\n    }\n  }, [fileLink, hasFiles]);\n\n  const handleLoggedOutDownloadClick = () => {\n    sessionStorage.setItem('loginRedirect', 'true');\n    navigate(`/sign-in?returnUrl=${encodeURIComponent(`${location?.pathname}`)}`);\n  };\n\n  return (\n    <UserAction\n      loggedIn={\n        <>\n          <DonationRequestModalContainer\n            isOpen={openModal}\n            onDidDismiss={() => setOpenModal(false)}\n            profileId={authorProfileId}\n          >\n            <Flex\n              sx={{\n                backgroundColor: '#fff',\n                borderRadius: '0 0 4px 4px',\n                flexDirection: ['column', 'row'],\n                marginTop: 2,\n                gap: 2,\n                justifyContent: 'flex-end',\n                alignItems: 'center',\n              }}\n            >\n              <ExternalLink\n                href={link}\n                onClick={() => setOpenModal(false)}\n                data-cy=\"DonationRequestSkip\"\n                data-testid=\"DonationRequestSkip\"\n              >\n                <Button>Download</Button>\n              </ExternalLink>\n            </Flex>\n          </DonationRequestModalContainer>\n\n          <>\n            {fileLink && (\n              <DownloadButton\n                isLoggedIn\n                onClick={() => {\n                  setLink(fileLink);\n                  setOpenModal(true);\n                  trackEvent({\n                    action: 'donationModalOpened',\n                    category: contentType,\n                  });\n                }}\n              />\n            )}\n            {files && (\n              <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n                {files.map((file, index) => (\n                  <DownloadStaticFile\n                    file={file}\n                    key={file ? file.url : `file-${index}`}\n                    fileDownloadCount={props.fileDownloadCount}\n                    handleClick={() => {\n                      setLink(file.url!);\n                      setOpenModal(true);\n                      trackEvent({\n                        action: 'donationModalOpened',\n                        category: contentType,\n                      });\n                    }}\n                    isLoggedIn\n                  />\n                ))}\n              </Flex>\n            )}\n            <DownloadCounter total={props.fileDownloadCount} />\n          </>\n        </>\n      }\n      loggedOut={\n        <>\n          <DownloadButton isLoggedIn={false} onClick={handleLoggedOutDownloadClick} />\n          <DownloadCounter total={fileDownloadCount} />\n        </>\n      }\n    />\n  );\n};\n"
  },
  {
    "path": "src/common/Form/Checkbox.tsx",
    "content": "import { Label } from 'theme-ui';\n\nimport type { FieldProps } from './types';\n\nexport const CheckboxInput = ({ input, id, labelText, ...rest }: FieldProps) => {\n  return (\n    <>\n      <input id={id} type=\"checkbox\" {...input} {...rest}></input>\n      <Label\n        sx={{\n          fontSize: 2,\n          cursor: 'pointer',\n        }}\n        htmlFor={id}\n      >\n        {labelText}\n      </Label>\n    </>\n  );\n};\n"
  },
  {
    "path": "src/common/Form/ErrorsContainer.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport { describe, expect, it } from 'vitest';\n\nimport { ErrorsContainer } from './ErrorsContainer';\n\ndescribe('ErrorsContainer', () => {\n  it('renders component when visible and has intro errors', async () => {\n    const descriptionError = 'Fill this in correctly';\n\n    const errorsListSet = [descriptionError];\n    render(<ErrorsContainer serverErrors={errorsListSet} />);\n\n    await screen.findByText(descriptionError, { exact: false });\n  });\n\n  it('renders nothing when not visible', async () => {\n    const errorsListSet = [];\n    const { container } = render(<ErrorsContainer serverErrors={errorsListSet} />);\n\n    expect(container.innerHTML).toBe('');\n  });\n});\n"
  },
  {
    "path": "src/common/Form/ErrorsContainer.tsx",
    "content": "import { Box, Card, Flex, Text } from 'theme-ui';\n\nimport { headings } from './labels';\n\nimport type { IErrorsListSet } from './types';\n\ninterface IProps {\n  clientErrors?: IErrorsListSet[];\n  serverErrors?: string[];\n}\n\nexport const ErrorsContainer = ({ clientErrors, serverErrors }: IProps) => {\n  const hasClientErrors = clientErrors && clientErrors.length !== 0;\n  const hasServerErrors =\n    serverErrors && serverErrors.filter((error) => error != undefined).length !== 0;\n\n  if (!hasClientErrors && !hasServerErrors) {\n    return null;\n  }\n\n  return (\n    <Card\n      data-cy=\"errors-container\"\n      sx={{\n        display: 'flex',\n        padding: 3,\n        flexDirection: 'column',\n        fontSize: 1,\n        backgroundColor: 'red2',\n        borderColor: 'red',\n        gap: 2,\n      }}\n    >\n      <Text sx={{ fontSize: 2, fontWeight: 'bold' }}>{headings.errors}</Text>\n      {hasServerErrors && (\n        <ul style={{ padding: 0, margin: 0, listStylePosition: 'inside' }}>\n          {serverErrors.map((x, i) => (\n            <li key={i}>{x}</li>\n          ))}\n        </ul>\n      )}\n      {hasClientErrors &&\n        clientErrors.map((errorsList, index) => {\n          const { errors, title, keys, labels } = errorsList;\n\n          return (\n            <Flex key={index} sx={{ flexDirection: 'column' }}>\n              {title && (\n                <Box paddingBottom={1}>\n                  <Text>{title}</Text>\n                </Box>\n              )}\n              <ul style={{ padding: 0, margin: 0, listStylePosition: 'inside' }}>\n                {keys.map((key, keyIndex) => {\n                  return (\n                    <li key={keyIndex}>\n                      <strong>{labels[key].title}</strong>: {errors[key] as string}\n                    </li>\n                  );\n                })}\n              </ul>\n            </Flex>\n          );\n        })}\n    </Card>\n  );\n};\n"
  },
  {
    "path": "src/common/Form/FieldContainer.ts",
    "content": "import { css } from '@emotion/react';\nimport styled from '@emotion/styled';\n\ninterface IFormElement {\n  invalid?: boolean;\n  customChange?: (location) => void;\n}\n\nconst inputStyles = ({ invalid }: IFormElement) => css`\n  border-width: 1px;\n  border-style: solid;\n  border-color: ${invalid ? 'error' : 'transparent'};\n  border-radius: 5px;\n  font-family: 'Inter', Arial, sans-serif;\n  font-size: 12px;\n  background-color: background;\n  width: 100%;\n  box-sizing: border-box;\n\n  &:disabled {\n    border: none;\n    color: black;\n  }\n\n  &:focus {\n    border-color: blue;\n    outline: none;\n  }\n`;\n\n// generic container used for some custom component fields\nexport const FieldContainer = styled.div<IFormElement>`\n  height: 100%;\n  width: 100%;\n  ${inputStyles};\n  border: none;\n  padding: 0;\n`;\n"
  },
  {
    "path": "src/common/Form/FileInput/FileDisplay.tsx",
    "content": "import { Icon } from 'oa-components';\nimport type { MediaFile } from 'oa-shared';\nimport { bytesToSize } from 'oa-shared';\nimport { Link } from 'react-router';\nimport { Flex, IconButton, Text } from 'theme-ui';\n\ntype FileDisplayProps = {\n  file: MediaFile;\n  onRemove: () => void;\n};\n\nexport const FileDisplay = ({ file, onRemove }: FileDisplayProps) => {\n  return (\n    <Flex\n      key={file.id}\n      sx={{\n        alignItems: 'center',\n        gap: 2,\n        background: 'background',\n        borderRadius: 6,\n        padding: 1,\n      }}\n    >\n      <Icon size={24} glyph=\"download-cloud\" sx={{ marginLeft: 1 }} />\n      <Text\n        sx={{\n          flex: 1,\n          fontSize: 1,\n          textOverflow: 'ellipsis',\n          overflow: 'hidden',\n        }}\n      >\n        {file.url ? (\n          <Link to={file.url} target=\"_blank\">\n            {file.name}\n          </Link>\n        ) : (\n          file.name\n        )}\n      </Text>\n\n      {file.size && <Text sx={{ fontSize: 1 }}>{bytesToSize(file.size)}</Text>}\n\n      <IconButton\n        onClick={onRemove}\n        data-testid=\"remove-file\"\n        data-cy=\"remove-file\"\n        type=\"button\"\n        sx={{ ':hover': { cursor: 'pointer' } }}\n      >\n        <Icon size={16} glyph=\"close\" />\n      </IconButton>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/common/Form/FileInput/FileInput.tsx",
    "content": "import { Button } from 'oa-components';\nimport { useRef, useState } from 'react';\nimport { Flex, Text } from 'theme-ui';\nimport { FileDisplay } from './FileDisplay';\n\nconst MaxFileSize = {\n  user: 50 * 1048576,\n  admin: 300 * 1048576,\n};\n\ninterface IProps {\n  onFilesChange?: (files: (Blob | File)[]) => void;\n  'data-cy'?: string;\n  admin: boolean;\n}\n\ntype FileWithId = File & { id: string };\n\nexport const FileInput = (props: IProps) => {\n  const [files, setFiles] = useState<FileWithId[]>([]);\n  const [error, setError] = useState<string | null>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  const maxFileSize = props.admin ? MaxFileSize.admin : MaxFileSize.user;\n\n  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    setError(null);\n    const selectedFiles = Array.from(event.target.files || []);\n\n    // Check number of files\n    if (files.length + selectedFiles.length > 5) {\n      setError(`You can only upload up to 5 files`);\n      return;\n    }\n\n    // Validate file sizes\n    const oversizedFiles = selectedFiles.filter((file) => file.size > maxFileSize);\n\n    if (oversizedFiles.length > 0) {\n      const maxSizeMB = Math.floor(maxFileSize / 1048576);\n      setError(`Some files exceed the maximum size of ${maxSizeMB}MB`);\n      return;\n    }\n\n    // Add unique IDs to files\n    const newFiles = selectedFiles.map((file) =>\n      Object.assign(file, { id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}` }),\n    ) as FileWithId[];\n\n    const updatedFiles = [...files, ...newFiles];\n    setFiles(updatedFiles);\n    props.onFilesChange?.(updatedFiles);\n\n    // Reset input value to allow selecting the same file again\n    if (fileInputRef.current) {\n      fileInputRef.current.value = '';\n    }\n  };\n\n  const remove = (id: string) => {\n    const updatedFiles = files.filter((file) => file.id !== id);\n    setFiles(updatedFiles);\n    props.onFilesChange?.(updatedFiles);\n  };\n\n  return (\n    <Flex sx={{ flexDirection: 'column', justifyContent: 'center', gap: 1 }}>\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        multiple\n        onChange={handleFileChange}\n        style={{ display: 'none' }}\n        id=\"file-input\"\n      />\n      <Button\n        type=\"button\"\n        onClick={() => fileInputRef.current?.click()}\n        icon=\"upload\"\n        variant=\"outline\"\n        sx={{ mb: 1, width: 'fit-content ' }}\n        data-cy={props['data-cy']}\n      >\n        Add Files\n      </Button>\n\n      {error && <Text sx={{ color: 'error', fontSize: 1, mb: 1 }}>{error}</Text>}\n\n      {files.map((file) => (\n        <FileDisplay\n          key={file.id}\n          file={{\n            id: file.id,\n            name: file.name,\n            size: file.size ?? 0,\n          }}\n          onRemove={() => remove(file.id)}\n        />\n      ))}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/common/Form/FileInput.field.tsx",
    "content": "import { FileInput } from './FileInput/FileInput';\n\nimport type { FieldProps } from './types';\n\nexport const FileInputField = ({\n  input,\n  onFilesChange,\n  ...rest\n}: FieldProps & { admin: boolean; onFilesChange?: (files: (Blob | File)[]) => void }) => (\n  <FileInput\n    {...rest}\n    onFilesChange={(files) => {\n      if (onFilesChange) {\n        // If custom handler provided, use it instead\n        onFilesChange(files);\n      } else {\n        // Default behavior - update form field directly\n        input.onChange(files);\n        input.onBlur();\n      }\n    }}\n  />\n);\n"
  },
  {
    "path": "src/common/Form/FormWrapper.tsx",
    "content": "import { Button, ElWithBeforeIcon, Loader } from 'oa-components';\nimport { useFormState } from 'react-final-form';\nimport IconHeaderHowto from 'src/assets/images/header-section/howto-header-icon.svg';\nimport { Box, Card, Flex, Heading } from 'theme-ui';\nimport { ErrorsContainer } from './ErrorsContainer';\nimport type { IErrorsListSet } from './types';\nimport { UnsavedChangesDialog } from './UnsavedChangesDialog';\n\ninterface IProps {\n  buttonLabel: string;\n  children: React.ReactNode;\n  errorsClientSide?: IErrorsListSet[];\n  errorSubmitting?: string;\n  guidelines?: React.ReactNode;\n  handleSubmit: () => void;\n  handleSubmitDraft: (e: React.MouseEvent) => void;\n  heading: string;\n  hasValidationErrors: boolean;\n  belowBody?: React.ReactNode;\n  sidebar?: React.ReactNode;\n  submitFailed: boolean;\n  submitting: boolean;\n  hideSubmittingMessage?: boolean;\n  unsavedChangesDialog?: React.ReactNode;\n}\n\nconst DRAFT_LABEL = 'Save as draft';\n\nexport const FormWrapper = (props: IProps) => {\n  const {\n    belowBody,\n    buttonLabel,\n    children,\n    errorsClientSide,\n    errorSubmitting,\n    guidelines,\n    handleSubmit,\n    handleSubmitDraft,\n    heading,\n    hasValidationErrors,\n    sidebar,\n    submitFailed,\n    submitting,\n    hideSubmittingMessage,\n    unsavedChangesDialog,\n  } = props;\n\n  const { dirty } = useFormState({ subscription: { dirty: true } });\n  const hasClientSideErrors = hasValidationErrors && submitFailed;\n\n  return (\n    <Flex\n      sx={{\n        flexWrap: 'wrap',\n        backgroundColor: 'inherit',\n        marginTop: 4,\n        gap: '1rem',\n      }}\n    >\n      <Flex\n        sx={{\n          backgroundColor: 'inherit',\n          width: ['100%', '100%', `${(2 / 3) * 100}%`],\n        }}\n      >\n        {unsavedChangesDialog ?? <UnsavedChangesDialog hasChanges={dirty} />}\n        <Flex\n          as=\"form\"\n          sx={{ width: '100%', flexDirection: 'column', gap: '1rem' }}\n          onSubmit={handleSubmit}\n        >\n          <Card sx={{ backgroundColor: 'softblue' }}>\n            <Flex sx={{ alignItems: 'center', paddingX: 3, paddingY: 2 }}>\n              <Heading as=\"h1\">{heading}</Heading>\n              <Box ml=\"15px\">\n                <ElWithBeforeIcon icon={IconHeaderHowto} size={20} />\n              </Box>\n            </Flex>\n          </Card>\n          {guidelines && <Box sx={{ display: ['block', 'block', 'none'] }}>{guidelines}</Box>}\n          <Card\n            sx={{\n              padding: 4,\n              overflow: 'visible',\n              display: 'flex',\n              flexDirection: 'column',\n              gap: '1rem',\n            }}\n          >\n            {children}\n          </Card>\n          {belowBody}\n        </Flex>\n      </Flex>\n      <Flex\n        sx={{\n          flexDirection: 'column',\n          backgroundColor: 'inherit',\n          maxWidth: ['inherit', 'inherit', '400px'],\n          width: ['100%', '100%', '30%'],\n          alignItems: 'space-between',\n          gap: 3,\n        }}\n      >\n        {guidelines && <Box sx={{ display: ['none', 'none', 'block'] }}>{guidelines}</Box>}\n        <Button\n          large\n          data-cy=\"submit\"\n          variant=\"primary\"\n          type=\"submit\"\n          disabled={submitting}\n          onClick={handleSubmit}\n          sx={{\n            width: '100%',\n            display: 'block',\n          }}\n        >\n          {buttonLabel}\n        </Button>\n\n        <Button\n          data-cy=\"draft\"\n          onClick={handleSubmitDraft}\n          variant=\"secondary\"\n          type=\"submit\"\n          disabled={submitting}\n          sx={{\n            width: '100%',\n            display: 'block',\n          }}\n        >\n          {DRAFT_LABEL}\n        </Button>\n\n        {submitting && !hideSubmittingMessage && (\n          <Loader label=\"Submitting, please do not close the page...\" />\n        )}\n        {sidebar && sidebar}\n        {errorSubmitting && <ErrorsContainer serverErrors={[errorSubmitting]} />}\n        {hasClientSideErrors && <ErrorsContainer clientErrors={errorsClientSide} />}\n      </Flex>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/common/Form/PasswordField.tsx",
    "content": "import { Icon } from 'oa-components';\nimport { useState } from 'react';\nimport { Field } from 'react-final-form';\n\nexport const PasswordField = ({ name, component, ...rest }) => {\n  const [isPasswordVisible, setIsPasswordVisible] = useState(false);\n\n  return (\n    <Field\n      {...rest}\n      name={name}\n      component={component}\n      type={isPasswordVisible ? 'text' : 'password'}\n      sx={{\n        paddingRight: 8,\n      }}\n      endAdornment={\n        <Icon\n          sx={{\n            display: 'flex',\n            alignItems: 'center',\n          }}\n          glyph={isPasswordVisible ? 'hide' : 'show'}\n          onClick={() => setIsPasswordVisible(!isPasswordVisible)}\n          size=\"25\"\n        />\n      }\n      required\n    />\n  );\n};\n"
  },
  {
    "path": "src/common/Form/README.md",
    "content": "## About Forms\n\nThe platform uses react-final-form as a state management tool for handling form data.\nhttps://github.com/final-form/react-final-form\n\nIt does not provide any of its own components, instead offering a `<Field />` wrapper that can\ntake any existing component and hook into value and onChange props as required.\n\nThe components in this folder are a combination of styled/wrapped standard input components, and wrappers for custom components\nwhich sit outside of the main directory.\n"
  },
  {
    "path": "src/common/Form/Select.field.tsx",
    "content": "import { Select } from 'oa-components';\nimport React from 'react';\nimport { Flex, Text } from 'theme-ui';\n\nimport { FieldContainer } from './FieldContainer';\n\nimport type { FieldProps } from './types';\n\ninterface ISelectOption {\n  value: string;\n  label: string;\n}\ninterface ISelectFieldProps extends FieldProps {\n  options?: ISelectOption[];\n  placeholder?: string;\n  style?: React.CSSProperties;\n  onCustomChange?: (value) => void;\n  showError?: boolean;\n}\n\n// annoyingly react-final-form saves the full option as values (not just the value field)\n// therefore the following two functions are used for converting to-from string values and field options\n\n// depending on select type (e.g. multi) and option selected get value\nconst getValueFromSelect = (v: ISelectOption | ISelectOption[] | null | undefined) =>\n  v ? (Array.isArray(v) ? v.map((el) => el.value) : v.value) : v;\n\n// given current values find the relevant select options\nconst getValueForSelect = (opts: ISelectOption[] = [], v: string | string[] | null | undefined) => {\n  const findVal = (optVal: string) => opts.find((o) => o.value === optVal);\n  return v\n    ? Array.isArray(v)\n      ? v.map((optVal) => findVal(optVal) as ISelectOption)\n      : findVal(v)\n    : null;\n};\n\nconst defaultProps: Partial<ISelectFieldProps> = {\n  getOptionLabel: (option: ISelectOption) => option.label,\n  getOptionValue: (option: ISelectOption) => option.value,\n  options: [],\n};\n\nexport const SelectField = ({\n  input,\n  meta,\n  onCustomChange,\n  showError = true,\n  ...rest\n}: ISelectFieldProps) => (\n  // note, we first use a div container so that default styles can be applied\n  <Flex sx={{ padding: 0, flexDirection: 'column', gap: 1 }}>\n    {showError && meta.error && meta.touched && (\n      <Text sx={{ fontSize: 1, color: 'error' }}>{meta.error}</Text>\n    )}\n\n    <FieldContainer\n      invalid={meta.error && meta.touched}\n      style={rest.style}\n      data-cy={rest['data-cy']}\n    >\n      <Select\n        onChange={(v) => {\n          const value = getValueFromSelect(v);\n          input.onChange(value);\n          if (onCustomChange) {\n            onCustomChange(value);\n          }\n        }}\n        onBlur={input.onBlur}\n        onFocus={input.onFocus}\n        value={getValueForSelect(rest.options, input.value)}\n        variant={meta?.error && meta?.touched ? 'formError' : 'form'}\n        {...defaultProps}\n        {...(rest as any)}\n      />\n    </FieldContainer>\n  </Flex>\n);\n"
  },
  {
    "path": "src/common/Form/TagsSelectField.tsx",
    "content": "import { TagsSelect } from '../Tags/TagsSelect';\n\nexport const TagsSelectField = ({ input, ...rest }) => (\n  <TagsSelect\n    styleVariant=\"selector\"\n    placeholder=\"Select tags (max 4)\"\n    isForm={true}\n    onChange={(tags) => input.onChange(tags)}\n    value={input.value}\n    {...rest}\n  />\n);\n"
  },
  {
    "path": "src/common/Form/UnsavedChangesDialog.tsx",
    "content": "import { ConfirmModal } from 'oa-components';\nimport { useBlocker } from 'react-router';\n\nconst CONFIRM_DIALOG_MSG = 'You have unsaved changes. Are you sure you want to leave this page?';\n\n/**\n * When places inside a react-final-form <Form> element watches for form pristine/dirty\n * change and handles router and window confirmation if form contains changes\n **/\ntype IProps = {\n  hasChanges: boolean;\n};\n\nexport const UnsavedChangesDialog = ({ hasChanges }: IProps) => {\n  const blocker = useBlocker(\n    ({ currentLocation, nextLocation }) => hasChanges && currentLocation !== nextLocation,\n  );\n\n  return (\n    <ConfirmModal\n      isOpen={blocker.state === 'blocked'}\n      message={CONFIRM_DIALOG_MSG}\n      confirmButtonText=\"Yes\"\n      handleCancel={() => blocker.reset && blocker.reset()}\n      handleConfirm={() => blocker.proceed && blocker.proceed()}\n    />\n  );\n};\n"
  },
  {
    "path": "src/common/Form/labels.ts",
    "content": "export const headings = {\n  errors: \"Ouch, something's wrong\",\n};\n"
  },
  {
    "path": "src/common/Form/types.ts",
    "content": "import type { ReactNode } from 'react';\nimport type { FieldRenderProps } from 'react-final-form';\n\n// any props can be passed to field and down to child component\n// input and meta props come from react field render props and will be\n// picked up by typing\nexport type FieldProps = FieldRenderProps<any, any> & {\n  disabled?: boolean;\n  children?: ReactNode;\n  'data-cy'?: string;\n  customOnBlur?: (event: any) => void;\n};\n\ntype EmptyObj = Record<never, never>;\n\nexport type IStepErrorsList = (INestedErrorList | EmptyObj)[];\n\nexport interface INestedErrorList {\n  [key: string]: string;\n}\n\nexport interface ITopLevelErrorsList {\n  [key: string]: string | IStepErrorsList;\n}\n\ninterface Label {\n  title: string;\n  description?: string;\n  error?: string;\n  placeholder?: string;\n}\n\nexport interface ILabels {\n  [key: string]: Label;\n}\n\nexport interface IErrorsListSet {\n  errors: ITopLevelErrorsList;\n  keys: string[];\n  labels: ILabels;\n  title?: string;\n}\n\nexport type MainFormAction = 'create' | 'edit';\n"
  },
  {
    "path": "src/common/HideDiscussionContainer.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { fireEvent, render } from '@testing-library/react';\nimport { describe, expect, it } from 'vitest';\n\nimport { HideDiscussionContainer } from './HideDiscussionContainer';\n\ndescribe('HideDiscussionContainer', () => {\n  it('can be opened/closed', () => {\n    const { getByText } = render(\n      <HideDiscussionContainer commentCount={0}>\n        <>Hidden</>\n      </HideDiscussionContainer>,\n    );\n    const button = getByText('Start a discussion');\n    expect(() => getByText('Hidden')).toThrow();\n\n    fireEvent.click(button);\n    getByText('Collapse Comments');\n    expect(getByText('Hidden')).toBeInTheDocument();\n  });\n\n  it('renders right text for 1 comment', () => {\n    const { getByText } = render(\n      <HideDiscussionContainer commentCount={1}>\n        <></>\n      </HideDiscussionContainer>,\n    );\n    expect(getByText('View 1 comment')).toBeInTheDocument();\n  });\n\n  it('renders right text for 567 comments', () => {\n    const { getByText } = render(\n      <HideDiscussionContainer commentCount={567}>\n        <></>\n      </HideDiscussionContainer>,\n    );\n    expect(getByText('View 567 comments')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/common/HideDiscussionContainer.tsx",
    "content": "import { Button } from 'oa-components';\nimport React, { useMemo, useState } from 'react';\nimport { Box } from 'theme-ui';\n\ninterface IProps {\n  commentCount: number;\n  children: React.ReactNode | React.ReactNode[];\n  showComments?: boolean;\n}\n\nexport const HideDiscussionContainer = ({ children, commentCount, showComments }: IProps) => {\n  const [viewComments, setViewComments] = useState(() => showComments || false);\n\n  const buttonText = useMemo(() => {\n    if (!viewComments) {\n      switch (commentCount) {\n        case 0:\n          return 'Start a discussion';\n        case 1:\n          return 'View 1 comment';\n        default:\n          return `View ${commentCount} comments`;\n      }\n    }\n\n    return 'Collapse Comments';\n  }, [viewComments]);\n\n  return (\n    <Box\n      sx={{\n        backgroundColor: viewComments ? 'softblue' : 'inherit',\n        borderTop: '2px solid #111',\n        padding: 2,\n        transition: 'background-color 120ms ease-out',\n      }}\n    >\n      <Button\n        type=\"button\"\n        variant=\"subtle\"\n        sx={{\n          fontSize: '14px',\n          width: '100%',\n          textAlign: 'center',\n          display: 'block',\n          marginBottom: viewComments ? 2 : 0,\n          '&:hover': { bg: '#ececec' },\n        }}\n        onClick={() => setViewComments((prev) => !prev)}\n        backgroundColor={viewComments ? '#c2daf0' : 'softblue'}\n        className={viewComments ? 'viewComments' : ''}\n        data-cy=\"HideDiscussionContainer:button\"\n      >\n        {buttonText}\n      </Button>\n      {viewComments && children}\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/common/Highlighter.tsx",
    "content": "import Highlighter from 'react-highlight-words';\nimport { Text } from 'theme-ui';\n\nconst HighLighted = ({ children }: { children: string }) => {\n  return (\n    <Text data-cy=\"HightedText\" sx={{ background: 'accentHover' }}>\n      {children}\n    </Text>\n  );\n};\n\nexport interface IProps {\n  searchWords: (string | RegExp)[];\n  textToHighlight: string;\n}\n\nconst HighlighterComponent = (props: IProps) => {\n  return (\n    <Highlighter\n      highlightTag={HighLighted}\n      autoEscape={true}\n      {...props}\n      searchWords={props.searchWords}\n      textToHighlight={props.textToHighlight}\n    />\n  );\n};\n\nexport { HighlighterComponent as Highlighter };\n"
  },
  {
    "path": "src/common/PageHeader.tsx",
    "content": "import { Flex } from 'theme-ui';\n\ntype PageHeaderProps = {\n  actions?: React.ReactNode;\n  children: React.ReactNode;\n};\n\nconst PageHeader = ({ actions, children }: PageHeaderProps) => {\n  return (\n    <Flex sx={{ flexWrap: 'wrap', paddingY: 2, paddingX: [2, 0, 0], gap: 1, alignItems: 'center' }}>\n      {children}\n      {actions && (\n        <Flex sx={{ gap: 2, marginLeft: 'auto', justifyContent: 'flex-end' }}>{actions}</Flex>\n      )}\n    </Flex>\n  );\n};\n\nexport default PageHeader;\n"
  },
  {
    "path": "src/common/PremiumTierWrapper.test.tsx",
    "content": "import { render } from '@testing-library/react';\nimport { PremiumTier, type Profile, UserRole } from 'oa-shared';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { PremiumTierWrapper, userHasPremiumTier } from './PremiumTierWrapper';\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: () => ({\n    profile: FactoryUser({\n      badges: [\n        {\n          id: 1,\n          name: 'supporter',\n          displayName: 'Supporter',\n          imageUrl: 'https://example.com/icons/supporter.svg',\n        },\n        {\n          id: 2,\n          name: 'pro',\n          displayName: 'PRO',\n          imageUrl: 'https://example.com/icons/pro.svg',\n          premiumTier: 1,\n        },\n      ],\n    }),\n  }),\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\ndescribe('PremiumTierWrapper', () => {\n  it('renders fallback when user does not have required tier', () => {\n    const { getByText } = render(\n      <ProfileStoreProvider>\n        <PremiumTierWrapper tierRequired={2 as PremiumTier} fallback={<div>Fallback Content</div>}>\n          <div>Test Content</div>\n        </PremiumTierWrapper>\n      </ProfileStoreProvider>,\n    );\n    expect(getByText('Fallback Content')).toBeTruthy();\n  });\n\n  it('renders child components when user has required tier', () => {\n    const { getByText } = render(\n      <ProfileStoreProvider>\n        <PremiumTierWrapper tierRequired={PremiumTier.ONE}>\n          <div>Test Content</div>\n        </PremiumTierWrapper>\n      </ProfileStoreProvider>,\n    );\n    expect(getByText('Test Content')).toBeTruthy();\n  });\n});\n\ndescribe('userHasPremiumTier', () => {\n  it('returns true for admin users regardless of tier', () => {\n    const adminUser = FactoryUser({ roles: [UserRole.ADMIN], badges: [] }) as Profile;\n    expect(userHasPremiumTier(adminUser, PremiumTier.ONE)).toBe(true);\n  });\n\n  it('returns false when user does not have required tier', () => {\n    const user = FactoryUser({ badges: [] }) as Profile;\n    expect(userHasPremiumTier(user, PremiumTier.ONE)).toBe(false);\n  });\n\n  it('returns true when no tier is required', () => {\n    const user = FactoryUser({ badges: [] }) as Profile;\n    expect(userHasPremiumTier(user, undefined)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/common/PremiumTierWrapper.tsx",
    "content": "import { observer } from 'mobx-react';\nimport type { PremiumTier, Profile } from 'oa-shared';\nimport { UserRole } from 'oa-shared';\nimport React from 'react';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\n\ninterface IProps {\n  children: React.ReactNode;\n  fallback?: React.ReactNode;\n  tierRequired: PremiumTier;\n}\n\nexport const PremiumTierWrapper = observer((props: IProps) => {\n  const { children, fallback, tierRequired } = props;\n  const { profile } = useProfileStore();\n\n  const hasRequiredTier = userHasPremiumTier(profile, tierRequired);\n\n  return <>{hasRequiredTier ? children : fallback || null}</>;\n});\n\nexport const userHasPremiumTier = (\n  profile?: Profile | null,\n  tierRequired?: PremiumTier,\n): boolean => {\n  if (!tierRequired || tierRequired <= 0) {\n    return true;\n  }\n\n  if (!profile) {\n    return false;\n  }\n\n  if (profile.roles?.includes(UserRole.ADMIN)) {\n    return true;\n  }\n\n  if (!profile.badges || profile.badges.length === 0) {\n    return false;\n  }\n\n  return profile.badges.some((badge) => badge.premiumTier === tierRequired);\n};\n"
  },
  {
    "path": "src/common/Spinner.tsx",
    "content": "import { Icon } from 'oa-components';\nimport { Box } from 'theme-ui';\n\nconst Spinner = () => {\n  return (\n    <Box\n      sx={{\n        display: 'inline-block',\n        animation: 'spin 1s linear infinite',\n        '@keyframes spin': {\n          '0%': {\n            transform: 'rotate(0deg)',\n          },\n          '100%': {\n            transform: 'rotate(360deg)',\n          },\n        },\n      }}\n    >\n      <Icon glyph=\"loading\" />\n    </Box>\n  );\n};\n\nexport default Spinner;\n"
  },
  {
    "path": "src/common/Tags/ProfileTagsSelect.tsx",
    "content": "import { Select } from 'oa-components';\nimport type { ProfileCategory, ProfileTag } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport type { FieldRenderProps } from 'react-final-form';\nimport { profileTagsService } from 'src/services/profileTagsService';\nimport { FieldContainer } from '../Form/FieldContainer';\n\nexport interface IProps extends Partial<FieldRenderProps<any, any>> {\n  value: number[];\n  profileType: string | undefined;\n  placeholder?: string;\n}\n\nconst onProfileTypeChange = (profileType: string | undefined): ProfileCategory => {\n  if (!profileType || profileType === 'member') {\n    return 'member';\n  }\n  return 'space';\n};\n\nexport const ProfileTagsSelect = (props: IProps) => {\n  const [allTags, setAllTags] = useState<ProfileTag[]>([]);\n  const [filteredTags, setFilteredTags] = useState<ProfileTag[]>([]);\n  const [selectedTags, setSelectedTags] = useState<ProfileTag[]>([]);\n\n  const setFilters = () => {\n    const category = onProfileTypeChange(props.profileType);\n\n    const filteredByType = allTags.filter(({ profileType }) => profileType === category);\n    setFilteredTags(filteredByType);\n  };\n\n  useEffect(() => {\n    const initTags = async () => {\n      const tags = await profileTagsService.getAllTags();\n\n      if (!tags) {\n        return;\n      }\n      setAllTags(tags);\n    };\n\n    initTags();\n  }, []);\n\n  useEffect(() => {\n    if (allTags.length > 0 && props.value.length > 0) {\n      setSelectedTags(allTags.filter((x) => props.value.includes(x.id)));\n    }\n  }, [allTags, props.value]);\n\n  useEffect(() => {\n    setFilters();\n  }, [allTags, props.profileType]);\n\n  const onChange = (tags: ProfileTag[]) => {\n    setSelectedTags(tags);\n    props.onChange(tags.map((x) => x.id));\n  };\n\n  const isOptionDisabled = () => selectedTags.length >= (props.maxTotal || 4);\n\n  return (\n    <FieldContainer data-cy=\"profile-tag-select\">\n      <Select\n        variant=\"form\"\n        options={filteredTags}\n        placeholder={props.placeholder}\n        isClearable={true}\n        isOptionDisabled={isOptionDisabled}\n        isMulti={true}\n        value={selectedTags}\n        getOptionLabel={(tag: ProfileTag) => tag.name}\n        getOptionValue={(tag: ProfileTag) => tag.id}\n        onChange={onChange}\n      />\n    </FieldContainer>\n  );\n};\n"
  },
  {
    "path": "src/common/Tags/TagsSelect.tsx",
    "content": "import { Select } from 'oa-components';\nimport type { Tag } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport type { FieldRenderProps } from 'react-final-form';\nimport { tagsService } from 'src/services/tagsService';\nimport { FieldContainer } from '../Form/FieldContainer';\n\n// we include props from react-final-form fields so it can be used as a custom field component\nexport interface IProps extends Partial<FieldRenderProps<any, any>> {\n  isForm?: boolean;\n  value: number[];\n  onChange: (val: number[]) => void;\n  styleVariant?: 'selector' | 'filter';\n  placeholder?: string;\n  maxTotal?: number;\n}\n\nexport const TagsSelect = (props: IProps) => {\n  const [allTags, setAllTags] = useState<Tag[]>([]);\n  const [selectedTags, setSelectedTags] = useState<Tag[]>([]);\n\n  useEffect(() => {\n    const initTags = async () => {\n      const tags = await tagsService.getAllTags();\n      if (!tags) {\n        return;\n      }\n\n      setAllTags(tags);\n    };\n\n    initTags();\n  }, []);\n\n  useEffect(() => {\n    if (allTags.length > 0 && props.value.length > 0) {\n      setSelectedTags(allTags.filter((x) => props.value.includes(x.id)));\n    }\n  }, [props.value, allTags]);\n\n  const onChange = (tags: Tag[]) => {\n    setSelectedTags(tags);\n    props.onChange(tags.map((x) => x.id));\n  };\n\n  const isOptionDisabled = () => selectedTags.length >= (props.maxTotal || 4);\n\n  return (\n    <FieldContainer data-cy=\"tag-select\">\n      <Select\n        variant={props.isForm ? 'form' : undefined}\n        options={allTags}\n        placeholder={props.placeholder}\n        isClearable={true}\n        isOptionDisabled={isOptionDisabled}\n        isMulti={true}\n        value={selectedTags}\n        getOptionLabel={(tag: Tag) => tag.name}\n        getOptionValue={(tag: Tag) => tag.id}\n        onChange={onChange}\n      />\n    </FieldContainer>\n  );\n};\n"
  },
  {
    "path": "src/common/Toast/CustomToast.tsx",
    "content": "import { Icon } from 'oa-components';\nimport { commonStyles } from 'oa-themes';\nimport { Link } from 'react-router';\nimport type { ExternalToast } from 'sonner';\nimport { toast } from 'sonner';\nimport { Box, Button, Flex, Text } from 'theme-ui';\nimport Spinner from '../Spinner';\n\nexport interface CustomToastProps {\n  message: string;\n  description?: string;\n  type: 'default' | 'success' | 'info' | 'warning' | 'error' | 'loading';\n  actionLink?: {\n    href: string;\n    label: string;\n  };\n  actionButton?: {\n    label: string;\n    callback: () => void;\n  };\n  toastId?: string | number;\n}\n\nconst getBorderColor = (type: CustomToastProps['type']) => {\n  switch (type) {\n    case 'success':\n      return commonStyles.colors.green;\n    case 'error':\n      return commonStyles.colors.red;\n    case 'warning':\n      return commonStyles.colors.activeYellow;\n    case 'info':\n      return commonStyles.colors.blue;\n    case 'loading':\n    default:\n      return commonStyles.colors.grey;\n  }\n};\n\nexport const CustomToast = ({\n  message,\n  description,\n  type,\n  actionLink,\n  actionButton,\n  toastId,\n}: CustomToastProps) => {\n  const handleClose = () => {\n    if (toastId) {\n      toast.dismiss(toastId);\n    }\n  };\n\n  return (\n    <Box\n      sx={{\n        border: '2px solid',\n        borderRadius: 1,\n        backgroundColor: commonStyles.colors.white,\n      }}\n      data-cy=\"toast\"\n    >\n      <Flex\n        sx={{\n          alignItems: 'center',\n          gap: 3,\n          padding: 3,\n          boxShadow: '0 4px 12px rgba(0, 0, 0, 0.25)',\n          borderLeft: `6px solid ${getBorderColor(type)}`,\n          borderRadius: '3px',\n        }}\n      >\n        <Flex sx={{ alignItems: 'center', flex: 1, minWidth: 0, gap: 2 }}>\n          {type === 'loading' && <Spinner />}\n          {type !== 'default' && type !== 'loading' && <Icon size={24} glyph={type} />}\n          <Flex sx={{ flexDirection: 'column' }}>\n            <Text\n              data-cy=\"toast-message\"\n              sx={{\n                fontWeight: 600,\n                color: 'black',\n              }}\n            >\n              {message}\n            </Text>\n            {description && (\n              <Text\n                data-cy=\"toast-description\"\n                sx={{\n                  fontSize: 2,\n                  color: 'grey',\n                }}\n              >\n                {description}\n              </Text>\n            )}\n          </Flex>\n        </Flex>\n        {actionLink && (\n          <Link\n            to={actionLink.href}\n            onClick={handleClose}\n            data-cy=\"toast-action-link\"\n            style={{ fontWeight: 500, color: 'black', textDecoration: 'underline' }}\n          >\n            {actionLink.label}\n          </Link>\n        )}\n        {actionButton && (\n          <Button\n            onClick={() => {\n              actionButton.callback();\n              handleClose();\n            }}\n            data-cy=\"toast-action-button\"\n          >\n            {actionButton.label}\n          </Button>\n        )}\n        {!actionLink && !actionButton && (\n          <Box\n            onClick={handleClose}\n            sx={{\n              cursor: 'pointer',\n            }}\n          >\n            <Icon glyph=\"close\" size={20} />\n          </Box>\n        )}\n      </Flex>\n    </Box>\n  );\n};\n\nexport const createToastOptions = (\n  type: 'default' | 'success' | 'info' | 'warning' | 'error',\n): ExternalToast => ({\n  duration: type === 'error' ? 6000 : 4000,\n  unstyled: true,\n});\n"
  },
  {
    "path": "src/common/Toast/index.tsx",
    "content": "export { useToast } from './useToast';\n"
  },
  {
    "path": "src/common/Toast/useToast.tsx",
    "content": "import { toast as sonnerToast } from 'sonner';\nimport { CustomToast, type CustomToastProps, createToastOptions } from './CustomToast';\n\ninterface ToastOptions\n  extends Pick<CustomToastProps, 'description' | 'actionLink' | 'actionButton'> {\n  duration?: number;\n}\n\ninterface PromiseOptions<T>\n  extends Pick<CustomToastProps, 'description' | 'actionLink' | 'actionButton'> {\n  loading: string;\n  success:\n    | string\n    | ((data: T) => string)\n    | ((data: T) => {\n        message: string;\n        description?: string;\n        actionLink?: CustomToastProps['actionLink'];\n        actionButton?: CustomToastProps['actionButton'];\n      });\n  error: string | ((error: any) => string);\n  duration?: number;\n}\n\nexport const useToast = () => {\n  const toast = {\n    default: (message: string, options?: ToastOptions) => {\n      const baseOptions = createToastOptions('default');\n      return sonnerToast.custom(\n        (toastId) => (\n          <CustomToast\n            message={message}\n            description={options?.description}\n            type=\"default\"\n            actionLink={options?.actionLink}\n            actionButton={options?.actionButton}\n            toastId={toastId}\n          />\n        ),\n        {\n          duration: options?.duration ?? baseOptions.duration,\n          unstyled: true,\n        },\n      );\n    },\n\n    success: (message: string, options?: ToastOptions) => {\n      const baseOptions = createToastOptions('success');\n      return sonnerToast.custom(\n        (toastId) => (\n          <CustomToast\n            message={message}\n            description={options?.description}\n            type=\"success\"\n            actionLink={options?.actionLink}\n            actionButton={options?.actionButton}\n            toastId={toastId}\n          />\n        ),\n        {\n          duration: options?.duration ?? baseOptions.duration,\n          unstyled: true,\n        },\n      );\n    },\n\n    info: (message: string, options?: ToastOptions) => {\n      const baseOptions = createToastOptions('info');\n      return sonnerToast.custom(\n        (toastId) => (\n          <CustomToast\n            message={message}\n            description={options?.description}\n            type=\"info\"\n            actionLink={options?.actionLink}\n            actionButton={options?.actionButton}\n            toastId={toastId}\n          />\n        ),\n        {\n          duration: options?.duration ?? baseOptions.duration,\n          unstyled: true,\n        },\n      );\n    },\n\n    warning: (message: string, options?: ToastOptions) => {\n      const baseOptions = createToastOptions('warning');\n      return sonnerToast.custom(\n        (toastId) => (\n          <CustomToast\n            message={message}\n            description={options?.description}\n            type=\"warning\"\n            actionLink={options?.actionLink}\n            actionButton={options?.actionButton}\n            toastId={toastId}\n          />\n        ),\n        {\n          duration: options?.duration ?? baseOptions.duration,\n          unstyled: true,\n        },\n      );\n    },\n\n    error: (message: string, options?: ToastOptions) => {\n      const baseOptions = createToastOptions('error');\n      return sonnerToast.custom(\n        (toastId) => (\n          <CustomToast\n            message={message}\n            description={options?.description}\n            type=\"error\"\n            actionLink={options?.actionLink}\n            actionButton={options?.actionButton}\n            toastId={toastId}\n          />\n        ),\n        {\n          duration: options?.duration ?? baseOptions.duration,\n          unstyled: true,\n        },\n      );\n    },\n\n    promise: <T,>(promise: Promise<T>, options: PromiseOptions<T>) => {\n      // Use custom toast to get access to toastId\n      let toastId: string | number;\n\n      toastId = sonnerToast.custom(\n        (id) => (\n          <CustomToast\n            message={options.loading}\n            description={options.description}\n            type=\"loading\"\n            toastId={id}\n          />\n        ),\n        {\n          duration: Infinity, // Keep loading toast until promise resolves\n          unstyled: true,\n        },\n      );\n\n      promise\n        .then((data: T) => {\n          const result =\n            typeof options.success === 'function' ? options.success(data) : options.success;\n\n          let message: string;\n          let description: string | undefined;\n          let actionLink = options.actionLink;\n          let actionButton = options.actionButton;\n\n          if (typeof result === 'object') {\n            message = result.message;\n            description = result.description;\n            actionLink = result.actionLink ?? actionLink;\n            actionButton = result.actionButton ?? actionButton;\n          } else {\n            message = result;\n          }\n\n          // Dismiss loading toast and show success\n          sonnerToast.dismiss(toastId);\n          sonnerToast.custom(\n            (id) => (\n              <CustomToast\n                message={message}\n                description={description}\n                type=\"success\"\n                actionLink={actionLink}\n                actionButton={actionButton}\n                toastId={id}\n              />\n            ),\n            {\n              duration: options.duration ?? 4000,\n              unstyled: true,\n            },\n          );\n        })\n        .catch((error) => {\n          const message =\n            typeof options.error === 'function' ? options.error(error) : options.error;\n\n          // Dismiss loading toast and show error\n          sonnerToast.dismiss(toastId);\n          sonnerToast.custom(\n            (id) => (\n              <CustomToast\n                message={message}\n                description={options.description}\n                type=\"error\"\n                toastId={id}\n              />\n            ),\n            {\n              duration: options.duration ?? 6000,\n              unstyled: true,\n            },\n          );\n        });\n\n      return toastId;\n    },\n\n    dismiss: (toastId?: string | number) => {\n      sonnerToast.dismiss(toastId);\n    },\n  };\n\n  return toast;\n};\n"
  },
  {
    "path": "src/common/UserAction.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport { factoryImage, FactoryUser } from 'src/test/factories/User';\nimport { afterEach, describe, it, vi } from 'vitest';\n\nimport { UserAction } from './UserAction';\n\nimport type { ProfileType } from 'oa-shared';\n\nconst mockUseProfileStore = vi.hoisted(() => vi.fn());\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: mockUseProfileStore,\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nconst incompleteProfile = <>incompleteProfile</>;\nconst loggedIn = <>LoggedIn</>;\nconst loggedOut = <>loggedOut</>;\n\ndescribe('UserAction', () => {\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  it('should render the incompleteProfile component when a user is logged in but profile incomplete', async () => {\n    const mockUser = FactoryUser();\n    mockUseProfileStore.mockReturnValue({\n      profile: mockUser,\n      update: vi.fn(),\n      isComplete: false,\n    });\n\n    render(\n      <UserAction\n        incompleteProfile={incompleteProfile}\n        loggedIn={loggedIn}\n        loggedOut={loggedOut}\n      />,\n    );\n    await screen.findByText('incompleteProfile', { exact: false });\n  });\n\n  it('should render the loggedIn component when a user is logged in and profile complete', async () => {\n    const completeUser = FactoryUser({\n      about: 'about',\n      type: { id: 1, name: 'member' } as ProfileType,\n      photo: factoryImage,\n    });\n    mockUseProfileStore.mockReturnValue({\n      profile: completeUser,\n      update: vi.fn(),\n      isComplete: true,\n    });\n\n    render(\n      <UserAction\n        incompleteProfile={incompleteProfile}\n        loggedIn={loggedIn}\n        loggedOut={loggedOut}\n      />,\n    );\n    await screen.findByText('loggedIn', { exact: false });\n  });\n\n  it('should render the loggedOut component when a user is not logged in', async () => {\n    mockUseProfileStore.mockReturnValue({\n      profile: null,\n      update: vi.fn(),\n    });\n    render(<UserAction loggedIn={loggedIn} loggedOut={loggedOut} />);\n\n    await screen.findByText('loggedOut');\n  });\n});\n"
  },
  {
    "path": "src/common/UserAction.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\n\ninterface IProps {\n  incompleteProfile?: React.ReactNode | null;\n  loggedIn: React.ReactNode;\n  loggedOut: React.ReactNode | null;\n}\n\nexport const UserAction = observer((props: IProps) => {\n  const { incompleteProfile, loggedIn, loggedOut } = props;\n  const { isComplete, profile } = useProfileStore();\n\n  if (!profile) {\n    return loggedOut;\n  }\n\n  if (isComplete === false) {\n    return incompleteProfile || loggedIn;\n  }\n\n  return loggedIn;\n});\n"
  },
  {
    "path": "src/common/hooks/useClickOutside.ts",
    "content": "import { useEffect, useRef } from 'react';\n\nexport const useClickOutside = (callback: () => void) => {\n  const ref = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (ref.current && !ref.current.contains(event.target as Node)) {\n        callback();\n      }\n    };\n\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, [callback]);\n\n  return ref;\n};\n"
  },
  {
    "path": "src/config/config.ts",
    "content": "/*************************************************************************************** \nSwitch config dependent on use case\n\nFor our use case the production config is stored in environment variables passed from\nCI. You can replace this with your own config or use the same pattern to keep\napi keys secret. Note, create-react-app only passes environment variables prefixed with\n'REACT_APP'. The required info has been encrypted and stored in a circleCI deployment context.\n\n*****************************************************************************************/\n\nimport type { ConfigurationOption } from './constants';\nimport type { ISentryConfig, siteVariants } from './types';\n\n/**\n * Helper function to load configuration property\n * from the global configuration object\n *\n * @param property\n * @param fallbackValue - optional fallback value\n * @returns string\n */\nconst _c = (property: ConfigurationOption, fallbackValue?: string): string => {\n  return import.meta.env?.[property] || fallbackValue || '';\n};\n\nexport const getConfigurationOption = _c;\n\n/*********************************************************************************************** /\n                                        Site Variants\n/********************************************************************************************** */\n\n// On dev sites user can override default role\n\nconst getSiteVariant = (): siteVariants => {\n  if (\n    (typeof location !== 'undefined' && location.host === 'localhost:3456') ||\n    _c('VITE_SITE_VARIANT') === 'test-ci'\n  ) {\n    return 'test-ci';\n  }\n  if (_c('VITE_SITE_VARIANT') === 'preview') {\n    return 'preview';\n  }\n  switch (_c('VITE_BRANCH')) {\n    case 'production':\n      return 'production';\n    case 'master':\n      return 'staging';\n    default:\n      return 'dev_site';\n  }\n};\n\nconst siteVariant = getSiteVariant();\n\nexport const isProductionEnvironment = (): boolean => {\n  const site = getSiteVariant();\n  return site === 'production';\n};\n\nexport const SITE = siteVariant;\nexport const SENTRY_CONFIG: ISentryConfig = {\n  dsn: _c('VITE_SENTRY_DSN', 'https://8c1f7eb4892e48b18956af087bdfa3ac@sentry.io/1399729'),\n  environment: siteVariant,\n};\n"
  },
  {
    "path": "src/config/constants.ts",
    "content": "/**\n * This export is intended for use _only_ within the\n * build process. If you're looking to work with\n * the configuration options please use the `ConfigurationOption`\n * type exported from this file\n */\nexport const _supportedConfigurationOptions = [\n  'VITE_SENTRY_DSN',\n  'VITE_BRANCH',\n  'VITE_SITE_VARIANT',\n] as const;\n\nexport type ConfigurationOption = (typeof _supportedConfigurationOptions)[number];\n"
  },
  {
    "path": "src/config/default.json",
    "content": "{\n  \"foo\": \"bar\"\n}\n"
  },
  {
    "path": "src/config/imageTransforms.ts",
    "content": "import type { TransformOptions } from '@supabase/storage-js';\n\nexport const IMAGE_SIZES: { [key: string]: TransformOptions } = {\n  LANDSCAPE: {\n    width: 1280,\n    resize: 'contain',\n  },\n  GALLERY: {\n    width: 956,\n    resize: 'contain',\n  },\n  LIST: {\n    width: 478,\n    resize: 'contain',\n  },\n};\n"
  },
  {
    "path": "src/config/types.ts",
    "content": "export interface ISentryConfig {\n  dsn: string;\n  environment: string;\n}\n\nexport type siteVariants = 'dev_site' | 'test-ci' | 'staging' | 'production' | 'preview';\n"
  },
  {
    "path": "src/constants.ts",
    "content": "export const MAX_COMMENT_LENGTH = 3000;\nexport const DISCORD_INVITE_URL = 'https://discord.gg/gJ7Yyk4';\n"
  },
  {
    "path": "src/entry.client.tsx",
    "content": "import { CacheProvider } from '@emotion/react';\nimport * as Sentry from '@sentry/react-router';\nimport { startTransition, useMemo, useState } from 'react';\nimport { hydrateRoot } from 'react-dom/client';\nimport { HydratedRouter } from 'react-router/dom';\nimport { SENTRY_CONFIG } from './config/config';\nimport { ClientStyleContext } from './styles/context';\nimport { createEmotionCache } from './styles/createEmotionCache';\n\nSentry.init({ ...SENTRY_CONFIG });\n\nconst ClientCacheProvider = ({ children }) => {\n  const [cache, setCache] = useState(createEmotionCache());\n\n  const clientStyleContextValue = useMemo(\n    () => ({\n      reset() {\n        setCache(createEmotionCache());\n      },\n    }),\n    [],\n  );\n\n  return (\n    <ClientStyleContext.Provider value={clientStyleContextValue}>\n      <CacheProvider value={cache}>{children}</CacheProvider>\n    </ClientStyleContext.Provider>\n  );\n};\n\nstartTransition(() => {\n  hydrateRoot(\n    document,\n    <ClientCacheProvider>\n      <HydratedRouter />\n    </ClientCacheProvider>,\n  );\n});\n\nif ('serviceWorker' in navigator && import.meta.env.PROD) {\n  (async () => {\n    try {\n      const { Workbox } = await import('workbox-window');\n      const wb = new Workbox('/sw.js');\n\n      wb.addEventListener('installed', (event) => {\n        if (event.isUpdate) {\n          // TODO: show toast\n          console.log('New version available! Refresh to update.');\n        }\n      });\n\n      await wb.register();\n    } catch (error) {\n      console.debug('Service worker registration skipped:', error.message);\n    }\n  })();\n}\n"
  },
  {
    "path": "src/entry.server.tsx",
    "content": "import { CacheProvider } from '@emotion/react';\nimport createEmotionServer from '@emotion/server/create-instance';\nimport * as Sentry from '@sentry/react-router';\nimport { isbot } from 'isbot';\nimport { renderToReadableStream } from 'react-dom/server';\nimport type { EntryContext } from 'react-router';\nimport { ServerRouter } from 'react-router';\nimport { SENTRY_CONFIG } from './config/config';\nimport { createEmotionCache } from './styles/createEmotionCache';\n\nconst ABORT_DELAY = 5_000;\n\nSentry.init({ ...SENTRY_CONFIG });\n\nexport default function handleRequest(\n  request: Request,\n  responseStatusCode: number,\n  responseHeaders: Headers,\n  context: EntryContext,\n) {\n  return isbot(request.headers.get('user-agent') || '')\n    ? handleBotRequest(request, responseStatusCode, responseHeaders, context)\n    : handleBrowserRequest(request, responseStatusCode, responseHeaders, context);\n}\n\nasync function handleBotRequest(\n  request: Request,\n  responseStatusCode: number,\n  responseHeaders: Headers,\n  context: EntryContext,\n) {\n  const controller = new AbortController();\n  const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);\n\n  try {\n    const stream = await renderToReadableStream(\n      <ServerRouter context={context} url={request.url} />,\n      {\n        signal: controller.signal,\n        onError(error: unknown) {\n          console.error(error);\n          responseStatusCode = 500;\n        },\n      },\n    );\n\n    await stream.allReady;\n    clearTimeout(timeoutId);\n\n    responseHeaders.set('Content-Type', 'text/html');\n\n    return new Response(stream, {\n      headers: responseHeaders,\n      status: responseStatusCode,\n    });\n  } catch (error) {\n    clearTimeout(timeoutId);\n    throw error;\n  }\n}\n\nasync function handleBrowserRequest(\n  request: Request,\n  responseStatusCode: number,\n  responseHeaders: Headers,\n  context: EntryContext,\n) {\n  const controller = new AbortController();\n  const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);\n\n  try {\n    const cache = createEmotionCache();\n    const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache);\n\n    const stream = await renderToReadableStream(\n      <CacheProvider value={cache}>\n        <ServerRouter context={context} url={request.url} />\n      </CacheProvider>,\n      {\n        signal: controller.signal,\n        onError(error: unknown) {\n          console.error(error);\n          Sentry.captureException(error);\n          if (responseStatusCode === 200) {\n            responseStatusCode = 500;\n          }\n        },\n      },\n    );\n\n    // Wait for the full shell to be ready before style extraction\n    await stream.allReady;\n    clearTimeout(timeoutId);\n\n    // Read the full HTML string from the stream\n    const html = await new Response(stream).text();\n\n    // Extract critical CSS and inject into <head>\n    const chunks = extractCriticalToChunks(html);\n    const styleTags = constructStyleTagsFromChunks(chunks);\n    const htmlWithStyles = html.replace('<head>', `<head>${styleTags}`);\n\n    responseHeaders.set('Content-Type', 'text/html');\n\n    return new Response(htmlWithStyles, {\n      headers: responseHeaders,\n      status: responseStatusCode,\n    });\n  } catch (error) {\n    clearTimeout(timeoutId);\n    throw error;\n  }\n}\n"
  },
  {
    "path": "src/factories/commentFactory.server.ts",
    "content": "import type { DBAuthor, DBComment, Reply } from 'oa-shared';\nimport { Author, Comment } from 'oa-shared';\nimport type { ImageServiceServer } from 'src/services/imageService.server';\n\nexport class CommentFactory {\n  constructor(private imageService: ImageServiceServer) {}\n\n  async fromDBWithAuthor(dbComment: DBComment, replies?: Reply[]): Promise<Comment> {\n    const author = dbComment.profile ? await this.createAuthor(dbComment.profile) : null;\n\n    return new Comment({\n      id: dbComment.id,\n      createdAt: new Date(dbComment.created_at),\n      createdBy: author,\n      modifiedAt: dbComment.modified_at ? new Date(dbComment.modified_at) : null,\n      comment: dbComment.comment,\n      sourceId: dbComment.source_id || dbComment.source_id_legacy || 0,\n      sourceType: dbComment.source_type,\n      parentId: dbComment.parent_id,\n      deleted: dbComment.deleted,\n      voteCount: dbComment.vote_count || 0,\n      hasVoted: dbComment.has_voted || false,\n      replies: replies,\n    });\n  }\n\n  async fromDBCommentsToThreads(dbComments: DBComment[]): Promise<Comment[]> {\n    const commentsByParentId = this.groupCommentsByParentId(dbComments);\n\n    const mainComments = commentsByParentId[0] ?? [];\n\n    const commentsWithReplies = await Promise.all(\n      mainComments.map(async (mainComment: DBComment) => {\n        const replyDBComments = commentsByParentId[mainComment.id] ?? [];\n\n        const replies: Reply[] = await Promise.all(\n          replyDBComments.map((reply: DBComment) => this.fromDBWithAuthor(reply, [])),\n        );\n\n        const filteredReplies = replies\n          .filter((reply) => !reply.deleted)\n          .sort((a, b) => a.id - b.id);\n\n        return this.fromDBWithAuthor(mainComment, filteredReplies);\n      }),\n    );\n\n    return commentsWithReplies.filter(\n      (comment: Comment) => !comment.deleted || (comment.replies?.length || 0) > 0,\n    );\n  }\n\n  private groupCommentsByParentId(dbComments: DBComment[]): Record<number, DBComment[]> {\n    return dbComments.reduce<Record<number, DBComment[]>>((acc, comment) => {\n      const parentId = comment.parent_id ?? 0;\n      if (!acc[parentId]) {\n        acc[parentId] = [];\n      }\n      acc[parentId].push(comment);\n      return acc;\n    }, {});\n  }\n\n  async createAuthor(dbAuthor: DBAuthor): Promise<Author> {\n    const photo = dbAuthor.photo ? this.imageService.getPublicUrl(dbAuthor.photo) : undefined;\n\n    return Author.fromDB(dbAuthor, photo);\n  }\n}\n"
  },
  {
    "path": "src/factories/mapPinFactory.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBMapPin, DBPinProfile, PinProfile } from 'oa-shared';\nimport { MapPin, ProfileBadge, ProfileTag, ProfileType } from 'oa-shared';\nimport { ImageServiceServer } from 'src/services/imageService.server';\n\nexport class MapPinFactory {\n  private imageService: ImageServiceServer;\n  constructor(client: SupabaseClient) {\n    this.imageService = new ImageServiceServer(client);\n  }\n\n  fromDBWithProfile(pin: DBMapPin): MapPin {\n    const profile = this.getProfilePin(pin.profile);\n\n    return new MapPin({\n      id: pin.id,\n      administrative: pin.administrative,\n      name: pin.name,\n      country: pin.country,\n      countryCode: pin.country_code,\n      lat: pin.lat,\n      lng: pin.lng,\n      moderation: pin.moderation,\n      postCode: pin.post_code,\n      profileId: pin.profile_id,\n      moderationFeedback: pin.moderation_feedback,\n      profile,\n    });\n  }\n\n  private getProfilePin(profile: DBPinProfile): PinProfile {\n    const photo = profile.photo ? this.imageService.getPublicUrl(profile.photo) : null;\n    const coverImages = profile.cover_images\n      ? profile.cover_images.map((image) => this.imageService.getPublicUrl(image))\n      : null;\n\n    return {\n      id: profile.id,\n      about: profile.about,\n      username: profile.username,\n      country: profile.country,\n      coverImages,\n      displayName: profile.display_name,\n      visitorPolicy: profile.visitor_policy,\n      type: profile.type ? ProfileType.fromDB(profile.type) : null,\n      photo: photo || null,\n      lastActive: profile.last_active,\n      isContactable: profile.is_contactable,\n      tags: profile.tags?.map((x) => ProfileTag.fromDBJoin(x)),\n      badges: profile.badges?.map((x) => ProfileBadge.fromDBJoin(x)),\n    } as PinProfile;\n  }\n}\n"
  },
  {
    "path": "src/factories/profileFactory.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { AuthorVotes, DBProfile } from 'oa-shared';\nimport { Profile, ProfileBadge, ProfileTag, ProfileType } from 'oa-shared';\nimport { ImageServiceServer } from 'src/services/imageService.server';\n\nexport class ProfileFactory {\n  private imageService: ImageServiceServer;\n  constructor(client: SupabaseClient) {\n    this.imageService = new ImageServiceServer(client);\n  }\n\n  fromDB(dbProfile: DBProfile, authorVotes?: AuthorVotes[]): Profile {\n    const photo = this.imageService.getPublicUrl(dbProfile.photo);\n\n    const coverImages =\n      dbProfile.cover_images && dbProfile.cover_images.length > 0\n        ? this.imageService.getPublicUrls(dbProfile.cover_images)\n        : null;\n\n    let impact = null;\n    let visitorPolicy = null;\n\n    try {\n      if (dbProfile.impact) {\n        if (typeof dbProfile.impact === 'string') {\n          impact = JSON.parse(dbProfile.impact);\n        } else if (typeof dbProfile.impact === 'object') {\n          impact = dbProfile.impact;\n        }\n      }\n    } catch (_) {\n      console.error('error parsing impact');\n    }\n\n    try {\n      if (dbProfile.visitor_policy) {\n        if (typeof dbProfile.visitor_policy === 'string') {\n          visitorPolicy = JSON.parse(dbProfile.visitor_policy);\n        } else if (\n          typeof dbProfile.impact === 'object' &&\n          (dbProfile.visitor_policy as any)?.policy\n        ) {\n          visitorPolicy = dbProfile.visitor_policy;\n        }\n      }\n    } catch (_) {\n      console.error('error parsing visitor policy');\n    }\n\n    return new Profile({\n      id: dbProfile.id,\n      createdAt: new Date(dbProfile.created_at),\n      country: dbProfile.country,\n      displayName: dbProfile.display_name,\n      username: dbProfile.username,\n      photo: dbProfile.photo && photo ? { ...dbProfile.photo, ...photo } : null,\n      roles: dbProfile.roles || null,\n      type: dbProfile.type ? ProfileType.fromDB(dbProfile.type) : null,\n      visitorPolicy,\n      isBlockedFromMessaging: !!dbProfile.is_blocked_from_messaging,\n      about: dbProfile.about,\n      coverImages:\n        dbProfile.cover_images && coverImages\n          ? dbProfile.cover_images\n              .map((dbImage) => {\n                const publicImage = coverImages.find((img) => img.id === dbImage.id);\n                return publicImage ? { ...dbImage, ...publicImage } : null;\n              })\n              .filter((img) => img !== null)\n          : null,\n      impact,\n      isContactable: !!dbProfile.is_contactable,\n      lastActive: dbProfile.last_active ? new Date(dbProfile.last_active) : null,\n      website: dbProfile.website,\n      patreon: dbProfile.patreon || null,\n      totalViews: dbProfile.total_views,\n      authorUsefulVotes: authorVotes,\n      donationsEnabled: !!dbProfile.donations_enabled,\n      tags: dbProfile.tags ? dbProfile.tags?.map((x) => ProfileTag.fromDBJoin(x)) : [],\n      badges: dbProfile.badges ? dbProfile.badges?.map((x) => ProfileBadge.fromDBJoin(x)) : [],\n    });\n  }\n}\n"
  },
  {
    "path": "src/logger/index.test.ts",
    "content": "import { describe, it } from 'vitest';\n\nimport { logger } from './index';\n\ndescribe('logger', () => {\n  it('should support all log level methods', () => {\n    ['debug', 'info', 'warn', 'error', 'fatal'].forEach((level) => {\n      logger[level]('test');\n    });\n  });\n});\n"
  },
  {
    "path": "src/logger/index.ts",
    "content": "import { Logger } from 'tslog';\n\nconst levelNumberToNameMap = {\n  silly: 0,\n  trace: 1,\n  debug: 2,\n  info: 3,\n  warn: 4,\n  error: 5,\n  fatal: 6,\n};\n\nexport const logger = new Logger({\n  type: 'pretty',\n  minLevel: process.env.NODE_ENV === 'test' ? 999 : levelNumberToNameMap['info'],\n  hideLogPositionForProduction: true,\n});\n"
  },
  {
    "path": "src/modules/index.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { getSupportedModules, isModuleSupported, MODULE } from './index';\n\ndescribe('getSupportedModules', () => {\n  it('returns a default set of modules', () => {\n    expect(getSupportedModules('')).toStrictEqual([\n      MODULE.LIBRARY,\n      MODULE.MAP,\n      MODULE.RESEARCH,\n      MODULE.ACADEMY,\n      MODULE.QUESTIONS,\n      MODULE.NEWS,\n    ]);\n  });\n\n  it('loads an additional module based on env configuration', () => {\n    expect(getSupportedModules(MODULE.LIBRARY)).toStrictEqual([MODULE.LIBRARY]);\n  });\n\n  it('loads multiple modules based on env configuration', () => {\n    expect(getSupportedModules(`${MODULE.LIBRARY},${MODULE.ACADEMY}`)).toStrictEqual([MODULE.LIBRARY, MODULE.ACADEMY]);\n  });\n\n  it('ignores a malformed module definitions', () => {\n    expect(getSupportedModules(`fake module,${MODULE.LIBRARY},malicious `)).toStrictEqual([MODULE.LIBRARY]);\n  });\n});\n\ndescribe('isModuleSupported', () => {\n  it('returns true for module enabled via env', () => {\n    expect(isModuleSupported(`${MODULE.RESEARCH}`, MODULE.RESEARCH)).toBe(true);\n  });\n\n  it('returns false for unsupported module', () => {\n    expect(isModuleSupported(`${MODULE.LIBRARY}`, MODULE.RESEARCH)).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/modules/index.ts",
    "content": "export enum MODULE {\n  LIBRARY = 'library',\n  MAP = 'map',\n  RESEARCH = 'research',\n  ACADEMY = 'academy',\n  QUESTIONS = 'questions',\n  NEWS = 'news',\n}\n\nexport const getSupportedModules = (supportedModules: string): MODULE[] => {\n  const envModules: string[] =\n    (supportedModules || 'library,map,research,academy,questions,news')\n      .split(',')\n      .map((s) => s.trim()) || [];\n  return Object.values(MODULE).filter((module) => envModules.includes(module));\n};\n\nexport const isModuleSupported = (supportedModules: string, MODULE: MODULE): boolean => {\n  const supported = getSupportedModules(supportedModules);\n  return supported.includes(MODULE);\n};\n"
  },
  {
    "path": "src/pages/Academy/Academy.test.tsx",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { getFrameSrc } from './Academy';\n\ndescribe('getFrameSrc', () => {\n  const basePath = `https://example.com/`;\n\n  it.each([\n    ['/', 'https://example.com/'],\n    ['/academy/', 'https://example.com/'],\n    ['/academy/path', 'https://example.com/path'],\n  ])('formats a URL correctly', (path, expected) => {\n    expect(getFrameSrc(basePath, path)).toBe(expected);\n  });\n});\n"
  },
  {
    "path": "src/pages/Academy/Academy.tsx",
    "content": "import { useContext, useEffect, useState } from 'react';\nimport { useLocation } from 'react-router';\nimport ExternalEmbed from 'src/pages/Academy/ExternalEmbed/ExternalEmbed';\nimport { TenantContext } from '../common/TenantContext';\n\nexport const getFrameSrc = (base: string, path: string): string =>\n  `${base}${path\n    .split('/')\n    .filter((str) => str !== 'academy' && Boolean(str))\n    .join('/')}`;\n\nconst Academy = () => {\n  const location = useLocation();\n  const tenantContext = useContext(TenantContext);\n\n  const [initial, setInitial] = useState<string>(tenantContext?.academyResource || '');\n\n  useEffect(() => {\n    // set initial only once\n    const newInitial = getFrameSrc(tenantContext?.academyResource || '', location.pathname);\n\n    if (newInitial !== initial) {\n      setInitial(newInitial);\n    }\n  }, []);\n\n  return <ExternalEmbed src={initial} />;\n};\n\nexport default Academy;\n"
  },
  {
    "path": "src/pages/Academy/ExternalEmbed/ExternalEmbed.tsx",
    "content": "import { useEffect } from 'react';\nimport { useNavigate } from 'react-router';\n\n/*************************************************************************************\n *  Embed an Iframe\n *\n *  NOTE - it is designed to only set the src on initial mount (not on prop change).\n *  This is so that postmessages can be used to communicate nav changes from the iframe\n *  up to the parent component, update the parent url and avoid double-refresh of the\n *  page.\n *************************************************************************************/\n\ninterface IProps {\n  src: string;\n}\n\nconst ExternalEmbed = ({ src }: IProps) => {\n  const navigate = useNavigate();\n\n  useEffect(() => {\n    // TODO - possible compatibility fallback for addEventListener (IE8)\n    // Example: https://davidwalsh.name/window-iframe\n    window.addEventListener('message', handlePostmessageFromIframe, false);\n\n    return () => {\n      window.removeEventListener('message', handlePostmessageFromIframe, false);\n    };\n  }, []);\n\n  /**\n   * Custom method to allow communication from Iframe to parent via postmessage\n   */\n  const handlePostmessageFromIframe = (e: MessageEvent) => {\n    // only allow messages from specific sites (academy dev and live)\n    if ([targetOrigin].includes(e.origin)) {\n      // communicate url changes, update navbar\n      if (e.data && e.data.pathname) {\n        let newPathName = e.data.pathname;\n\n        /**\n         * At the moment this component is only used for handling contents within the `/academy`\n         * section of the site. If we want to use this elsewhere this should lifted outside of the component\n         * perhaps moved to an emitted event\n         */\n        if (!newPathName.startsWith(`/academy`)) {\n          newPathName = `/academy${newPathName}`;\n        }\n        navigate(newPathName, { viewTransition: true });\n      }\n      // communicate a href link clicks, open link in new tab\n      if (e.data && e.data.linkClick) {\n        window.open(e.data.linkClick, '_blank');\n      }\n    }\n  };\n\n  if (!src) {\n    return null;\n  }\n\n  const url = new URL(src);\n  const targetOrigin = url.protocol + '//' + url.hostname + (url.port ? ':' + url.port : '');\n\n  return (\n    <div\n      style={{\n        position: 'relative',\n        height: '100%',\n      }}\n    >\n      <iframe\n        src={src}\n        style={{\n          border: 0,\n          height: '100%',\n          width: '100%',\n        }}\n        title=\"precious plastic academy\"\n      />\n    </div>\n  );\n};\nexport default ExternalEmbed;\n"
  },
  {
    "path": "src/pages/Library/Content/Common/DeleteProjectButton.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Button, ConfirmModal } from 'oa-components';\nimport { useState } from 'react';\nimport { useNavigate } from 'react-router';\nimport { trackEvent } from 'src/common/Analytics';\nimport { useToast } from 'src/common/Toast';\nimport { libraryService } from '../../library.service';\n\ntype DeleteProjectButtonProps = {\n  id: number;\n};\n\nconst DeleteProjectButton = observer(({ id }: DeleteProjectButtonProps) => {\n  const toast = useToast();\n  const navigate = useNavigate();\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  const handleDelete = async () => {\n    setIsDeleting(true);\n    const promise = libraryService.deleteProject(id);\n\n    toast.promise(promise, {\n      loading: 'Deleting project...',\n      success: () => {\n        trackEvent({\n          category: 'projects',\n          action: 'deleted',\n          label: `Project ID: ${id}`,\n        });\n        navigate('/library');\n        setIsDeleting(false);\n        return {\n          message: `Project deleted!`,\n        };\n      },\n      error: (error) => {\n        console.error(error);\n        setIsDeleting(false);\n        return `Error: ${error.message}`;\n      },\n    });\n  };\n\n  return (\n    <>\n      <Button\n        type=\"button\"\n        data-cy=\"Project: delete button\"\n        variant=\"destructive\"\n        disabled={isDeleting}\n        sx={{ justifyContent: 'center' }}\n        onClick={() => setShowDeleteModal(true)}\n      >\n        Delete\n      </Button>\n\n      <ConfirmModal\n        key={id}\n        isOpen={showDeleteModal}\n        message=\"Are you sure you want to delete this Project?\"\n        confirmButtonText=\"Delete\"\n        handleCancel={() => setShowDeleteModal(false)}\n        handleConfirm={handleDelete}\n        confirmVariant=\"destructive\"\n      />\n    </>\n  );\n});\n\nexport default DeleteProjectButton;\n"
  },
  {
    "path": "src/pages/Library/Content/Common/FormSettings.tsx",
    "content": "import type { DifficultyLevel } from 'oa-shared';\n\nconst makeEntry = <T,>(value: T, label?: string) => {\n  return { value, label: label || value };\n};\n\nexport const TIME_OPTIONS = [\n  makeEntry<string>('< 1 hour'),\n  makeEntry<string>('< 5 hours'),\n  makeEntry<string>('< 1 day'),\n  makeEntry<string>('< 1 week'),\n  makeEntry<string>('1-2 weeks'),\n  makeEntry<string>('3-4 weeks'),\n  makeEntry<string>('1+ months'),\n];\n\nexport const DIFFICULTY_OPTIONS = [\n  makeEntry<DifficultyLevel>('easy', 'Easy'),\n  makeEntry<DifficultyLevel>('medium', 'Medium'),\n  makeEntry<DifficultyLevel>('hard', 'Hard'),\n  makeEntry<DifficultyLevel>('very-hard', 'Very Hard'),\n];\n"
  },
  {
    "path": "src/pages/Library/Content/Common/Library.form.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\nimport { createRoutesStub } from 'react-router';\nimport { act, cleanup, fireEvent, render } from '@testing-library/react';\nimport { ThemeProvider } from '@theme-ui/core';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\nimport { FactoryProjectFormData } from 'src/test/factories/Library';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { LibraryForm } from './LibraryForm';\nimport type { ProjectFormData, IMediaFile } from 'oa-shared';\nimport { theme } from 'oa-themes';\n\n// Mock timers to prevent async operations from running after tests\nbeforeEach(() => {\n  vi.useFakeTimers();\n});\n\nafterEach(() => {\n  // Clean up any pending timers\n  vi.runOnlyPendingTimers();\n  vi.useRealTimers();\n  cleanup();\n});\n\ndescribe('Library form', () => {\n  describe('Provides user information', () => {\n    it('shows maximum file size', () => {\n      // Arrange\n      const project = FactoryProjectFormData();\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = Wrapper(project);\n      });\n\n      // Assert\n      expect(wrapper.getByText('Maximum file size 50MB')).toBeInTheDocument();\n\n      // Clean up any pending timers before test completes\n      act(() => {\n        vi.runAllTimers();\n      });\n    });\n  });\n\n  describe('Invalid file warning', () => {\n    it('Does not appear when submitting only fileLink', () => {\n      // Arrange\n      const project = FactoryProjectFormData({\n        fileLink: 'www.test.com',\n        files: null,\n      });\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = Wrapper(project);\n      });\n\n      // Assert\n      expect(wrapper.queryByTestId('invalid-file-warning')).not.toBeInTheDocument();\n\n      // Clean up any pending timers before test completes\n      act(() => {\n        vi.runAllTimers();\n      });\n    });\n\n    it('Does not appear when submitting only files', () => {\n      // Arrange\n      const project = FactoryProjectFormData({\n        files: [\n          {\n            id: '123',\n            name: 'test-file.pdf',\n            size: 12345,\n          },\n        ],\n        fileLink: null,\n      });\n\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = Wrapper(project);\n      });\n\n      // Assert\n      expect(wrapper.queryByTestId('invalid-file-warning')).not.toBeInTheDocument();\n\n      // Clean up any pending timers before test completes\n      act(() => {\n        vi.runAllTimers();\n      });\n    });\n\n    it('Appears when submitting 2 file types', () => {\n      // Arrange\n      const project = FactoryProjectFormData({\n        files: [\n          {\n            id: '123',\n            name: 'test-file.pdf',\n            size: 12345,\n          },\n        ],\n        fileLink: 'www.test.com',\n      });\n\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = Wrapper(project);\n      });\n\n      // Assert\n      expect(wrapper.queryByTestId('invalid-file-warning')).toBeInTheDocument();\n\n      // Clean up any pending timers before test completes\n      act(() => {\n        vi.runAllTimers();\n      });\n    });\n\n    it('Does not appear when files are removed and filelink added', async () => {\n      // Arrange\n      const project = FactoryProjectFormData({\n        files: [\n          {\n            id: '123',\n            name: 'test-file.pdf',\n            size: 12345,\n          },\n        ],\n        fileLink: null,\n      });\n\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = Wrapper(project);\n      });\n\n      // clear files\n      expect(wrapper.queryByTestId('remove-file')).toBeInTheDocument();\n\n      const removeFileButton = wrapper.getByTestId('remove-file');\n      fireEvent.click(removeFileButton);\n\n      // add fileLink\n      const fileLink = wrapper.getByPlaceholderText('Link to Google Drive, Dropbox, Grabcad etc');\n      fireEvent.change(fileLink, {\n        target: { value: '<http://www.test.com>' },\n      });\n\n      // Assert\n      expect(wrapper.queryByTestId('invalid-file-warning')).not.toBeInTheDocument();\n\n      // Clean up any pending timers before test completes\n      act(() => {\n        vi.runAllTimers();\n      });\n    });\n  });\n});\n\nconst Wrapper = (project: ProjectFormData) => {\n  const ReactStub = createRoutesStub(\n    [\n      {\n        index: true,\n        Component: () => (\n          <ProfileStoreProvider>\n            <ThemeProvider theme={theme}>\n              <LibraryForm formData={project} id={null} />\n            </ThemeProvider>\n          </ProfileStoreProvider>\n        ),\n      },\n    ],\n    { initialEntries: ['/'] },\n  );\n\n  return render(<ReactStub />);\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryCategory.field.tsx",
    "content": "import type { Category, SelectValue } from 'oa-shared';\nimport { useEffect, useMemo, useState } from 'react';\nimport { Field } from 'react-final-form';\nimport { CategoriesSelectV2 } from 'src/pages/common/Category/CategoriesSelectV2';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { intro } from 'src/pages/Library/labels';\nimport { categoryService } from 'src/services/categoryService';\nimport { required } from 'src/utils/validators';\nimport { Box, Text } from 'theme-ui';\nimport { LibraryCategoryGuidance } from './LibraryCategoryGuidance';\n\nexport const LibraryCategoryField = () => {\n  const { placeholder, title } = intro.category;\n\n  const [categories, setCategories] = useState<Category[]>([]);\n  const options = useMemo<SelectValue[]>(\n    () => categories.map((x) => ({ label: x.name, value: x.id.toString() })),\n    [categories],\n  );\n\n  useEffect(() => {\n    const getCategories = async () => {\n      const categories = await categoryService.getCategories('projects');\n\n      if (categories) {\n        setCategories(categories);\n      }\n    };\n\n    getCategories();\n  }, []);\n\n  return (\n    <FormFieldWrapper text={title} required>\n      <Field\n        name=\"category\"\n        validate={required}\n        render={({ input, meta }) => (\n          <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>\n            {meta.touched && meta.error && (\n              <Text sx={{ color: 'error', fontSize: 1 }}>{meta.error}</Text>\n            )}\n            <CategoriesSelectV2\n              isForm={true}\n              onChange={(category) => input.onChange(category)}\n              value={input.value}\n              placeholder={placeholder || ''}\n              categories={options}\n              invalid={meta.touched && meta.error}\n            />\n            {input?.value?.value && (\n              <LibraryCategoryGuidance\n                category={categories.find((x) => x.id === Number(input.value.value))}\n                type=\"main\"\n              />\n            )}\n          </Box>\n        )}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryCategoryGuidance.test.tsx",
    "content": "import { faker } from '@faker-js/faker';\nimport { render, screen } from '@testing-library/react';\nimport { describe, expect, it } from 'vitest';\n\nimport { guidance } from '../../labels';\nimport { LibraryCategoryGuidance } from './LibraryCategoryGuidance';\nimport { LibraryFormProvider } from './LibraryFormProvider';\n\ndescribe('LibraryCategoryGuidance', () => {\n  it('renders expected main content when a category that exists is present', async () => {\n    render(\n      <LibraryFormProvider>\n        <LibraryCategoryGuidance\n          category={{\n            id: 1,\n            createdAt: faker.date.past(),\n            modifiedAt: faker.date.past(),\n            name: 'Moulds',\n            type: 'projects',\n          }}\n          type=\"main\"\n        />\n      </LibraryFormProvider>,\n    );\n\n    const guidanceFirstLine = guidance.moulds.main.slice(0, 40);\n\n    await screen.findByText(guidanceFirstLine, { exact: false });\n  });\n\n  it('renders nothing when not visible', () => {\n    const { container } = render(\n      <LibraryFormProvider>\n        <LibraryCategoryGuidance category={undefined} type=\"main\" />\n      </LibraryFormProvider>,\n    );\n\n    expect(container.innerHTML).toBe('');\n  });\n});\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryCategoryGuidance.tsx",
    "content": "import type { Category } from 'oa-shared';\nimport { Alert, Text } from 'theme-ui';\nimport { guidance } from '../../labels';\n\ninterface IProps {\n  category?: Category;\n  type: 'main' | 'files';\n}\n\nexport const LibraryCategoryGuidance = ({ category, type }: IProps) => {\n  if (!category) {\n    return null;\n  }\n\n  const label = category.name.toLowerCase();\n  const labelExists = !!guidance[label] && !!guidance[label][type];\n\n  if (!labelExists) {\n    return null;\n  }\n\n  return (\n    <Alert variant=\"info\">\n      <Text\n        dangerouslySetInnerHTML={{ __html: guidance[label][type] }}\n        sx={{\n          fontSize: 2,\n          textAlign: 'left',\n          ol: {\n            marginTop: 1,\n            marginBottom: 0,\n          },\n        }}\n      />\n    </Alert>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryDescription.field.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport { describe, it } from 'vitest';\n\nimport { LibraryDescriptionField } from './LibraryDescription.field';\nimport { LibraryFormProvider } from './LibraryFormProvider';\n\ndescribe('HowtoFieldStepsDescription', () => {\n  it('renders', async () => {\n    render(\n      <LibraryFormProvider>\n        <LibraryDescriptionField />\n      </LibraryFormProvider>,\n    );\n\n    await screen.findByText('Short description *');\n  });\n  // Will add behavioural test when #2698 is merged in; 1000 character cap\n});\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryDescription.field.tsx",
    "content": "import { FieldTextarea } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { draftValidationWrapper, required } from 'src/utils/validators';\n\nimport { LIBRARY_DESCRIPTION_MAX_LENGTH } from '../../constants';\nimport { intro } from '../../labels';\n\nexport const LibraryDescriptionField = () => {\n  const { description, title } = intro.description;\n  const name = 'description';\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title} required>\n      <Field\n        id={name}\n        name={name}\n        data-cy=\"intro-description\"\n        validate={(values, allValues) => draftValidationWrapper(values, allValues, required)}\n        validateFields={[]}\n        modifiers={{ capitalize: true, trim: true }}\n        isEqual={COMPARISONS.textInput}\n        component={FieldTextarea}\n        rows={10}\n        maxLength={LIBRARY_DESCRIPTION_MAX_LENGTH}\n        showCharacterCount\n        placeholder={description}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryDifficulty.field.tsx",
    "content": "import { Field } from 'react-final-form';\nimport { SelectField } from 'src/common/Form/Select.field';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { draftValidationWrapper, required } from 'src/utils/validators';\n\nimport { intro } from '../../labels';\nimport { DIFFICULTY_OPTIONS } from './FormSettings';\n\nexport const LibraryDifficultyField = () => {\n  const { placeholder, title } = intro.difficultyLevel;\n  const name = 'difficultyLevel';\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title} required>\n      <Field\n        px={1}\n        id={name}\n        name={name}\n        data-cy=\"difficulty-select\"\n        validate={(values, allValues) => draftValidationWrapper(values, allValues, required)}\n        validateFields={[]}\n        isEqual={COMPARISONS.textInput}\n        component={SelectField}\n        options={DIFFICULTY_OPTIONS}\n        placeholder={placeholder}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryFileLink.field.tsx",
    "content": "import { FieldInput } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { draftValidationWrapper, validateUrlAcceptEmpty } from 'src/utils/validators';\n\nimport { MAX_LINK_LENGTH } from '../../../constants';\nimport { intro } from '../../labels';\n\nexport const LibraryFileLinkField = () => {\n  const { description, title } = intro.fileLink;\n  const name = 'fileLink';\n\n  return (\n    <FormFieldWrapper htmlFor=\"file-download-link\" text={title}>\n      <Field\n        id={name}\n        name={name}\n        data-cy={name}\n        component={FieldInput}\n        placeholder={description}\n        isEqual={COMPARISONS.textInput}\n        maxLength={MAX_LINK_LENGTH}\n        validate={(values, allValues) =>\n          draftValidationWrapper(values, allValues, validateUrlAcceptEmpty)\n        }\n        validateFields={[]}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryFileUpload.field.tsx",
    "content": "import { UserRole } from 'oa-shared';\nimport { Field } from 'react-final-form';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { AuthWrapper } from 'src/common/AuthWrapper';\nimport { FileInputField } from 'src/common/Form/FileInput.field';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { Text } from 'theme-ui';\n\nimport { intro } from '../../labels';\n\n// TODO: Replicate research behavior\nexport const LibraryFileUploadField = () => {\n  const { description, title } = intro.files;\n  const name = 'files';\n\n  return (\n    <ClientOnly fallback={<></>}>\n      {() => (\n        <>\n          <AuthWrapper\n            roleRequired={UserRole.ADMIN}\n            fallback={\n              <FormFieldWrapper htmlFor={name} text={title}>\n                <Field id={name} name={name} data-cy={name} component={FileInputField} />\n                <Text color={'grey'} mt={4} sx={{ fontSize: 1 }}>\n                  {description}\n                </Text>\n              </FormFieldWrapper>\n            }\n          >\n            <FormFieldWrapper htmlFor={name} text={title}>\n              <Field id={name} name={name} data-cy={name} admin={true} component={FileInputField} />\n              <Text color={'grey'} mt={4} sx={{ fontSize: 1 }}>\n                {'Maximum file size 300MB'}\n              </Text>\n            </FormFieldWrapper>\n          </AuthWrapper>\n        </>\n      )}\n    </ClientOnly>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryForm.tsx",
    "content": "import arrayMutators from 'final-form-arrays';\nimport { FormApi } from 'node_modules/final-form/dist';\nimport type { ProjectFormData } from 'oa-shared';\nimport { useMemo, useState } from 'react';\nimport { Form } from 'react-final-form';\nimport { FormWrapper } from 'src/common/Form/FormWrapper';\nimport { useToast } from 'src/common/Toast';\nimport { FilesFields } from 'src/pages/common/FormFields/FilesFields';\nimport { ImageField } from 'src/pages/common/FormFields/ImageField';\nimport { TagsField } from 'src/pages/common/FormFields/Tags.field';\nimport { Flex } from 'theme-ui';\nimport { buttons, headings, intro } from '../../labels';\nimport { libraryService } from '../../library.service';\nimport { transformLibraryErrors } from '../utils';\nimport DeleteProjectButton from './DeleteProjectButton';\nimport { LibraryCategoryField } from './LibraryCategory.field';\nimport { LibraryDescriptionField } from './LibraryDescription.field';\nimport { LibraryDifficultyField } from './LibraryDifficulty.field';\nimport { LibraryPostingGuidelines } from './LibraryPostingGuidelines';\nimport { LibraryStepsContainerField } from './LibraryStepsContainer.field';\nimport { LibraryTimeField } from './LibraryTime.field';\nimport { LibraryTitleField } from './LibraryTitle.field';\n\ninterface LibraryFormProps {\n  id: number | null;\n  formData: ProjectFormData | null;\n}\n\nexport const LibraryForm = ({ id, formData }: LibraryFormProps) => {\n  const toast = useToast();\n  const [isSubmittingDraft, setIsSubmittingDraft] = useState(false);\n\n  const initialValues = useMemo<ProjectFormData>(\n    () =>\n      ({\n        title: formData?.title || '',\n        description: formData?.description || '',\n        category: formData?.category || null,\n        tags: formData?.tags || [],\n        time: formData?.time || null,\n        difficultyLevel: formData?.difficultyLevel || null,\n        coverImage: formData?.coverImage || null,\n        files: formData?.files || null,\n        fileLink: formData?.fileLink || null,\n        steps: formData?.steps ?? [\n          { id: null, title: '', description: '', images: [], videoUrl: null },\n          { id: null, title: '', description: '', images: [], videoUrl: null },\n          { id: null, title: '', description: '', images: [], videoUrl: null },\n        ],\n      }) satisfies ProjectFormData,\n    [],\n  );\n\n  const headingText = id ? headings.edit : headings.create;\n\n  const onSubmit = async (\n    form: FormApi<ProjectFormData, Partial<ProjectFormData>>,\n    values: ProjectFormData,\n    isDraft = false,\n  ) => {\n    try {\n      const promise = libraryService.upsert(id || null, values, isDraft);\n\n      toast.promise(promise, {\n        loading: isDraft ? 'Saving draft...' : 'Publishing project...',\n        success: (data) => {\n          form.reset(values);\n          return {\n            message: isDraft ? 'Draft saved!' : 'Project published!',\n            actionLink: {\n              href: `/library/${data.project.slug}`,\n              label: isDraft ? 'View draft' : 'View project',\n            },\n          };\n        },\n        error: (error) => {\n          console.error(error);\n          return `Error: ${error.message}`;\n        },\n        duration: 10000,\n      });\n\n      await promise;\n\n      await new Promise((resolve) => setTimeout(resolve, 1000)); // to avoid spam clicking\n    } catch (_) {\n      // do nothing, error is handled by toast\n    }\n  };\n\n  return (\n    <Form<ProjectFormData>\n      onSubmit={async (values, form) => await onSubmit(form, values, false)}\n      initialValues={initialValues}\n      mutators={{\n        ...arrayMutators,\n      }}\n      enableReinitialize={true}\n      render={({\n        errors,\n        handleSubmit,\n        hasValidationErrors,\n        submitFailed,\n        submitting,\n        values,\n        form,\n      }) => {\n        const belowBody = (\n          <Flex sx={{ flexDirection: 'column' }}>\n            <LibraryStepsContainerField contentType=\"projects\" contentId={id ?? null} />\n          </Flex>\n        );\n\n        const errorsClientSide = transformLibraryErrors(errors);\n\n        const handleSubmitDraft = async (e: React.MouseEvent) => {\n          e.preventDefault();\n          setIsSubmittingDraft(true);\n          await onSubmit(form, values, true);\n          form.reset(values);\n          setIsSubmittingDraft(false);\n        };\n\n        return (\n          <FormWrapper\n            belowBody={belowBody}\n            buttonLabel={buttons.publish}\n            errorsClientSide={errorsClientSide}\n            guidelines={<LibraryPostingGuidelines />}\n            handleSubmit={handleSubmit}\n            handleSubmitDraft={handleSubmitDraft}\n            hasValidationErrors={hasValidationErrors}\n            heading={headingText}\n            submitFailed={submitFailed}\n            submitting={submitting || isSubmittingDraft}\n            hideSubmittingMessage={true}\n            sidebar={<>{id && <DeleteProjectButton id={id} />}</>}\n          >\n            <Flex\n              sx={{\n                gap: 4,\n                flexDirection: ['column', 'row'],\n              }}\n            >\n              <Flex sx={{ flexDirection: 'column', gap: '1rem', flex: 1 }}>\n                <LibraryTitleField />\n                <LibraryDescriptionField />\n                <LibraryCategoryField />\n                <LibraryTimeField />\n                <LibraryDifficultyField />\n                <TagsField title={intro.tags.title} />\n                <FilesFields contentType=\"projects\" contentId={id ?? null} />\n              </Flex>\n              <Flex data-cy=\"intro-cover\" sx={{ flex: 1, width: '100%' }}>\n                <ImageField title=\"Cover Image\" contentType=\"projects\" contentId={id ?? null} />\n              </Flex>\n            </Flex>\n          </FormWrapper>\n        );\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryFormProvider.tsx",
    "content": "import arrayMutators from 'final-form-arrays';\nimport { Form } from 'react-final-form';\nimport { FactoryLibraryItem } from 'src/test/factories/Library';\nimport { vi } from 'vitest';\n\nexport const LibraryFormProvider = ({ children }: { children: React.ReactNode }) => {\n  const formProps = {\n    formValues: FactoryLibraryItem(),\n    onSubmit: vi.fn(),\n    mutators: { ...arrayMutators },\n    component: () => children,\n  };\n  return <Form {...formProps} />;\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryPostingGuidelines.tsx",
    "content": "import { ExternalLink, Guidelines } from 'oa-components';\n\nexport const LibraryPostingGuidelines = () => (\n  <Guidelines\n    title=\"How does it work?\"\n    steps={[\n      <>\n        Choose what you want to share{' '}\n        <span role=\"img\" aria-label=\"raised-hand\">\n          🙌\n        </span>\n      </>,\n      <>\n        Read{' '}\n        <ExternalLink sx={{ color: 'blue' }} href=\"/academy/create/library\">\n          our guidelines{' '}\n          <span role=\"img\" aria-label=\"nerd-face\">\n            🤓\n          </span>\n        </ExternalLink>\n      </>,\n      <>\n        Prepare your text & images{' '}\n        <span role=\"img\" aria-label=\"archive-box\">\n          🗄️\n        </span>\n      </>,\n      <>\n        Create your Project{' '}\n        <span role=\"img\" aria-label=\"writing-hand\">\n          ✍️\n        </span>\n      </>,\n      <>\n        Click on “Publish”{' '}\n        <span role=\"img\" aria-label=\"mouse\">\n          🖱️\n        </span>\n      </>,\n      <>We will either send you feedback, or</>,\n      <>\n        Approve if everything is okay{' '}\n        <span role=\"img\" aria-label=\"tick-validate\">\n          ✅\n        </span>\n      </>,\n      <>\n        Be proud{' '}\n        <span role=\"img\" aria-label=\"simple-smile\">\n          🙂\n        </span>\n      </>,\n    ]}\n  />\n);\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryStep.field.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport { describe, it, vi } from 'vitest';\n\nimport { LibraryFormProvider } from './LibraryFormProvider';\nimport { LibraryStepField } from './LibraryStep.field';\n\ndescribe('LibraryStepField', () => {\n  it('renders', async () => {\n    const props = {\n      name: '',\n      index: 0,\n      images: [],\n      onDelete: vi.fn(() => null),\n      moveStep: vi.fn(() => null),\n      removeExistingImage: vi.fn(() => null),\n    };\n\n    render(\n      <LibraryFormProvider>\n        <LibraryStepField {...props} />\n      </LibraryFormProvider>,\n    );\n\n    await screen.findByText('Step 1 *');\n  });\n});\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryStep.field.tsx",
    "content": "import { Button, FieldInput, FieldTextarea, Modal } from 'oa-components';\nimport type { MediaWithPublicUrl } from 'oa-shared';\nimport { useState } from 'react';\nimport { Field } from 'react-final-form';\nimport { StepImagesField } from 'src/pages/common/FormFields';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport {\n  composeValidators,\n  draftValidationWrapper,\n  minValue,\n  required,\n} from 'src/utils/validators';\nimport { Card, Flex, Heading, Label, Text } from 'theme-ui';\nimport {\n  LIBRARY_MIN_REQUIRED_STEPS,\n  LIBRARY_TITLE_MAX_LENGTH,\n  LIBRARY_TITLE_MIN_LENGTH,\n  STEP_DESCRIPTION_MAX_LENGTH,\n  STEP_DESCRIPTION_MIN_LENGTH,\n} from '../../constants';\nimport { buttons, errors, steps } from '../../labels';\n\ninterface IProps {\n  name: string;\n  index: number;\n  images: MediaWithPublicUrl[];\n  onDelete: (index: number) => void;\n  moveStep: (indexfrom: number, indexTo: number) => void;\n  contentType?: 'projects' | 'research' | 'questions' | 'news';\n  contentId?: number | null;\n}\n\n/**\n * Ensure that the project description meets the following criteria:\n * - required\n * - minimum character length of 100 characters\n * - maximum character length of 1000 characters\n */\nexport const LibraryStepField = ({\n  name,\n  index,\n  images,\n  onDelete,\n  moveStep,\n  contentType = 'projects',\n  contentId = null,\n}: IProps) => {\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n\n  const toggleDeleteModal = () => {\n    setShowDeleteModal((state) => !state);\n  };\n\n  const confirmDelete = () => {\n    toggleDeleteModal();\n    onDelete(index);\n  };\n\n  const validateStepMedia = (allValues: any) => {\n    if (!allValues?.steps || !allValues?.steps.length) {\n      return null;\n    }\n\n    const stepValues = allValues.steps[index];\n    if (!stepValues) {\n      return null;\n    }\n\n    // More robust checking for images - ensure array exists AND has items\n    const hasImages = Array.isArray(stepValues.images) && stepValues.images.length > 0;\n\n    if (stepValues.videoUrl) {\n      if (hasImages) {\n        return errors.videoUrl.both;\n      }\n\n      const ytRegex = new RegExp(/(youtu\\.be\\/|youtube\\.com\\/(watch\\?v=|embed\\/|v\\/))/gi);\n      const urlValid = ytRegex.test(stepValues.videoUrl);\n      return urlValid ? null : errors.videoUrl.invalidUrl;\n    }\n\n    return hasImages ? null : errors.videoUrl.empty;\n  };\n\n  const { deleteButton } = buttons.steps;\n  const _labelStyle = {\n    fontSize: 2,\n    marginBottom: 2,\n  };\n\n  const isAboveMinimumStep = index >= LIBRARY_MIN_REQUIRED_STEPS;\n\n  return (\n    <Card data-cy={`step_${index}`} key={index}>\n      <Flex p={3} sx={{ flexDirection: 'column' }}>\n        <Flex p={0}>\n          <Heading as=\"h3\" variant=\"small\" sx={{ flex: 1 }} mb={3}>\n            {steps.heading.title} {index + 1} {!isAboveMinimumStep && '*'}\n          </Heading>\n          {index >= 1 && (\n            <Button\n              data-cy=\"move-step-up\"\n              data-testid=\"move-step-up\"\n              variant={'secondary'}\n              icon=\"arrow-full-up\"\n              showIconOnly={true}\n              sx={{ mx: '5px' }}\n              type=\"button\"\n              onClick={() => moveStep(index, index - 1)}\n            />\n          )}\n          <Button\n            data-cy=\"move-step-down\"\n            data-testid=\"move-step-down\"\n            variant={'secondary'}\n            icon=\"arrow-full-down\"\n            sx={{ mx: '5px' }}\n            showIconOnly={true}\n            type=\"button\"\n            onClick={() => moveStep(index, index + 1)}\n          />\n          {isAboveMinimumStep && (\n            <Button\n              data-cy=\"delete-step\"\n              data-testid=\"delete-step\"\n              variant={'outline'}\n              showIconOnly={true}\n              icon=\"delete\"\n              type=\"button\"\n              onClick={() => toggleDeleteModal()}\n            />\n          )}\n\n          <Modal onDismiss={() => toggleDeleteModal()} isOpen={!!showDeleteModal}>\n            <Text>{deleteButton.warning}</Text>\n            <Flex mt={3} p={0} mx={-1} sx={{ justifyContent: 'flex-end' }}>\n              <Flex px={1}>\n                <Button variant={'outline'} onClick={() => toggleDeleteModal()}>\n                  {deleteButton.cancel}\n                </Button>\n              </Flex>\n              <Flex px={1}>\n                <Button\n                  data-cy=\"confirm\"\n                  data-testid=\"confirm\"\n                  variant=\"outline\"\n                  onClick={() => confirmDelete()}\n                  type=\"button\"\n                >\n                  {deleteButton.title}\n                </Button>\n              </Flex>\n            </Flex>\n          </Modal>\n        </Flex>\n\n        <Flex sx={{ flexDirection: 'column', gap: '1rem' }}>\n          <Flex sx={{ flexDirection: 'column' }}>\n            <Label sx={_labelStyle} htmlFor={`${name}.title`}>\n              {`${steps.title.title} *`}\n            </Label>\n            <Field\n              name={`${name}.title`}\n              data-cy=\"step-title\"\n              data-testid=\"step-title\"\n              modifiers={{ capitalize: true, trim: true }}\n              component={FieldInput}\n              placeholder={steps.title.placeholder}\n              maxLength={LIBRARY_TITLE_MAX_LENGTH}\n              minLength={LIBRARY_TITLE_MIN_LENGTH}\n              validate={(value, allValues) =>\n                draftValidationWrapper(\n                  value,\n                  allValues,\n                  composeValidators(required, minValue(LIBRARY_TITLE_MIN_LENGTH)),\n                )\n              }\n              validateFields={[]}\n              isEqual={COMPARISONS.textInput}\n              showCharacterCount\n            />\n          </Flex>\n\n          <Flex sx={{ flexDirection: 'column' }}>\n            <Label sx={_labelStyle} htmlFor={`${name}.text`}>\n              {`${steps.description.title} *`}\n            </Label>\n            <Field\n              name={`${name}.description`}\n              placeholder={steps.description.placeholder}\n              minLength={STEP_DESCRIPTION_MIN_LENGTH}\n              maxLength={STEP_DESCRIPTION_MAX_LENGTH}\n              data-cy=\"step-description\"\n              data-testid=\"step-description\"\n              modifiers={{ capitalize: true, trim: true }}\n              component={FieldTextarea}\n              rows={10}\n              validate={(value, allValues) =>\n                draftValidationWrapper(\n                  value,\n                  allValues,\n                  composeValidators(required, minValue(STEP_DESCRIPTION_MIN_LENGTH)),\n                )\n              }\n              validateFields={[]}\n              isEqual={COMPARISONS.textInput}\n              showCharacterCount\n            />\n          </Flex>\n\n          <StepImagesField\n            stepIndex={index}\n            contentType={contentType}\n            contentId={contentId}\n            images={images}\n            fieldName={name}\n          />\n\n          <Flex sx={{ flexDirection: 'column' }}>\n            <Field\n              name={`${name}.videoUrl`}\n              data-cy=\"step-videoUrl\"\n              data-testid=\"step-videoUrl\"\n              component={FieldInput}\n              placeholder={steps.videoUrl.placeholder}\n              validate={(_, allValues) => validateStepMedia(allValues)}\n              isEqual={COMPARISONS.textInput}\n            />\n          </Flex>\n        </Flex>\n      </Flex>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryStepsContainer.field.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport { describe, it } from 'vitest';\n\nimport { LibraryFormProvider } from './LibraryFormProvider';\nimport { LibraryStepsContainerField } from './LibraryStepsContainer.field';\n\ndescribe('HowtoFieldStepsContainer', () => {\n  it('renders', async () => {\n    render(\n      <LibraryFormProvider>\n        <LibraryStepsContainerField />\n      </LibraryFormProvider>,\n    );\n\n    await screen.findByText('Add step');\n  });\n  // Will add behavioural test when #2698 is merged in; adding steps\n});\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryStepsContainer.field.tsx",
    "content": "import { animated, useTransition } from '@react-spring/web';\nimport { Button } from 'oa-components';\nimport { FieldArray } from 'react-final-form-arrays';\nimport { LibraryStepField } from 'src/pages/Library/Content/Common/LibraryStep.field';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { Box, Flex, Heading, Text } from 'theme-ui';\n\nimport { buttons as buttonsLabel, steps as stepsLabel } from '../../labels';\n\ninterface IPropsAnimation {\n  children: React.ReactNode;\n  animationKey: string;\n}\n\nconst AnimatedStep = ({ children, animationKey }: IPropsAnimation) => {\n  const transitions = useTransition(true, {\n    keys: animationKey,\n    from: { opacity: 0 },\n    enter: { opacity: 1 },\n    leave: { opacity: 0, display: 'none' },\n    config: { duration: 200 },\n  });\n\n  return (\n    <>\n      {transitions((style, item) => item && <animated.div style={style}>{children}</animated.div>)}\n    </>\n  );\n};\n\nexport const LibraryStepsContainerField = ({\n  contentType = 'projects',\n  contentId = null,\n}: {\n  contentType?: 'projects' | 'research' | 'questions' | 'news';\n  contentId?: number | null;\n}) => {\n  return (\n    <FieldArray name=\"steps\" isEqual={COMPARISONS.step}>\n      {({ fields }) => (\n        <Flex sx={{ flexDirection: 'column', gap: 4 }}>\n          <Box>\n            <Heading as=\"h2\" variant=\"h2\">\n              Steps\n            </Heading>\n            <Text\n              sx={{ fontSize: 2 }}\n              dangerouslySetInnerHTML={{\n                __html: stepsLabel.heading.description as string,\n              }}\n            />\n          </Box>\n          {fields.map((name, index: number) => (\n            <AnimatedStep\n              key={`${fields.value[index]._animationKey}-${index}`}\n              animationKey={`${fields.value[index]._animationKey}-${index}`}\n            >\n              <LibraryStepField\n                key={`${fields.value[index]._animationKey}-${index}2`}\n                name={name}\n                index={index}\n                moveStep={(from, to) => {\n                  if (to !== fields.length && to >= 0) {\n                    // Move form fields\n                    fields.move(from, to);\n                  }\n                }}\n                images={fields.value[index].images || []}\n                onDelete={(fieldIndex: number) => {\n                  fields.remove(fieldIndex);\n                }}\n                contentType={contentType}\n                contentId={contentId}\n              />\n            </AnimatedStep>\n          ))}\n          <Flex>\n            <Button\n              type=\"button\"\n              icon=\"add\"\n              data-cy=\"add-step\"\n              mx=\"auto\"\n              variant=\"secondary\"\n              onClick={() => {\n                fields.push({\n                  title: '',\n                  description: '',\n                  images: [],\n                });\n              }}\n            >\n              {buttonsLabel.steps.add}\n            </Button>\n          </Flex>\n        </Flex>\n      )}\n    </FieldArray>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryTime.field.tsx",
    "content": "import { Field } from 'react-final-form';\nimport { SelectField } from 'src/common/Form/Select.field';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { draftValidationWrapper, required } from 'src/utils/validators';\nimport { intro } from '../../labels';\nimport { TIME_OPTIONS } from './FormSettings';\n\nexport const LibraryTimeField = () => {\n  const { placeholder, title } = intro.time;\n  const name = 'time';\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title} required>\n      <Field\n        id={name}\n        name={name}\n        validate={(values, allValues) => draftValidationWrapper(values, allValues, required)}\n        validateFields={[]}\n        isEqual={COMPARISONS.textInput}\n        options={TIME_OPTIONS}\n        component={SelectField}\n        data-cy=\"time-select\"\n        placeholder={placeholder}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryTitle.field.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport { describe, it } from 'vitest';\n\nimport { LibraryFormProvider } from './LibraryFormProvider';\nimport { LibraryTitleField } from './LibraryTitle.field';\n\ndescribe('LibraryTitleField', () => {\n  it('renders', async () => {\n    render(\n      <LibraryFormProvider>\n        <LibraryTitleField />\n      </LibraryFormProvider>,\n    );\n\n    await screen.findByText('0 / 50');\n  });\n});\n"
  },
  {
    "path": "src/pages/Library/Content/Common/LibraryTitle.field.tsx",
    "content": "import { FieldInput } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { composeValidators, minValue, required } from 'src/utils/validators';\nimport { LIBRARY_TITLE_MAX_LENGTH, LIBRARY_TITLE_MIN_LENGTH } from '../../constants';\nimport { intro } from '../../labels';\n\nexport const LibraryTitleField = () => {\n  const name = 'title';\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={intro.title.title} required>\n      <Field\n        id={name}\n        name={name}\n        data-cy=\"intro-title\"\n        validateFields={[]}\n        validate={composeValidators(required, minValue(LIBRARY_TITLE_MIN_LENGTH))}\n        isEqual={COMPARISONS.textInput}\n        modifiers={{ capitalize: true, trim: true }}\n        component={FieldInput}\n        minLength={LIBRARY_TITLE_MIN_LENGTH}\n        maxLength={LIBRARY_TITLE_MAX_LENGTH}\n        placeholder={intro.title.placeholder}\n        showCharacterCount={true}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/List/LibraryList.tsx",
    "content": "import { Loader, MoreContainer, Pagination } from 'oa-components';\nimport type { Project } from 'oa-shared';\nimport { useContext, useEffect, useState } from 'react';\nimport { useSearchParams } from 'react-router';\nimport { logger } from 'src/logger';\nimport useDrafts from 'src/pages/common/Drafts/useDraftsSupabase';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { Flex, Grid, Heading } from 'theme-ui';\nimport { ITEMS_PER_PAGE } from '../../constants';\nimport { LibrarySearchParams, libraryService } from '../../library.service';\nimport { LibraryListHeader } from './LibraryListHeader';\nimport type { LibrarySortOption } from './LibrarySortOptions';\nimport { ProjectCard } from './ProjectCard';\n\nexport const LibraryList = () => {\n  const tenantContext = useContext(TenantContext);\n  const [isFetching, setIsFetching] = useState(true);\n  const [projects, setProjects] = useState<Project[]>([]);\n  const [total, setTotal] = useState(0);\n  const { draftCount, isFetchingDrafts, drafts, showDrafts, handleShowDrafts } = useDrafts<Project>(\n    {\n      getDraftCount: libraryService.getDraftCount,\n      getDrafts: libraryService.getDrafts,\n    },\n  );\n\n  const [searchParams, setSearchParams] = useSearchParams();\n  const q = searchParams.get(LibrarySearchParams.q) || '';\n  const category = searchParams.get(LibrarySearchParams.category) || '';\n  const sort = searchParams.get(LibrarySearchParams.sort) as LibrarySortOption;\n  const pageNumber = parseInt(searchParams.get('page') || '1');\n\n  useEffect(() => {\n    if (!sort) {\n      // ensure sort is set\n      const params = new URLSearchParams(searchParams.toString());\n\n      if (q) {\n        params.set(LibrarySearchParams.sort, 'MostRelevant');\n      } else {\n        params.set(LibrarySearchParams.sort, 'MostUsefulLastWeek');\n      }\n      setSearchParams(params, { replace: true });\n    } else {\n      // search only when sort is set (avoids duplicate requests)\n      const skip = (pageNumber - 1) * ITEMS_PER_PAGE;\n      fetchProjects(skip);\n    }\n  }, [q, category, sort, pageNumber]);\n\n  const fetchProjects = async (skip: number = 0) => {\n    setIsFetching(true);\n\n    try {\n      const result = await libraryService.search(q?.toLocaleLowerCase(), category, sort, skip);\n\n      if (result) {\n        setProjects(result.items);\n        setTotal(result.total);\n      }\n    } catch (error) {\n      logger.error('error fetching library', error);\n    }\n\n    setIsFetching(false);\n  };\n\n  const updatePageNumber = (value: number) => {\n    const params = new URLSearchParams(searchParams.toString());\n    params.set('page', value.toString());\n    setSearchParams(params, { replace: true });\n  };\n\n  const showLoadMore =\n    !isFetching && !showDrafts && projects && projects.length > 0 && projects.length < total;\n\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: [2, 3] }}>\n      <LibraryListHeader\n        itemCount={isFetching ? undefined : total}\n        draftCount={isFetchingDrafts ? undefined : draftCount}\n        handleShowDrafts={handleShowDrafts}\n        showDrafts={showDrafts}\n      />\n\n      <Grid columns={[1, 2, 2, 3]} gap={[2, 3, 4]}>\n        {showDrafts ? (\n          drafts.map((item) => {\n            return <ProjectCard key={item.id} item={item} />;\n          })\n        ) : (\n          <>\n            {projects &&\n              projects.length > 0 &&\n              projects.map((item, index) => <ProjectCard key={index} item={item} query={q} />)}\n          </>\n        )}\n      </Grid>\n\n      {showLoadMore && (\n        <Flex\n          sx={{\n            justifyContent: 'center',\n          }}\n        >\n          <Pagination\n            totalPages={Math.ceil(total / ITEMS_PER_PAGE)}\n            onPageChange={updatePageNumber}\n            page={pageNumber}\n          />\n        </Flex>\n      )}\n\n      {(isFetching || isFetchingDrafts) && <Loader />}\n\n      <MoreContainer\n        sx={{\n          paddingTop: [20, 70],\n          paddingBottom: [40, 90],\n          paddingX: 80,\n          alignSelf: 'center',\n        }}\n      >\n        <Flex sx={{ alignItems: 'center', flexDirection: 'column' }}>\n          <Heading as=\"p\" sx={{ textAlign: 'center', maxWidth: '500px' }}>\n            Contribute to the {tenantContext?.siteName || 'Community Platform'} library,\n            <br />\n            share your project.\n          </Heading>\n        </Flex>\n      </MoreContainer>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/List/LibraryListHeader.tsx",
    "content": "import debounce from 'debounce';\nimport {\n  ButtonIcon,\n  CategoryHorizonalList,\n  ReturnPathLink,\n  SearchField,\n  Select,\n  Tooltip,\n} from 'oa-components';\nimport type { Category } from 'oa-shared';\nimport { useCallback, useContext, useEffect, useState } from 'react';\nimport { Link, useSearchParams } from 'react-router';\nimport { FieldContainer } from 'src/common/Form/FieldContainer';\nimport { useClickOutside } from 'src/common/hooks/useClickOutside';\nimport { UserAction } from 'src/common/UserAction';\nimport DraftButton from 'src/pages/common/Drafts/DraftButton';\nimport { ListHeader } from 'src/pages/common/Layout/ListHeader';\nimport type { FilterSection } from 'src/pages/common/Layout/MobileSortModal';\nimport { MobileSortModal } from 'src/pages/common/Layout/MobileSortModal';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { categoryService } from 'src/services/categoryService';\nimport { Box, Button, Flex } from 'theme-ui';\nimport { listing } from '../../labels';\nimport { LibrarySearchParams } from '../../library.service';\nimport type { LibrarySortOption } from './LibrarySortOptions';\nimport { LibrarySortOptions } from './LibrarySortOptions';\n\ninterface IProps {\n  itemCount?: number;\n  draftCount?: number;\n  handleShowDrafts: () => void;\n  showDrafts: boolean;\n}\n\nconst DEFAULT_SORT: LibrarySortOption = 'MostUsefulLastWeek';\n\nexport const LibraryListHeader = (props: IProps) => {\n  const { itemCount, draftCount, handleShowDrafts, showDrafts } = props;\n  const [categories, setCategories] = useState<Category[]>([]);\n  const [searchParams, setSearchParams] = useSearchParams();\n  const q = searchParams.get(LibrarySearchParams.q);\n  const [searchString, setSearchString] = useState<string>(q ?? '');\n  const tenantContext = useContext(TenantContext);\n\n  const categoryParam = Number(searchParams.get(LibrarySearchParams.category));\n  const category = categories?.find((x) => x.id === categoryParam) ?? null;\n  const sort = searchParams.get(LibrarySearchParams.sort) as LibrarySortOption;\n\n  const [isSearchOpen, setIsSearchOpen] = useState(false);\n  const [isSortModalOpen, setIsSortModalOpen] = useState(false);\n  const [pendingSort, setPendingSort] = useState<LibrarySortOption>(sort || DEFAULT_SORT);\n\n  const handleOpenSortModal = () => {\n    setPendingSort(sort || DEFAULT_SORT);\n    setIsSortModalOpen(true);\n  };\n\n  const handleCloseSortModal = () => {\n    setIsSortModalOpen(false);\n  };\n\n  const handleToggleSearchOpen = () => {\n    setIsSearchOpen((x) => !x);\n  };\n\n  useEffect(() => {\n    const initCategories = async () => {\n      const categories = (await categoryService.getCategories('projects')) || [];\n      setCategories(categories);\n    };\n\n    initCategories();\n\n    if (!searchParams.get(LibrarySearchParams.sort)) {\n      const params = new URLSearchParams(searchParams.toString());\n      params.set(LibrarySearchParams.sort, 'MostUsefulLastWeek');\n      setSearchParams(params, { replace: true });\n    }\n  }, []);\n\n  const updateFilter = useCallback(\n    (key: LibrarySearchParams, value: string) => {\n      const params = new URLSearchParams(searchParams.toString());\n      if (value) {\n        params.set(key, value);\n      } else {\n        params.delete(key);\n      }\n      setSearchParams(params);\n    },\n    [searchParams],\n  );\n\n  const onSearchInputChange = useCallback(\n    debounce((value: string) => {\n      searchValue(value);\n    }, 500),\n    [searchParams],\n  );\n\n  const searchValue = (value: string) => {\n    const params = new URLSearchParams(searchParams.toString());\n    params.set(LibrarySearchParams.q, value);\n\n    if (value.length > 0 && sort !== 'MostRelevant') {\n      params.set(LibrarySearchParams.sort, 'MostRelevant');\n    }\n\n    if (value.length === 0 || !value) {\n      params.set(LibrarySearchParams.sort, DEFAULT_SORT);\n    }\n\n    setSearchParams(params);\n  };\n\n  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    searchValue(searchString);\n    setIsSearchOpen(false);\n  };\n\n  const sortOptions = LibrarySortOptions.toArray(!!q);\n\n  const effectiveDefaultSort = q ? 'MostRelevant' : DEFAULT_SORT;\n  const activeFilterCount =\n    (sort && sort !== effectiveDefaultSort ? 1 : 0) + (categoryParam > 0 ? 1 : 0);\n\n  const handleApplySort = () => {\n    updateFilter(LibrarySearchParams.sort, pendingSort);\n    handleCloseSortModal();\n  };\n\n  const handleResetSort = () => {\n    updateFilter(LibrarySearchParams.sort, DEFAULT_SORT);\n    setPendingSort(DEFAULT_SORT);\n  };\n\n  const formRef = useClickOutside(() => {\n    setIsSearchOpen(false);\n  });\n\n  const sortSections: FilterSection[] = [\n    {\n      title: 'Sort',\n      options: sortOptions,\n      selectedValue: pendingSort,\n      onSelect: (value) => setPendingSort(value as LibrarySortOption),\n    },\n  ];\n\n  const categoryComponent = (\n    <CategoryHorizonalList\n      allCategories={categories}\n      activeCategory={category}\n      setActiveCategory={(updatedCategory) =>\n        updateFilter(LibrarySearchParams.category, (updatedCategory?.id || '').toString())\n      }\n    />\n  );\n\n  const filteringComponents = (\n    <Flex\n      sx={{\n        gap: 2,\n        flexDirection: ['column', 'row', 'row'],\n        display: ['none', 'none', 'flex'],\n      }}\n    >\n      <Flex sx={{ width: ['100%', '100%', '220px'] }}>\n        <FieldContainer>\n          <Select\n            options={sortOptions}\n            placeholder={listing.sort}\n            value={sort ? { label: LibrarySortOptions.get(sort), value: sort } : undefined}\n            onChange={(sortBy) => updateFilter(LibrarySearchParams.sort, sortBy.value)}\n          />\n        </FieldContainer>\n      </Flex>\n      <Flex sx={{ width: ['100%', '100%', '270px'] }}>\n        <SearchField\n          dataCy=\"library-search-box\"\n          placeHolder={listing.search}\n          value={searchString}\n          onChange={(value) => {\n            setSearchString(value);\n            onSearchInputChange(value);\n          }}\n          onClear={() => {\n            setSearchString('');\n            searchValue('');\n          }}\n          onClickSearch={() => searchValue(searchString)}\n        />\n      </Flex>\n    </Flex>\n  );\n\n  const actionComponents = (\n    <UserAction\n      incompleteProfile={\n        <>\n          <Link\n            to=\"/settings\"\n            data-tooltip-id=\"tooltip\"\n            data-tooltip-content={listing.incompleteProfile}\n          >\n            <Button type=\"button\" data-cy=\"complete-profile-project\" variant=\"disabled\">\n              {listing.create}\n            </Button>\n          </Link>\n          <Tooltip id=\"tooltip\" />\n        </>\n      }\n      loggedIn={\n        <Flex sx={{ gap: 2 }}>\n          <DraftButton\n            showDrafts={showDrafts}\n            draftCount={draftCount}\n            handleShowDrafts={handleShowDrafts}\n          />\n          <Link to=\"/library/create\">\n            <Button type=\"button\" sx={{ width: '100%' }} variant=\"primary\" data-cy=\"create-project\">\n              {listing.create}\n            </Button>\n          </Link>\n        </Flex>\n      }\n      loggedOut={\n        <ReturnPathLink to=\"/sign-up\">\n          <Button type=\"button\" sx={{ width: '100%' }} variant=\"primary\" data-cy=\"sign-up\">\n            {listing.create}\n          </Button>\n        </ReturnPathLink>\n      }\n    />\n  );\n\n  const mobileFilteringComponents = (\n    <Flex sx={{ display: ['flex', 'flex', 'none'], gap: '5px' }}>\n      <Flex sx={{ position: 'relative' }}>\n        <ButtonIcon\n          onClick={handleOpenSortModal}\n          icon=\"sliders\"\n          sx={{\n            borderRadius: 1,\n            padding: '9px',\n            '&:hover': {\n              backgroundColor: 'background',\n            },\n          }}\n        />\n        {activeFilterCount > 0 && (\n          <Box\n            sx={{\n              position: 'absolute',\n              top: '-4px',\n              right: '-4px',\n              minWidth: '18px',\n              height: '18px',\n              borderRadius: '50%',\n              backgroundColor: 'red',\n              color: 'background',\n              fontSize: 0,\n              fontWeight: 'bold',\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n            }}\n          >\n            {activeFilterCount}\n          </Box>\n        )}\n      </Flex>\n      <ButtonIcon\n        onClick={handleToggleSearchOpen}\n        icon=\"search\"\n        sx={{\n          border: 'none',\n          background: 'transparent',\n          '&:hover': {\n            backgroundColor: 'background',\n          },\n        }}\n      />\n    </Flex>\n  );\n\n  const mobileSearchBar = (\n    <Flex sx={{ display: ['flex', 'flex', 'none'], width: '100%' }}>\n      <div ref={formRef} style={{ width: '100%' }}>\n        <form onSubmit={handleSubmit} style={{ width: '100%' }}>\n          <SearchField\n            isExpanded\n            autoFocus\n            dataCy=\"library-search-box\"\n            placeHolder={listing.search}\n            value={searchString}\n            onChange={(value) => {\n              setSearchString(value);\n              onSearchInputChange(value);\n            }}\n            onClear={() => {\n              setSearchString('');\n              searchValue('');\n            }}\n            onClickSearch={() => searchValue(searchString)}\n            onBack={() => {\n              setIsSearchOpen(false);\n            }}\n          />\n        </form>\n      </div>\n    </Flex>\n  );\n\n  return (\n    <>\n      <ListHeader\n        itemCount={(showDrafts ? draftCount : itemCount) || 0}\n        actionComponents={isSearchOpen ? null : actionComponents}\n        showDrafts={showDrafts}\n        headingTitle={tenantContext?.libraryHeading || ''}\n        categoryComponent={categoryComponent}\n        filteringComponents={filteringComponents}\n        mobileFilteringComponents={isSearchOpen ? mobileSearchBar : mobileFilteringComponents}\n        searchString={q || undefined}\n      />\n      <MobileSortModal\n        isOpen={isSortModalOpen}\n        onDismiss={handleCloseSortModal}\n        title=\"Sort\"\n        sections={sortSections}\n        onApply={handleApplySort}\n        onReset={handleResetSort}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/List/LibrarySortOptions.ts",
    "content": "export type LibrarySortOption =\n  | 'MostRelevant'\n  | 'Newest'\n  | 'MostUseful'\n  | 'MostUsefulLastWeek'\n  | 'LatestUpdated'\n  | 'MostDownloads'\n  | 'MostComments'\n  | 'MostViews';\n\nconst BaseOptions = new Map<LibrarySortOption, string>();\nBaseOptions.set('MostUsefulLastWeek', 'Most Useful Last Week');\nBaseOptions.set('Newest', 'Newest');\nBaseOptions.set('MostComments', 'Most Comments');\nBaseOptions.set('MostUseful', 'Most Useful');\nBaseOptions.set('MostDownloads', 'Most Downloads');\nBaseOptions.set('MostViews', 'Most Views');\n\nconst QueryParamOptions = new Map<LibrarySortOption, string>(BaseOptions);\nQueryParamOptions.set('MostRelevant', 'Most Relevant');\n\nconst toArray = (hasQueryParam: boolean) => {\n  const options = hasQueryParam ? QueryParamOptions : BaseOptions;\n  return Array.from(options, ([value, label]) => ({\n    label: label,\n    value: value,\n  }));\n};\n\nexport const LibrarySortOptions = {\n  get: (key: LibrarySortOption) => QueryParamOptions.get(key) ?? '',\n  toArray,\n};\n"
  },
  {
    "path": "src/pages/Library/Content/List/ProjectCard.tsx",
    "content": "import { Category, IconCountWithTooltip, ModerationStatus, Username } from 'oa-components';\nimport { type Project, UserRole } from 'oa-shared';\nimport { Link as RouterLink } from 'react-router';\nimport { AuthWrapper } from 'src/common/AuthWrapper';\nimport { Highlighter } from 'src/common/Highlighter';\nimport { capitalizeFirstLetter } from 'src/utils/helpers';\nimport { Box, Card, Flex, Heading, Image } from 'theme-ui';\n\ntype ProjectCardProps = {\n  item: Project;\n  query?: string;\n};\n\nexport const ProjectCard = ({ item, query }: ProjectCardProps) => {\n  const searchWords = [query || ''];\n\n  return (\n    <Card data-cy=\"card\">\n      <RouterLink to={`/library/${encodeURIComponent(item.slug)}`} style={{ display: 'block' }}>\n        <Flex\n          sx={{\n            background: 'background',\n            height: '60%',\n            overflow: 'hidden',\n            position: 'relative',\n          }}\n        >\n          {item.coverImage && (\n            <Image\n              style={{\n                width: '100%',\n                height: 'calc(((350px) / 3) * 2)',\n                objectFit: 'cover',\n              }}\n              loading=\"lazy\"\n              src={item.coverImage?.publicUrl || ''}\n              crossOrigin=\"\"\n              alt={`Cover image of ${item.title}`}\n            />\n          )}\n          {item.moderation && item.moderation !== 'accepted' && (\n            <ModerationStatus\n              status={item.moderation}\n              sx={{\n                top: 2,\n                position: 'absolute',\n                right: 2,\n                alignSelf: 'self-start',\n              }}\n            />\n          )}\n        </Flex>\n        <Flex\n          sx={{\n            flexDirection: 'column',\n            gap: 2,\n            padding: 2,\n            height: '40%',\n            justifyContent: 'space-between',\n          }}\n        >\n          <Flex sx={{ gap: 1, flexDirection: 'column' }}>\n            <Heading as=\"h2\" variant=\"small\" color={'black'}>\n              <Highlighter\n                searchWords={searchWords}\n                textToHighlight={capitalizeFirstLetter(item.title)}\n              />\n            </Heading>\n\n            {item.author && (\n              <Box>\n                <Username user={item.author} />\n              </Box>\n            )}\n          </Flex>\n\n          <AuthWrapper roleRequired={UserRole.BETA_TESTER} borderLess>\n            <Flex sx={{ justifyContent: 'flex-end' }}>\n              <Box\n                sx={{\n                  color: 'red',\n                  padding: '2px',\n                }}\n              >\n                {item.usefulVotesLastWeek}\n              </Box>\n            </Flex>\n          </AuthWrapper>\n\n          <Flex sx={{ justifyContent: 'flex-end' }}>\n            {item.category && (\n              <Flex sx={{ flex: 1 }}>\n                <Category category={item.category} sx={{ color: 'black' }} />\n              </Flex>\n            )}\n\n            <Flex\n              sx={{\n                gap: 2,\n                justifyContent: 'flex-end',\n              }}\n            >\n              <IconCountWithTooltip count={item.totalViews || 0} icon=\"show\" text=\"Views\" />\n              <IconCountWithTooltip\n                count={item.usefulCount || 0}\n                icon=\"star-active\"\n                text=\"How useful is it\"\n              />\n            </Flex>\n          </Flex>\n        </Flex>\n      </RouterLink>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Page/LibraryDescription.tsx",
    "content": "import {\n  AuthorDisplay,\n  Category,\n  ContentStatistics,\n  DisplayDate,\n  LinkifyText,\n  ModerationStatus,\n  TagList,\n  UsefulStatsButton,\n} from 'oa-components';\nimport type { Profile, Project } from 'oa-shared';\nimport { DifficultyLevelRecord, PremiumTier } from 'oa-shared';\nimport { useMemo } from 'react';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport DifficultyLevel from 'src/assets/icons/icon-difficulty-level.svg';\nimport TimeNeeded from 'src/assets/icons/icon-time-needed.svg';\nimport { DownloadWrapper } from 'src/common/DownloadWrapper';\nimport { userHasPremiumTier } from 'src/common/PremiumTierWrapper';\nimport { buildStatisticsLabel, capitalizeFirstLetter, hasAdminRights } from 'src/utils/helpers';\nimport { createUsefulStatistic } from 'src/utils/statistics';\nimport { Alert, Box, Card, Divider, Flex, Heading, Image, Text } from 'theme-ui';\n\ninterface IProps {\n  commentsCount: number;\n  item: Project;\n  loggedInUser: Profile | undefined;\n  votedUsefulCount?: number;\n  hasUserVotedUseful: boolean;\n  onUsefulClick: () => Promise<void>;\n}\n\nexport const LibraryDescription = ({\n  commentsCount,\n  hasUserVotedUseful,\n  item,\n  loggedInUser,\n  onUsefulClick,\n  votedUsefulCount,\n}: IProps) => {\n  const isEditable = useMemo(() => {\n    return (\n      !!loggedInUser &&\n      (hasAdminRights(loggedInUser) || item.author?.username === loggedInUser.username)\n    );\n  }, [loggedInUser, item.author]);\n\n  const showFeedback = item.moderationFeedback && item.moderation !== 'accepted' && isEditable;\n\n  return (\n    <Card variant=\"responsive\">\n      <Flex\n        data-cy=\"library-basis\"\n        data-id={item.id}\n        sx={{\n          overflow: 'hidden',\n          flexDirection: ['column-reverse', 'column-reverse', 'row'],\n        }}\n      >\n        <Flex\n          sx={{\n            padding: [2, 4],\n            gap: 2,\n            flexDirection: 'column',\n            width: ['100%', '100%', `${(1 / 2) * 100}%`],\n          }}\n        >\n          {item.deleted && (\n            <Text color=\"red\" pl={2} mb={2} data-cy=\"how-to-deleted\">\n              * Marked for deletion\n            </Text>\n          )}\n\n          {showFeedback && (\n            <Alert variant=\"info\">\n              <Box sx={{ textAlign: 'left' }} data-cy=\"moderationFeedback\">\n                <Heading as=\"p\" variant=\"small\">\n                  Moderator Feedback\n                </Heading>\n                <Text sx={{ fontSize: 2 }}>{item.moderationFeedback}</Text>\n              </Box>\n            </Alert>\n          )}\n\n          <Heading as=\"h1\" data-cy=\"project-title\">\n            {capitalizeFirstLetter(item.title)}\n          </Heading>\n\n          <Flex\n            sx={{\n              alignItems: 'center',\n              flexDirection: 'row',\n              flexWrap: 'wrap',\n              gap: 2,\n            }}\n          >\n            <AuthorDisplay author={item.author} />\n\n            <Text variant=\"auxiliary\">\n              <DisplayDate\n                createdAt={item.createdAt}\n                publishedAt={item.publishedAt}\n                modifiedAt={item.modifiedAt}\n                publishedAction=\"Published\"\n              />\n            </Text>\n\n            {item.isDraft && (\n              <Flex\n                sx={{\n                  borderRadius: 1,\n                  background: 'lightgrey',\n                }}\n              >\n                <Text\n                  sx={{\n                    fontSize: '14px',\n                    paddingX: 2,\n                    paddingY: 1,\n                  }}\n                >\n                  Draft\n                </Text>\n              </Flex>\n            )}\n\n            {item.category && <Category category={item.category} sx={{ fontSize: 2 }} />}\n          </Flex>\n\n          <Text variant=\"paragraph\" sx={{ whiteSpace: 'pre-line' }} data-cy=\"project-description\">\n            <LinkifyText>{item.description}</LinkifyText>\n          </Text>\n\n          <Flex sx={{ gap: 3, fontSize: 2 }}>\n            <Flex sx={{ flexDirection: ['column', 'row', 'row'] }}>\n              <Image loading=\"lazy\" src={TimeNeeded} height=\"16\" width=\"16\" mr=\"2\" mb=\"2\" />\n              {item.time}\n            </Flex>\n            {item.difficultyLevel && (\n              <Flex sx={{ flexDirection: ['column', 'row', 'row'] }} data-cy=\"difficulty-level\">\n                <Image loading=\"lazy\" src={DifficultyLevel} height=\"15\" width=\"16\" mr=\"2\" mb=\"2\" />\n                {DifficultyLevelRecord[item.difficultyLevel]}\n              </Flex>\n            )}\n          </Flex>\n\n          <Flex sx={{ marginTop: 'auto', flexDirection: 'column', gap: 1 }}>\n            <TagList tags={item.tags.map((t) => ({ label: t.name }))} />\n            <DownloadWrapper\n              contentType=\"projects\"\n              authorProfileId={item.author?.id}\n              fileDownloadCount={item.fileDownloadCount}\n              fileLink={item.hasFileLink ? `/api/documents/project/${item.id}/link` : undefined}\n              files={item.files?.map((x) => ({\n                id: x.id,\n                name: x.name,\n                size: x.size,\n                url: `/api/documents/project/${item.id}/${x.id}`,\n              }))}\n            />\n          </Flex>\n        </Flex>\n        <Box\n          sx={{\n            width: ['100%', '100%', `${(1 / 2) * 100}%`],\n            position: 'relative',\n          }}\n        >\n          <Box sx={{ overflow: 'hidden' }}>\n            <Box\n              sx={{\n                width: '100%',\n                height: '0',\n                paddingBottom: '75%',\n              }}\n            ></Box>\n            <Box\n              sx={{\n                position: 'absolute',\n                top: '0',\n                bottom: '0',\n                left: '0',\n                right: '0',\n              }}\n            >\n              {item.coverImage && (\n                // 3407 - AspectImage creates divs that can mess up page layout,\n                // so using Image here instead and recreating the div layout\n                // that was created by AspectImage\n                <Image\n                  loading=\"lazy\"\n                  src={item.coverImage.publicUrl}\n                  sx={{\n                    objectFit: 'cover',\n                    height: '100%',\n                    width: '100%',\n                  }}\n                  crossOrigin=\"\"\n                  alt=\"project cover image\"\n                />\n              )}\n            </Box>\n          </Box>\n\n          {!item.isDraft && item.moderation !== 'accepted' && (\n            <ModerationStatus\n              status={item.moderation}\n              sx={{ top: 3, position: 'absolute', right: 3, fontSize: 2 }}\n            />\n          )}\n        </Box>\n      </Flex>\n      <Divider sx={{ border: '1px solid black', margin: 0 }} />\n\n      <Flex\n        sx={{\n          alignItems: 'center',\n          flexDirection: 'row',\n          padding: [2, 3],\n          gap: 3,\n          flexWrap: 'wrap',\n          justifyContent: 'space-between',\n        }}\n      >\n        <Flex sx={{ gap: 2 }}>\n          <ClientOnly fallback={<></>}>\n            {() => (\n              <UsefulStatsButton\n                hasUserVotedUseful={hasUserVotedUseful}\n                isLoggedIn={loggedInUser ? true : false}\n                onUsefulClick={onUsefulClick}\n              />\n            )}\n          </ClientOnly>\n        </Flex>\n\n        <ContentStatistics\n          statistics={[\n            {\n              icon: 'show',\n              label: buildStatisticsLabel({\n                stat: item.totalViews,\n                statUnit: 'view',\n                usePlural: true,\n              }),\n              stat: item.totalViews,\n            },\n            createUsefulStatistic(\n              'projects',\n              item.id,\n              votedUsefulCount || 0,\n              userHasPremiumTier(loggedInUser, PremiumTier.ONE),\n            ),\n            {\n              icon: 'comment-outline',\n              label: buildStatisticsLabel({\n                stat: commentsCount || 0,\n                statUnit: 'comment',\n                usePlural: true,\n              }),\n              stat: commentsCount || 0,\n            },\n            {\n              icon: 'update',\n              label: buildStatisticsLabel({\n                stat: item.steps.length,\n                statUnit: 'step',\n                usePlural: true,\n              }),\n              stat: item.steps.length,\n            },\n          ]}\n          alwaysShow\n        />\n      </Flex>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/Content/Page/LibraryStep.tsx",
    "content": "import { ImageGallery, LinkifyText, VideoPlayer } from 'oa-components';\nimport type { ProjectStep } from 'oa-shared';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { formatImagesForGallery } from 'src/utils/formatImageListForGallery';\nimport { Box, Card, Flex, Heading, Text } from 'theme-ui';\n\ninterface IProps {\n  step: ProjectStep;\n  stepindex: number;\n}\n\nconst Step = (props: IProps) => {\n  const { stepindex, step } = props;\n\n  const displayNumber = stepindex + 1;\n\n  return (\n    <Flex\n      data-cy={`step_${displayNumber}`}\n      sx={{\n        flexDirection: ['column', 'column', 'row'],\n        gap: 2,\n        paddingX: [2, 0],\n      }}\n    >\n      <Flex\n        sx={{\n          alignItems: 'center',\n          flexDirection: ['row', 'row', 'column'],\n        }}\n      >\n        <Card sx={{ padding: [2, 3, 4] }}>\n          <Heading sx={{ textAlign: 'center' }}>{displayNumber}</Heading>\n        </Card>\n      </Flex>\n\n      <Flex\n        sx={{\n          flex: 1,\n          flexDirection: ['column', 'column', 'row'],\n          overflow: 'hidden',\n        }}\n      >\n        <Card sx={{ flex: 1 }}>\n          <Flex\n            sx={{\n              flexDirection: ['column-reverse', 'column-reverse', 'row'],\n            }}\n          >\n            <Flex\n              sx={{\n                padding: 4,\n                width: ['100%', '100%', `${(1 / 2) * 100}%`],\n                flexDirection: 'column',\n              }}\n            >\n              <Heading as=\"h2\" mb={0} data-cy=\"step-title\">\n                {step.title}\n              </Heading>\n              <Box>\n                <Text\n                  mt={3}\n                  color=\"grey\"\n                  variant=\"paragraph\"\n                  sx={{\n                    wordBreak: 'break-word',\n                    whiteSpace: 'pre-line',\n                  }}\n                  data-cy=\"step-text\"\n                >\n                  <LinkifyText>{step.description}</LinkifyText>\n                </Text>\n              </Box>\n            </Flex>\n            <Box sx={{ width: ['100%', '100%', `${(1 / 2) * 100}%`] }}>\n              {step.videoUrl ? (\n                <ClientOnly fallback={<></>}>\n                  {() => <VideoPlayer videoUrl={step.videoUrl!} />}\n                </ClientOnly>\n              ) : step.images ? (\n                <ImageGallery\n                  images={formatImagesForGallery(step.images, `Step ${displayNumber}`) as any}\n                />\n              ) : null}\n            </Box>\n          </Flex>\n        </Card>\n      </Flex>\n    </Flex>\n  );\n};\n\nexport default Step;\n"
  },
  {
    "path": "src/pages/Library/Content/Page/ProjectPage.tsx",
    "content": "import { observer } from 'mobx-react';\nimport {\n  ArticleCallToActionSupabase,\n  Button,\n  UsefulStatsButton,\n  UserEngagementWrapper,\n} from 'oa-components';\nimport type { Project, ProjectStep } from 'oa-shared';\nimport { useMemo, useState } from 'react';\nimport { Link } from 'react-router';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { trackEvent } from 'src/common/Analytics';\nimport { DonationRequestModalContainer } from 'src/common/DonationRequestModalContainer';\nimport PageHeader from 'src/common/PageHeader';\nimport { Breadcrumbs } from 'src/pages/common/Breadcrumbs/Breadcrumbs';\nimport { CommentSectionSupabase } from 'src/pages/common/CommentsSupabase/CommentSectionSupabase';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { useUsefulVote } from 'src/stores/UsefulVote/useUsefulVote';\nimport { hasAdminRights } from 'src/utils/helpers';\nimport { Card, Flex } from 'theme-ui';\nimport { LibraryDescription } from './LibraryDescription';\nimport Step from './LibraryStep';\n\ninterface ProjectPageProps {\n  item: Project;\n}\n\nexport const ProjectPage = observer(({ item }: ProjectPageProps) => {\n  const { profile: activeUser } = useProfileStore();\n  const {\n    hasVoted,\n    usefulCount,\n    toggle: toggleVote,\n  } = useUsefulVote('projects', item.id, item.usefulCount);\n  const [isDonationModalOpen, setIsDonationModalOpen] = useState(false);\n\n  const isEditable = useMemo(() => {\n    return (\n      !!activeUser && (hasAdminRights(activeUser) || item.author?.username === activeUser.username)\n    );\n  }, [activeUser, item.author]);\n\n  return (\n    <>\n      <PageHeader\n        actions={\n          isEditable && (\n            <Link to={'/library/' + item.slug + '/edit'} data-cy=\"edit\">\n              <Button type=\"button\" variant=\"primary\">\n                Edit\n              </Button>\n            </Link>\n          )\n        }\n      >\n        <Breadcrumbs\n          steps={[\n            { text: 'Library', link: '/library' },\n            ...(item.category\n              ? [{ text: item.category.name, link: `/library?category=${item.category.id}` }]\n              : []),\n            { text: item.title },\n          ]}\n        />\n      </PageHeader>\n\n      <LibraryDescription\n        item={item}\n        loggedInUser={activeUser}\n        commentsCount={item.commentCount}\n        votedUsefulCount={usefulCount}\n        hasUserVotedUseful={hasVoted}\n        onUsefulClick={toggleVote}\n      />\n      <Flex sx={{ flexDirection: 'column', marginTop: [3, 4], gap: 4 }}>\n        {item.steps\n          .sort((a, b) => a.order - b.order)\n          .map((step: ProjectStep, index: number) => (\n            <Step step={step} key={index} stepindex={index} />\n          ))}\n      </Flex>\n      <ClientOnly fallback={<></>}>\n        {() => (\n          <UserEngagementWrapper>\n            <ArticleCallToActionSupabase author={item.author!}>\n              <Button\n                type=\"button\"\n                sx={{ fontSize: 2, justifyContent: 'center' }}\n                onClick={() => {\n                  document\n                    .querySelector('[data-target=\"create-comment-container\"]')\n                    ?.scrollIntoView({\n                      behavior: 'smooth',\n                    });\n                  (\n                    document.querySelector('[data-cy=\"comments-form\"]') as HTMLTextAreaElement\n                  )?.focus();\n\n                  return false;\n                }}\n              >\n                Leave a comment\n              </Button>\n              {item.moderation === 'accepted' && (\n                <UsefulStatsButton\n                  hasUserVotedUseful={hasVoted}\n                  isLoggedIn={!!activeUser}\n                  onUsefulClick={toggleVote}\n                />\n              )}\n              {item.author?.profileType?.isSpace && item.author?.donationsEnabled && (\n                <>\n                  <DonationRequestModalContainer\n                    profileId={item.author?.id}\n                    isOpen={isDonationModalOpen}\n                    onDidDismiss={() => setIsDonationModalOpen(false)}\n                  />\n                  <Button\n                    icon=\"donate\"\n                    variant=\"outline\"\n                    iconColor=\"primary\"\n                    sx={{ fontSize: '14px', backgroundColor: '#fff' }}\n                    onClick={() => {\n                      trackEvent({\n                        action: 'donationModalOpened',\n                        category: 'projects',\n                        label: item.author?.username || '',\n                      });\n                      setIsDonationModalOpen(true);\n                    }}\n                  >\n                    Support the author\n                  </Button>\n                </>\n              )}\n            </ArticleCallToActionSupabase>\n            <Card\n              sx={{\n                background: 'softblue',\n                gap: 2,\n                padding: 3,\n                width: ['100%', '100%', `90%`, `${(2 / 3) * 100}%`],\n                margin: '0 auto',\n                mt: 5,\n              }}\n            >\n              <CommentSectionSupabase\n                authors={item.author?.id ? [item.author?.id] : []}\n                sourceId={item.id}\n                sourceType=\"projects\"\n              />\n            </Card>\n          </UserEngagementWrapper>\n        )}\n      </ClientOnly>\n    </>\n  );\n});\n"
  },
  {
    "path": "src/pages/Library/Content/utils/downloadCooldown.ts",
    "content": "interface localStorageExpiry {\n  id: string;\n  expiry: number;\n}\n\nexport const retrieveLocalStorageArray = (): localStorageExpiry[] => {\n  const downloadCooldownArray: string | null = localStorage.getItem('downloadCooldown');\n  if (typeof downloadCooldownArray === 'string') {\n    return JSON.parse(downloadCooldownArray);\n  } else {\n    return [] as localStorageExpiry[];\n  }\n};\n\nexport const retrieveLibraryDownloadCooldown = (id: string): localStorageExpiry | undefined => {\n  const downloadCooldownArray = retrieveLocalStorageArray();\n  if (downloadCooldownArray) {\n    return downloadCooldownArray.find((elem) => elem.id === id);\n  }\n};\n\nexport const isLibraryDownloadCooldownExpired = (cooldown: localStorageExpiry): boolean => {\n  const now = new Date();\n  if (now.getTime() > cooldown.expiry) {\n    return true;\n  } else {\n    return false;\n  }\n};\n\nexport const CreateLibraryExpiryObject = (id: string): localStorageExpiry => {\n  const now = new Date();\n  const twelveHoursInMiliseconds = 12 * 60 * 60 * 1000;\n  return {\n    id,\n    expiry: now.getTime() + twelveHoursInMiliseconds,\n  };\n};\n\nexport const addLibraryDownloadCooldown = (id: string) => {\n  const downloadCooldownArray = retrieveLocalStorageArray();\n  const expiryObject = CreateLibraryExpiryObject(id);\n\n  downloadCooldownArray.push(expiryObject);\n  localStorage.setItem('downloadCooldown', JSON.stringify(downloadCooldownArray));\n};\n\nexport const updateLibraryDownloadCooldown = (id: string) => {\n  const downloadCooldownArray = retrieveLocalStorageArray();\n  const expiryObject = CreateLibraryExpiryObject(id);\n  const foundIndex = downloadCooldownArray.findIndex((elem) => elem.id === id);\n\n  downloadCooldownArray[foundIndex] = expiryObject;\n  localStorage.setItem('downloadCooldown', JSON.stringify(downloadCooldownArray));\n};\n"
  },
  {
    "path": "src/pages/Library/Content/utils/index.ts",
    "content": "export { transformLibraryErrors } from './transformLibraryErrors';\n"
  },
  {
    "path": "src/pages/Library/Content/utils/transformLibraryErrors.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { intro, steps } from '../../labels';\nimport { transformLibraryErrors } from './transformLibraryErrors';\n\ndescribe('transformLibraryErrors', () => {\n  describe('introErrors', () => {\n    it('transforms a shallow list of intro errors into a set', () => {\n      const errors = {\n        notReal: 'anything',\n        title: 'missing',\n        category: 'add a category',\n      };\n\n      const expected = [\n        {\n          errors,\n          title: 'Intro',\n          keys: ['title', 'category'],\n          labels: intro,\n        },\n      ];\n\n      expect(transformLibraryErrors(errors)).toEqual(expected);\n    });\n  });\n\n  describe('stepErrors', () => {\n    it('transforms how final-forms provides steps errors into a set', () => {\n      const errors = {\n        steps: [\n          {},\n          {\n            title: 'missing',\n            text: 'no description',\n          },\n          {\n            title: 'missing',\n          },\n        ],\n      };\n\n      const expectedStepTwo = {\n        errors: { ...errors.steps[1] },\n        title: 'Step 2',\n        keys: ['title', 'text'],\n        labels: steps,\n      };\n\n      const expectedStepThree = {\n        errors: { ...errors.steps[2] },\n        title: 'Step 3',\n        keys: ['title'],\n        labels: steps,\n      };\n\n      const set = transformLibraryErrors(errors);\n      expect(set).toEqual([expectedStepTwo, expectedStepThree]);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/Library/Content/utils/transformLibraryErrors.ts",
    "content": "import type {\n  IErrorsListSet,\n  ILabels,\n  IStepErrorsList,\n  ITopLevelErrorsList,\n} from 'src/common/Form/types';\nimport { intro as introLabels, steps as stepsLabels } from '../../labels';\n\nconst stepErrors = (stepErrors: IStepErrorsList): IErrorsListSet[] => {\n  return stepErrors.map((errors, index) => {\n    const labels = stepsLabels;\n    const title = `${stepsLabels.heading.title} ${index + 1}`;\n    const keys = errors ? Object.keys(errors) : [];\n\n    return { errors, keys, labels, title };\n  });\n};\n\nconst introErrors = (errors: ITopLevelErrorsList): IErrorsListSet => {\n  const labels = introLabels;\n  const title = introLabels.heading.title;\n  const keys = errors ? Object.keys(errors).filter((key) => introLabels[key]) : [];\n\n  return { errors, keys, labels, title };\n};\n\nexport const errorSet = (\n  errorSet: ITopLevelErrorsList | undefined,\n  labels: ILabels,\n): IErrorsListSet => {\n  const errors = errorSet ? errorSet : {};\n  const keys = errors ? Object.keys(errors).filter((key) => labels[key]) : [];\n\n  return { errors, keys, labels };\n};\n\nexport const transformLibraryErrors = (\n  errors: ITopLevelErrorsList | undefined,\n): IErrorsListSet[] => {\n  if (!errors) {\n    return [];\n  }\n\n  const transformedErrorsSet = [introErrors(errors)];\n\n  if (errors.steps && typeof errors.steps !== 'string') {\n    transformedErrorsSet.push(...stepErrors(errors.steps));\n  }\n\n  return transformedErrorsSet.filter(\n    (transformedErrors) => transformedErrors && transformedErrors.keys.length !== 0,\n  );\n};\n"
  },
  {
    "path": "src/pages/Library/constants.ts",
    "content": "export const STEP_DESCRIPTION_MIN_LENGTH = 100;\nexport const STEP_DESCRIPTION_MAX_LENGTH = 1000;\nexport const LIBRARY_DESCRIPTION_MAX_LENGTH = 1000;\nexport const LIBRARY_MIN_REQUIRED_STEPS = 3;\nexport const LIBRARY_TITLE_MIN_LENGTH = 5;\nexport const LIBRARY_TITLE_MAX_LENGTH = 50;\nexport const ITEMS_PER_PAGE = 12; // Should be a multiple of 2 and 3 because the UI can show 2 or 3 columns\n"
  },
  {
    "path": "src/pages/Library/labels.ts",
    "content": "import type { ILabels } from 'src/common/Form/types';\nimport {\n  LIBRARY_DESCRIPTION_MAX_LENGTH,\n  LIBRARY_TITLE_MAX_LENGTH,\n  LIBRARY_TITLE_MIN_LENGTH,\n  STEP_DESCRIPTION_MAX_LENGTH,\n  STEP_DESCRIPTION_MIN_LENGTH,\n} from './constants';\n\nexport const headings = {\n  create: 'Add your project',\n  edit: 'Edit your project',\n  errors: \"Ouch, something's wrong\",\n  files: 'Do you have supporting files to help others replicate your project?',\n  uploading: 'Uploading project',\n};\n\nexport const buttons = {\n  draft: {\n    create: 'Save draft',\n  },\n  files: 'Re-upload files (this will delete the existing ones)',\n  publish: 'Publish',\n  steps: {\n    deleteButton: {\n      title: 'Delete',\n      cancel: 'Cancel',\n      warning: 'Are you sure you want to delete this step?',\n    },\n    add: 'Add step',\n  },\n  view: 'View Project',\n};\n\nexport const errors = {\n  videoUrl: {\n    both: 'Do not include both images and video',\n    empty: 'Include either images or a video',\n    invalidUrl: 'Please provide a valid YouTube Url',\n  },\n};\n\nexport const intro: ILabels = {\n  category: {\n    placeholder: 'Select one category',\n    title: 'Category',\n  },\n  coverImage: {\n    description: 'This image should be landscape. We advise 1280x960px',\n    title: 'Cover image',\n  },\n  description: {\n    description: `Provide a short introduction (max ${LIBRARY_DESCRIPTION_MAX_LENGTH} characters)`,\n    title: 'Short description',\n  },\n  difficultyLevel: {\n    placeholder: 'How hard is it?',\n    title: 'Difficulty level?',\n  },\n  fileLink: {\n    title: 'Or add a download link',\n    description: 'Link to Google Drive, Dropbox, Grabcad etc',\n  },\n  files: {\n    title: 'Or upload your files here',\n    description: 'Maximum file size 50MB',\n    error: 'Please provide either a file link or upload a file, not both.',\n  },\n  heading: {\n    title: 'Intro',\n  },\n  tags: {\n    title: 'Select tags',\n  },\n  time: {\n    placeholder: 'How much time?',\n    title: 'How long does it take?',\n  },\n  title: {\n    placeholder: `Make a chair from... (${LIBRARY_TITLE_MIN_LENGTH} - ${LIBRARY_TITLE_MAX_LENGTH} characters)`,\n    title: 'Title of your project',\n  },\n};\n\nexport const guidance = {\n  guides: {\n    main:\n      '✅ Cover image should show the topic of the guide<br/>' +\n      '👍 Few steps we advise having: ' +\n      '<ol><li>Explain the Guide</li>' +\n      '<li>(As many steps as needed) talk about the process</li>' +\n      '<li>(As many steps as needed) talk about the challenges</li>' +\n      '<li>Tips & tricks</li>' +\n      '<li>Show the final outcome</li></ol>',\n  },\n  machines: {\n    main:\n      '✅ Cover image should show the fully built machine<br/>' +\n      '👍 Few steps we advise having: ' +\n      '<ol><li>Explain the machine</li>' +\n      '<li>Mention tools required</li>' +\n      '<li>Challenges building the machine </li>' +\n      '<li>Explain how to run it</li>' +\n      '<li>Tips & tricks</li>' +\n      '<li>Link to the Bazar if you sell it there</li></ol>',\n  },\n  moulds: {\n    main:\n      '✅ Cover image should show the fully built mould<br/>' +\n      '👍 Few steps we advise having:' +\n      '<ol><li>Explain the mould</li>' +\n      '<li>Mention tools required</li>' +\n      '<li>Challenges producing the mould</li>' +\n      '<li>Explain how to use the mould</li>' +\n      '<li>Tips & tricks</li>' +\n      '<li>Show the final product</li>' +\n      '<li>Link to the Bazar if you sell it there</li></ol>',\n  },\n  products: {\n    main:\n      '✅ Cover image should show the final product<br/>' +\n      '👍 Few steps we advise having: ' +\n      '<ol><li>Explain the product</li>' +\n      '<li>(As many steps as needed) talk about the process</li>' +\n      '<li>(As many steps as needed) challenges making the product</li>' +\n      '<li>Tips & tricks</li>' +\n      '<li>Show the final product</li>' +\n      '<li>Link to the Bazar if you sell it there</li></ol>',\n  },\n};\n\nexport const steps: ILabels = {\n  heading: {\n    description:\n      \"Each step needs an intro, a description and photos or video. You'll need to have <strong>at least three steps</strong>.\",\n    title: 'Step',\n  },\n  title: {\n    title: 'Title of this step',\n    placeholder: `Provide a title (max ${LIBRARY_TITLE_MAX_LENGTH} characters)`,\n  },\n  description: {\n    title: 'Step Description',\n    placeholder: `Explain what you are doing. If it gets too long, consider breaking it into multiple steps (${STEP_DESCRIPTION_MIN_LENGTH}-${STEP_DESCRIPTION_MAX_LENGTH} characters)`,\n  },\n  images: {\n    title: 'Upload image(s) or a video',\n  },\n  videoUrl: {\n    title: 'YouTube video',\n    placeholder: 'https://youtube.com/watch?v=',\n  },\n};\n\nexport const listing = {\n  create: 'Add your project',\n  empty: 'No projects to show!',\n  filterCategory: 'Filter by category',\n  incompleteProfile: 'Complete your profile to add your project',\n  loadMore: 'Load More',\n  loggedOut: 'We need that password of yours before learning about your awesome project...',\n  totalComments: 'Total comments',\n  search: 'Search the library',\n  sort: 'Sort by category',\n  usefulness: 'How useful it is',\n};\n"
  },
  {
    "path": "src/pages/Library/library.service.ts",
    "content": "import { DBMedia, type Project, ProjectDTO, type ProjectFormData } from 'oa-shared';\nimport { logger } from 'src/logger';\nimport { createFormData } from 'src/services/formDataHelper';\nimport type { LibrarySortOption } from './Content/List/LibrarySortOptions';\n\nexport enum LibrarySearchParams {\n  category = 'category',\n  q = 'q',\n  sort = 'sort',\n}\n\nconst search = async (q: string, category: string, sort: LibrarySortOption, skip: number = 0) => {\n  try {\n    const url = new URL('/api/projects', window.location.origin);\n    url.searchParams.append('q', q);\n    url.searchParams.append('category', category);\n    url.searchParams.append('sort', sort);\n    url.searchParams.append('skip', skip.toString());\n\n    const response = await fetch(url);\n    const { items, total } = (await response.json()) as {\n      items: Project[];\n      total: number;\n    };\n\n    return { items, total };\n  } catch (error) {\n    logger.error('Failed to fetch projects', { error });\n    return { items: [], total: 0 };\n  }\n};\n\nconst getDraftCount = async () => {\n  try {\n    const response = await fetch('/api/projects/drafts/count');\n    const { total } = (await response.json()) as { total: number };\n\n    return total;\n  } catch (error) {\n    logger.error('Failed to fetch draft count', { error });\n    return 0;\n  }\n};\n\nconst getDrafts = async () => {\n  try {\n    const response = await fetch('/api/projects/drafts');\n    const { items } = (await response.json()) as { items: Project[] };\n\n    return items;\n  } catch (error) {\n    logger.error('Failed to fetch project draft articles', { error });\n    return [];\n  }\n};\n\nconst upsert = async (id: number | null, formData: ProjectFormData, isDraft = false) => {\n  const data = createFormData<ProjectDTO>({\n    title: formData.title,\n    description: formData.description,\n    category: Number(formData.category?.value) || null,\n    coverImage: formData.coverImage ? DBMedia.fromPublicMedia(formData.coverImage) : null,\n    difficultyLevel: formData.difficultyLevel,\n    fileLink: formData.fileLink,\n    files: formData.files,\n    tags: formData.tags,\n    time: formData.time,\n    stepCount: formData.steps.length || 0,\n    isDraft: isDraft,\n  });\n\n  if (formData.steps?.length) {\n    for (let i = 0; i < formData.steps.length; i++) {\n      const step = formData.steps[i];\n      if (step.id) {\n        data.append(`steps.[${i}].id`, step.id.toString());\n      }\n      data.append(`steps.[${i}].title`, step.title);\n      data.append(`steps.[${i}].description`, step.description);\n\n      // Only send existing image metadata (images are uploaded immediately)\n      const stepImages = step.images || step.images || [];\n      if (stepImages && stepImages.length > 0) {\n        for (const image of stepImages) {\n          if (image?.path) {\n            data.append(`steps.[${i}].images`, JSON.stringify(image));\n          }\n        }\n      }\n      if (step.videoUrl) {\n        data.append(`steps.[${i}].videoUrl`, step.videoUrl);\n      }\n    }\n  }\n\n  const response =\n    id === null\n      ? await fetch(`/api/projects`, {\n          method: 'POST',\n          body: data,\n        })\n      : await fetch(`/api/projects/${id}`, {\n          method: 'PUT',\n          body: data,\n        });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error saving the project' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving the project';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  return (await response.json()) as { project: Project };\n};\n\nconst deleteProject = async (id: number) => {\n  const response = await fetch(`/api/projects/${id}`, {\n    method: 'DELETE',\n  });\n\n  if (response.status !== 200 && response.status !== 201) {\n    throw new Error('Error deleting project', { cause: 500 });\n  }\n};\n\nexport const libraryService = {\n  search,\n  getDrafts,\n  getDraftCount,\n  upsert,\n  deleteProject,\n};\n"
  },
  {
    "path": "src/pages/Maps/Content/MapView/ButtonZoomIn.client.tsx",
    "content": "import { Button } from 'oa-components';\nimport type { ILatLng } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { Tooltip } from 'react-tooltip';\nimport { logger } from 'src/logger';\nimport { GetLocation } from '../../utils/geolocation';\n\ninterface IProps {\n  setCenter: (value: ILatLng) => void;\n  setZoom: (value: number) => void;\n}\n\nconst ZOOM_IN_TOOLTIP = 'Zoom in to your location';\nconst DENIED_TOOLTIP = 'Request to get your location already denied';\n\nexport const ButtonZoomIn = ({ setCenter, setZoom }: IProps) => {\n  const [isDisabled, setIsDisabled] = useState<boolean>(false);\n\n  useEffect(() => {\n    navigator.permissions.query({ name: 'geolocation' }).then((result) => {\n      if (result.state === 'denied') {\n        setIsDisabled(true);\n      }\n    });\n  }, []);\n\n  const promptUserLocation = async () => {\n    try {\n      const position = await GetLocation();\n      setCenter({\n        lat: position.coords.latitude,\n        lng: position.coords.longitude,\n      });\n    } catch (error) {\n      if (error === 'User denied geolocation prompt') {\n        setIsDisabled(true);\n      }\n      logger.error(error);\n    }\n  };\n\n  const sx = {\n    backgroundColor: 'white',\n    borderRadius: 99,\n    padding: 4,\n    ':hover': {\n      backgroundColor: 'lightgray',\n    },\n  };\n\n  return (\n    <>\n      <Button\n        data-tooltip-content={isDisabled ? DENIED_TOOLTIP : ZOOM_IN_TOOLTIP}\n        data-cy=\"LocationViewButton\"\n        data-tooltip-id=\"locationButton-tooltip\"\n        sx={sx}\n        onClick={() => {\n          promptUserLocation();\n          setZoom(9);\n        }}\n        disabled={isDisabled}\n        icon=\"gps-location\"\n      />\n      <Tooltip id=\"locationButton-tooltip\" place=\"left\" />\n    </>\n  );\n};\n"
  },
  {
    "path": "src/pages/Maps/Content/MapView/Cluster.client.tsx",
    "content": "import L from 'leaflet';\nimport { type MarkerCluster, MarkerClusterGroup } from 'leaflet.markercluster';\nimport type { MapPin } from 'oa-shared';\nimport { type RefObject, useEffect, useRef } from 'react';\nimport { useMap } from 'react-leaflet';\nimport { createClusterIcon, createMarkerIcon } from './Sprites';\n\ninterface IProps {\n  pins: MapPin[];\n  onPinClick: (pin: MapPin) => void;\n  onClusterClick: (cluster: MarkerCluster) => void;\n  clusterGroupRef?: RefObject<any>;\n}\n\n/**\n * Manages map pin clusters using Leaflet's MarkerClusterGroup directly.\n * Markers are added/removed differentially to avoid the full clear+re-add\n * cycle that causes visible blinking.\n *\n * @see https://github.com/Leaflet/Leaflet.markercluster#clusters-methods\n */\nexport const Clusters = ({ pins, onPinClick, onClusterClick, clusterGroupRef }: IProps) => {\n  const map = useMap();\n  const groupRef = useRef<MarkerClusterGroup | null>(null);\n  const markersRef = useRef<Map<string, L.Marker>>(new Map());\n\n  // Keep callbacks in refs so the cluster group and markers always invoke the\n  // latest version without needing to tear down and recreate Leaflet objects.\n  const onPinClickRef = useRef(onPinClick);\n  const onClusterClickRef = useRef(onClusterClick);\n  onPinClickRef.current = onPinClick;\n  onClusterClickRef.current = onClusterClick;\n\n  const iconCreateFn = createClusterIcon();\n  const iconCreateFnRef = useRef(iconCreateFn);\n  iconCreateFnRef.current = iconCreateFn;\n\n  // Create the MarkerClusterGroup once and attach it to the map.\n  useEffect(() => {\n    const group = new MarkerClusterGroup({\n      iconCreateFunction: (cluster) => iconCreateFnRef.current(cluster),\n      showCoverageOnHover: false,\n      spiderfyOnMaxZoom: true,\n      maxClusterRadius: 54,\n    });\n\n    group.on('clusterclick', (e: any) => {\n      onClusterClickRef.current(e.layer);\n    });\n\n    map.addLayer(group);\n    groupRef.current = group;\n\n    if (clusterGroupRef) {\n      (clusterGroupRef as React.MutableRefObject<any>).current = group;\n    }\n\n    return () => {\n      map.removeLayer(group);\n      markersRef.current.clear();\n      groupRef.current = null;\n      if (clusterGroupRef) {\n        (clusterGroupRef as React.MutableRefObject<any>).current = null;\n      }\n    };\n  }, [map, clusterGroupRef]);\n\n  // Differential marker updates — only add/remove changed pins.\n  useEffect(() => {\n    const group = groupRef.current;\n    if (!group) return;\n\n    const currentMarkers = markersRef.current;\n    const validPins = pins.filter((p) => Boolean(p.lat));\n    const newPinIds = new Set(validPins.map((p) => String(p.id)));\n\n    // Collect markers to remove\n    const toRemove: L.Marker[] = [];\n    for (const [id, marker] of currentMarkers) {\n      if (!newPinIds.has(id)) {\n        toRemove.push(marker);\n        currentMarkers.delete(id);\n      }\n    }\n\n    // Collect markers to add\n    const toAdd: L.Marker[] = [];\n    for (const pin of validPins) {\n      const id = String(pin.id);\n      if (!currentMarkers.has(id)) {\n        const marker = L.marker([pin.lat, pin.lng], {\n          icon: createMarkerIcon(pin),\n        });\n        marker.on('click', () => onPinClickRef.current(pin));\n        currentMarkers.set(id, marker);\n        toAdd.push(marker);\n      }\n    }\n\n    if (toRemove.length) group.removeLayers(toRemove);\n    if (toAdd.length) group.addLayers(toAdd);\n  }, [pins]);\n\n  // No React children — markers are managed imperatively above.\n  return null;\n};\n"
  },
  {
    "path": "src/pages/Maps/Content/MapView/MapList.tsx",
    "content": "import { Button } from 'oa-components';\nimport { useContext } from 'react';\nimport { Box, Flex } from 'theme-ui';\n\nimport { MapContext } from '../../MapContext';\nimport { MapWithListHeader } from './MapWithListHeader';\n\nexport const MapList = () => {\n  const mapState = useContext(MapContext);\n\n  if (!mapState) {\n    return null;\n  }\n\n  const mobileListDisplay = mapState.isMobile ? 'block' : 'none';\n\n  return (\n    <>\n      {/* Desktop list view */}\n      <Box\n        sx={{\n          display: ['none', 'none', 'block', 'block'],\n          background: 'white',\n          flex: 1,\n          overflowY: 'auto',\n          overflowX: 'hidden',\n        }}\n      >\n        <MapWithListHeader viewport=\"desktop\" />\n      </Box>\n\n      {/* Mobile/tablet list view */}\n      <Box\n        sx={{\n          display: [mobileListDisplay, mobileListDisplay, 'none', 'none'],\n          background: 'white',\n          width: '100%',\n          overflow: 'auto',\n        }}\n      >\n        <Flex\n          sx={{\n            justifyContent: 'center',\n            paddingBottom: 2,\n            position: 'absolute',\n            zIndex: 2,\n            width: '100%',\n          }}\n        >\n          <Button\n            data-cy=\"ShowMapButton\"\n            icon=\"map\"\n            sx={{ position: 'sticky', marginTop: 2 }}\n            onClick={() => mapState.setIsMobile(false)}\n            small\n          >\n            Show map view\n          </Button>\n        </Flex>\n        <MapWithListHeader viewport=\"mobile\" />\n      </Box>\n    </>\n  );\n};\n"
  },
  {
    "path": "src/pages/Maps/Content/MapView/MapView.tsx",
    "content": "import type { LatLngExpression, Map as LeafletMap } from 'leaflet';\n// biome-ignore lint/suspicious/noShadowRestrictedNames: this is an external library import\nimport { Button, Map } from 'oa-components';\nimport { useCallback, useContext, useEffect, useRef } from 'react';\nimport { useMapEvents } from 'react-leaflet';\nimport { Box, Flex } from 'theme-ui';\nimport { MapContext } from '../../MapContext';\nimport { ButtonZoomIn } from './ButtonZoomIn.client';\nimport { Clusters } from './Cluster.client';\nimport { Popup } from './Popup.client';\n\n// Component to handle map events\nfunction MapEventsHandler({\n  onLocationChange,\n  onMapClick,\n}: {\n  onLocationChange: () => void;\n  onMapClick: () => void;\n}) {\n  useMapEvents({\n    dragend: onLocationChange,\n    zoomend: onLocationChange,\n    resize: onLocationChange,\n    click: onMapClick,\n  });\n  return null;\n}\n\nexport const MapView = () => {\n  const mapState = useContext(MapContext);\n  const mapRef = useRef<LeafletMap>(null);\n  const clusterGroupRef = useRef<any>(null);\n\n  // Extract stable references — useState setters and useCallback-wrapped\n  // functions from the context never change identity.\n  const setBoundaries = mapState?.setBoundaries;\n  const selectPin = mapState?.selectPin;\n  const fitBounds = mapState?.fitBounds;\n\n  useEffect(() => {\n    if (mapRef.current && mapState) {\n      mapState.setMapRef(mapRef.current);\n    }\n  }, [mapRef.current, mapState]);\n\n  useEffect(() => {\n    if (clusterGroupRef.current && mapState) {\n      mapState.setClusterGroupRef(clusterGroupRef.current);\n    }\n  }, [clusterGroupRef.current, mapState]);\n\n  // All hooks must be declared before any early return (Rules of Hooks).\n  const handleLocationChange = useCallback(() => {\n    if (mapRef.current && setBoundaries) {\n      setBoundaries(mapRef.current.getBounds());\n    }\n  }, [setBoundaries]);\n\n  const handleMapClick = useCallback(() => {\n    selectPin?.(null);\n  }, [selectPin]);\n\n  const handleClusterClick = useCallback(\n    (cluster: any) => {\n      fitBounds?.(cluster.getBounds());\n    },\n    [fitBounds],\n  );\n\n  const handlePinClose = useCallback(() => {\n    selectPin?.(null);\n  }, [selectPin]);\n\n  if (!mapState) {\n    return null;\n  }\n\n  const isViewportGreaterThanTablet = window.innerWidth > 1024;\n  const mapCenter: LatLngExpression = mapState.location\n    ? [mapState.location.lat, mapState.location.lng]\n    : [0, 0];\n\n  return (\n    <Box className=\"markercluster-map\" sx={{ flex: 1 }}>\n      <Map\n        ref={mapRef}\n        center={mapCenter}\n        zoom={mapState.zoom}\n        setZoom={mapState.setZoom}\n        maxZoom={18}\n        style={{ flex: 1, backgroundColor: '#AAD3DF', height: '100%', width: '100%' }}\n        zoomControl={isViewportGreaterThanTablet}\n      >\n        <MapEventsHandler onLocationChange={handleLocationChange} onMapClick={handleMapClick} />\n        <Box\n          sx={{\n            position: 'absolute',\n            top: 0,\n            right: 0,\n            padding: 4,\n            zIndex: 1001,\n            display: 'flex',\n            flexDirection: 'column',\n            gap: 2,\n          }}\n        >\n          <ButtonZoomIn\n            setCenter={(value) => mapState.setLocation(value)}\n            setZoom={mapState.setZoom}\n          />\n        </Box>\n\n        <Flex\n          sx={{\n            flexDirection: 'column',\n            alignItems: 'center',\n            padding: 2,\n            gap: 2,\n          }}\n        >\n          <Button\n            data-cy=\"ShowMobileListButton\"\n            icon=\"step\"\n            sx={{ display: ['flex', 'flex', 'none'], zIndex: 1000 }}\n            onClick={() => mapState.setIsMobile(true)}\n            small\n          >\n            Show list view\n          </Button>\n        </Flex>\n        {mapState.mapPins && mapState.mapPins.length > 0 && (\n          <Clusters\n            pins={mapState.mapPins}\n            onPinClick={mapState.selectPinWithClusterCheck}\n            onClusterClick={handleClusterClick}\n            clusterGroupRef={clusterGroupRef}\n          />\n        )}\n        {mapState.selectedPin && (\n          <Popup activePin={mapState.selectedPin} mapRef={mapRef} onClose={handlePinClose} />\n        )}\n      </Map>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/Maps/Content/MapView/MapWithListHeader.tsx",
    "content": "import { LatLngBounds } from 'leaflet';\nimport { Button, Loader, MapCardList, Modal, OsmGeocoding } from 'oa-components';\nimport { useContext, useState } from 'react';\nimport { Flex, Text } from 'theme-ui';\nimport { MapContext } from '../../MapContext';\nimport { MapFilterList } from '../../MapFilterList';\nimport { MemberTypeList } from '../MemberTypeVerticalList/MemberTypeVerticalList.client';\n\ninterface IProps {\n  viewport: 'desktop' | 'mobile';\n}\n\nexport const MapWithListHeader = ({ viewport }: IProps) => {\n  const mapState = useContext(MapContext);\n  const [showFilters, setShowFilters] = useState<boolean>(false);\n\n  const isMobile = viewport === 'mobile';\n\n  const toggleFilterModal = () => setShowFilters(!showFilters);\n\n  if (!mapState) {\n    return null;\n  }\n\n  const hasFiltersSelected =\n    !!mapState.activeBadgeFilters.length ||\n    !!mapState.activeProfileSettingFilters.length ||\n    !!mapState.activeProfileTypeFilters.length ||\n    !!mapState.activeTagFilters.length;\n\n  if (mapState.loadingMessage) {\n    return (\n      <Flex\n        sx={{ background: 'background', height: '100%', width: '100%', justifyContent: 'center' }}\n      >\n        <Loader label={mapState.loadingMessage} sx={{ alignSelf: 'center' }} />\n      </Flex>\n    );\n  }\n\n  return (\n    <>\n      <Modal\n        onDismiss={toggleFilterModal}\n        isOpen={showFilters}\n        sx={{ width: ['350px', '600px'], minWidth: '350px', padding: '0 !important' }}\n      >\n        {mapState?.allPins && <MapFilterList onClose={toggleFilterModal} />}\n      </Modal>\n\n      <Flex\n        sx={{\n          flexDirection: 'column',\n          backgroundColor: 'background',\n          gap: 2,\n          paddingY: 2,\n          paddingTop: isMobile ? '50px' : 6,\n        }}\n      >\n        <Flex sx={{ paddingX: 4, gap: 2, flexDirection: 'row' }}>\n          <OsmGeocoding\n            callback={({ boundingbox }) => {\n              if (boundingbox) {\n                const bounds = new LatLngBounds(\n                  [parseFloat(boundingbox[0]), parseFloat(boundingbox[2])],\n                  [parseFloat(boundingbox[1]), parseFloat(boundingbox[3])],\n                );\n                mapState.selectPin(null);\n                mapState.fitBounds(bounds);\n                mapState.setIsMobile(false);\n              }\n            }}\n            countrycodes=\"\"\n            acceptLanguage=\"en\"\n            placeholder=\"Search for a place\"\n          />\n          <Button\n            data-cy=\"MapFilterList-OpenButton\"\n            icon=\"sliders\"\n            onClick={toggleFilterModal}\n            variant={hasFiltersSelected ? 'primary' : 'outline'}\n            sx={{ backgroundColor: '#fff' }}\n          >\n            <Text sx={{ paddingRight: 2 }}>Filter</Text>\n          </Button>\n        </Flex>\n\n        <MemberTypeList />\n      </Flex>\n      {mapState && (\n        <MapCardList\n          list={mapState.filteredPins}\n          onPinClick={(pin) => {\n            mapState.selectPinWithClusterCheck(pin);\n          }}\n          selectedPin={mapState.selectedPin}\n          viewport={viewport}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "src/pages/Maps/Content/MapView/Popup.client.tsx",
    "content": "import type { Map as LeafletMap, Popup as LeafletPopupType } from 'leaflet';\nimport { Point } from 'leaflet';\nimport { PinProfile } from 'oa-components';\nimport type { ILatLng, MapPin } from 'oa-shared';\nimport React, { useEffect, useRef } from 'react';\nimport { Popup as LeafletPopup } from 'react-leaflet';\n\nimport './popup.css';\n\ninterface IProps {\n  activePin: MapPin;\n  mapRef: React.RefObject<LeafletMap | null>;\n  onClose?: () => void;\n  customPosition?: ILatLng;\n}\n\nexport const Popup = (props: IProps) => {\n  const leafletRef = useRef<LeafletPopupType>(null);\n  const { mapRef, onClose, customPosition } = props;\n\n  useEffect(() => {\n    openPopup();\n  }, [props]);\n\n  // HACK - as popup is created dynamically want to be able to trigger\n  // open on props change\n  const openPopup = () => {\n    if (leafletRef.current && mapRef.current) {\n      leafletRef.current.openOn(mapRef.current);\n    }\n  };\n\n  if (!props.activePin?.lat) {\n    return null;\n  }\n\n  return (\n    <LeafletPopup\n      ref={leafletRef}\n      position={customPosition ? customPosition : [props.activePin.lat, props.activePin.lng]}\n      offset={new Point(2, -10)}\n      closeOnClick={false}\n      closeOnEscapeKey={false}\n      closeButton={false}\n      minWidth={250}\n      maxWidth={300}\n      autoPan={false}\n    >\n      {onClose && <PinProfile item={props.activePin} onClose={onClose} />}\n    </LeafletPopup>\n  );\n};\n"
  },
  {
    "path": "src/pages/Maps/Content/MapView/Sprites.tsx",
    "content": "import { divIcon, point } from 'leaflet';\nimport { MarkerCluster } from 'leaflet.markercluster';\nimport type { MapPin } from 'oa-shared';\nimport { useEffect, useRef } from 'react';\nimport clusterIcon from 'src/assets/icons/map-cluster.svg';\nimport AwaitingModerationHighlight from 'src/assets/icons/map-unpproved-pin.svg';\n\nimport './sprites.css';\n\n/**\n * Generate custom cluster icon, including style formatting, size, image etc.\n * @param opts - optional parameters could be passed from parent,\n * such as total pins. Currently none used, but retaining\n */\nexport const createClusterIcon = () => {\n  const iconAsStringRef = useRef<string>('');\n\n  useEffect(() => {\n    // Resolve CSS variable to actual hex for SVG attribute replacement\n    // (SVG attributes like fill=\"#...\" don't support CSS variables)\n    const resolved = getComputedStyle(document.documentElement)\n      .getPropertyValue('--color-primary')\n      .trim();\n\n    fetch(clusterIcon)\n      .then((response) => response.text())\n      .then((data) => {\n        iconAsStringRef.current = data.replaceAll(\n          /#([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})/g,\n          resolved,\n        );\n      })\n      .catch((fetchError) => console.error(fetchError));\n  }, []);\n\n  return (cluster: MarkerCluster) => {\n    const className = ['icon'];\n    let icon: any;\n    let outlineSize: number = 0;\n    const clusterChildCount: number = cluster.getChildCount();\n    if (clusterChildCount > 1) {\n      className.push('icon-cluster-many');\n      icon = iconAsStringRef.current;\n      // Calcute Outline CSS\n      if (clusterChildCount > 49) {\n        outlineSize = 24;\n      } else {\n        outlineSize = 4 + ((clusterChildCount - 2) / 50) * 20;\n      }\n    } else if (clusterChildCount === 1) {\n      const childMarkers = cluster.getAllChildMarkers();\n      const firstPin = childMarkers[0]?.options as any;\n      icon = firstPin?.icon ? `<img src=\"${firstPin.icon}\" />` : '';\n    }\n    const { fontSize, iconSize, lineHeight } = getClusterSizes(cluster);\n\n    const borderRadius = lineHeight / 2;\n\n    return divIcon({\n      html: `${icon}<span class=\"icon-cluster-text\" style=\"\n        background: var(--color-primary);\n        font-size: ${fontSize}px;\n        line-height: ${lineHeight}px;\n        border-radius: ${borderRadius}px;\n        outline: ${outlineSize}px solid color-mix(in srgb, var(--color-primary) 50%, transparent);\n        \">${clusterChildCount}</span>`,\n      className: className.join(' '),\n      iconSize: point(iconSize, iconSize, true),\n    });\n  };\n};\n\nexport const createMarkerIcon = (pin: MapPin, draggable?: boolean) => {\n  const icon =\n    pin.moderation === 'accepted'\n      ? pin.profile!.type?.smallImageUrl || clusterIcon\n      : AwaitingModerationHighlight;\n  return divIcon({\n    className: `icon-marker icon-${pin.profile!.type}`,\n    html: `<img data-cy=\"pin-${pin.profile.username}\" src=\"${icon}\" style=\"${draggable ? 'cursor: grab' : ''}\" />`,\n    iconSize: point(38, 38, true),\n  });\n};\n\n/**\n * Depending on size of cluster, return range for font and icon sizes\n * to scale cluster depending on value and ensure fits in icon\n * @param cluster - MarkerCluster passed from creation function\n */\nconst getClusterSizes = (cluster: MarkerCluster) => {\n  const count = cluster.getChildCount();\n  const order = Math.round(count).toString().length;\n  switch (order) {\n    case 1:\n      return {\n        fontSize: 18,\n        iconSize: 26,\n        lineHeight: 22,\n      };\n    case 2:\n      return {\n        fontSize: 18,\n        iconSize: 32,\n        lineHeight: 28,\n      };\n\n    default:\n      return {\n        fontSize: 18,\n        iconSize: 44,\n        lineHeight: 40,\n      };\n  }\n};\n"
  },
  {
    "path": "src/pages/Maps/Content/MapView/popup.css",
    "content": ".markercluster-map .leaflet-popup-content {\n  margin: 0;\n  z-index: 99991;\n}\n\n.markercluster-map .leaflet-popup-content p {\n  margin: 0;\n}\n\n.markercluster-map .leaflet-popup-content-wrapper {\n  background: none;\n\n  border-radius: 0;\n  box-shadow: none;\n  padding: 0;\n}\n\n.markercluster-map .leaflet-popup-tip-container .leaflet-popup-tip {\n  box-shadow: 0px 0px 2px grey;\n}\n\n.leaflet-popup-tip-container {\n  display: none;\n}\n"
  },
  {
    "path": "src/pages/Maps/Content/MapView/sprites.css",
    "content": ".icon-marker {\n  line-height: 26px;\n  text-align: center;\n  color: white;\n  padding-left: 1px;\n  border-radius: 50%;\n}\n\n/* To make sure images are not\nbigger than image container */\n.icon-marker img {\n  width: 100%;\n}\n\n.icon-cluster-text {\n  position: absolute;\n  left: 0;\n  text-align: center;\n  width: 100%;\n  height: 100%;\n}\n"
  },
  {
    "path": "src/pages/Maps/Content/MemberTypeVerticalList/MemberTypeVerticalList.client.tsx",
    "content": "import { CardButton, MemberBadge, VerticalList } from 'oa-components';\nimport { useContext } from 'react';\nimport { Text } from 'theme-ui';\n\nimport { MapContext } from '../../MapContext';\n\nexport const MemberTypeList = () => {\n  const mapState = useContext(MapContext);\n\n  if (!mapState || (mapState.allProfileTypes?.length || 0) < 2) {\n    return null;\n  }\n\n  return (\n    <VerticalList dataCy=\"MemberTypeVerticalList\">\n      {mapState.allProfileTypes.map((profileType, index) => {\n        const isSelected = mapState.activeProfileTypeFilters.includes(profileType.name);\n\n        return (\n          <CardButton\n            data-cy={`MemberTypeVerticalList-Item${isSelected ? '-active' : ''}`}\n            data-testid=\"MemberTypeVerticalList-Item\"\n            title={profileType.displayName}\n            key={index}\n            onClick={() => mapState.toggleActiveProfileTypeFilter(profileType.name)}\n            extrastyles={{\n              background: 'none',\n              flexDirection: 'column',\n              minWidth: '130px',\n              marginX: 1,\n              paddingY: 2,\n              textAlign: 'center',\n              width: '130px',\n              ...(isSelected\n                ? {\n                    borderColor: 'green',\n                    ':hover': { borderColor: 'green' },\n                  }\n                : {\n                    borderColor: 'background',\n                    ':hover': { borderColor: 'background' },\n                  }),\n            }}\n            isSelected={isSelected}\n          >\n            <MemberBadge size={40} profileType={profileType} />\n            <Text variant=\"quiet\" sx={{ fontSize: 1 }}>\n              {profileType.mapPinName}\n            </Text>\n          </CardButton>\n        );\n      })}\n    </VerticalList>\n  );\n};\n"
  },
  {
    "path": "src/pages/Maps/MapContext.ts",
    "content": "import type { LatLngBounds, Map as LeafletMap } from 'leaflet';\nimport type { ILatLng, MapPin, ProfileBadge, ProfileTag, ProfileType } from 'oa-shared';\nimport { createContext } from 'react';\n\nexport const MapContext = createContext<{\n  allPins: MapPin[] | null;\n  allBadges: ProfileBadge[];\n  allTags: ProfileTag[];\n  allProfileTypes: ProfileType[];\n  allProfileSettings: string[];\n  mapPins: MapPin[];\n  filteredPins: MapPin[];\n  activeTagFilters: number[];\n  activeBadgeFilters: string[];\n  activeProfileTypeFilters: string[];\n  activeProfileSettingFilters: string[];\n  location: ILatLng;\n  selectedPin: MapPin | null | undefined;\n  loadingMessage: string;\n  isMobile: boolean;\n  boundaries: LatLngBounds | null;\n  zoom: number;\n  toggleActiveTagFilter: (value: number) => void;\n  toggleActiveBadgeFilter: (value: string) => void;\n  toggleActiveProfileTypeFilter: (value: string) => void;\n  toggleActiveProfileSettingFilter: (value: string) => void;\n  setLocation: (value: ILatLng) => void;\n  selectPin: (value: MapPin | null) => void;\n  selectPinWithClusterCheck: (pin: MapPin) => void;\n  setIsMobile: (value: boolean) => void;\n  setBoundaries: (value: LatLngBounds | null) => void;\n  setZoom: (value: number) => void;\n  setView: (location: ILatLng, zoom: number) => void;\n  panTo: (location: ILatLng) => void;\n  fitBounds: (bounds: LatLngBounds) => void;\n  setMapRef: (ref: LeafletMap) => void;\n  setClusterGroupRef: (ref: any) => void;\n} | null>(null);\n"
  },
  {
    "path": "src/pages/Maps/MapFilterList.tsx",
    "content": "import { Button, ButtonIcon, MapFilterListItem, MemberBadge, UserBadge } from 'oa-components';\nimport { useContext, useMemo } from 'react';\nimport { Checkbox, Flex, Heading, Label, Text } from 'theme-ui';\n\nimport { MapContext } from './MapContext';\n\ntype MapFilterListProps = {\n  onClose: () => void;\n};\n\nexport const MapFilterList = ({ onClose }: MapFilterListProps) => {\n  const mapState = useContext(MapContext);\n\n  if (!mapState) {\n    return null;\n  }\n\n  const visibleTags = useMemo(\n    () =>\n      mapState.allTags.filter(\n        (tag) =>\n          mapState.activeTagFilters.includes(tag.id) ||\n          mapState.filteredPins.some((pin) => pin.profile?.tags?.some((t) => t.id === tag.id)),\n      ),\n    [mapState.allTags, mapState.activeTagFilters, mapState.filteredPins],\n  );\n\n  const visibleBadges = useMemo(\n    () =>\n      mapState.allBadges.filter(\n        (badge) =>\n          mapState.activeBadgeFilters.includes(badge.name) ||\n          mapState.filteredPins.some((pin) =>\n            pin.profile?.badges?.some((b) => b.name === badge.name),\n          ),\n      ),\n    [mapState.allBadges, mapState.activeBadgeFilters, mapState.filteredPins],\n  );\n\n  const pinCount = mapState?.filteredPins?.length || 0;\n  const buttonLabel = `${pinCount} result${pinCount === 1 ? '' : 's'} in current view`;\n\n  return (\n    <Flex\n      data-cy=\"MapFilterList\"\n      sx={{\n        flexDirection: 'column',\n        maxHeight: '100%',\n        overflow: 'auto',\n        position: 'relative',\n      }}\n    >\n      <Flex\n        sx={{\n          alignItems: 'center',\n          borderBottom: '1px solid',\n          gap: 2,\n          justifyContent: 'space-between',\n          padding: 2,\n        }}\n      >\n        <Flex sx={{ flexDirection: 'column' }}>\n          <Heading as=\"h3\" variant=\"small\">\n            Filter what you see\n          </Heading>\n\n          <Text variant=\"quiet\">Zoom out on the map to search the whole world</Text>\n        </Flex>\n\n        <ButtonIcon\n          data-cy=\"MapFilterList-CloseButton\"\n          icon=\"close\"\n          onClick={() => onClose()}\n          sx={{ border: 'none', paddingLeft: 2, paddingRight: 3 }}\n        />\n      </Flex>\n\n      <Flex\n        sx={{\n          flexDirection: 'column',\n          flex: 1,\n          gap: 2,\n          overflow: 'auto',\n          padding: 2,\n        }}\n      >\n        {(mapState.allProfileTypes?.length || 0) > 0 && (\n          <MapFilterListWrapper title=\"Profiles\">\n            {mapState.allProfileTypes.map((profileType, index) => (\n              <MapFilterListItem\n                active={mapState.activeProfileTypeFilters.includes(profileType.name)}\n                key={index}\n                onClick={() => mapState.toggleActiveProfileTypeFilter(profileType.name)}\n                filterType=\"profile\"\n              >\n                <MemberBadge size={30} profileType={profileType} />\n                <Text variant=\"quiet\" sx={{ fontSize: 1 }}>\n                  {profileType.displayName}\n                </Text>\n              </MapFilterListItem>\n            ))}\n          </MapFilterListWrapper>\n        )}\n        {mapState.allTags.length > 0 && (\n          <MapFilterListWrapper title=\"Spaces activities\">\n            {visibleTags.length > 0 ? (\n              visibleTags.map((tag) => (\n                <MapFilterListItem\n                  active={mapState.activeTagFilters.includes(tag.id)}\n                  key={tag.id}\n                  onClick={() => mapState.toggleActiveTagFilter(tag.id)}\n                  sx={{ maxWidth: 'auto', width: 'auto' }}\n                  filterType=\"tag\"\n                >\n                  <Text variant=\"quiet\" sx={{ fontSize: 1 }}>\n                    {tag.name}\n                  </Text>\n                </MapFilterListItem>\n              ))\n            ) : (\n              <Text variant=\"quiet\" sx={{ fontSize: 1 }}>\n                No space activities to show\n              </Text>\n            )}\n          </MapFilterListWrapper>\n        )}\n\n        {(mapState.allBadges?.length || 0) > 0 && (\n          <MapFilterListWrapper title=\"Badges\">\n            {visibleBadges.length > 0 ? (\n              visibleBadges.map((badge) => (\n                <Label key={badge.id} sx={{ alignItems: 'center', gap: 0 }}>\n                  <Checkbox\n                    onClick={() => mapState.toggleActiveBadgeFilter(badge.name)}\n                    defaultChecked={mapState.activeBadgeFilters?.includes(badge.name)}\n                  />\n                  {badge.displayName}\n                  <UserBadge badge={badge} />\n                </Label>\n              ))\n            ) : (\n              <Text variant=\"quiet\" sx={{ fontSize: 1 }}>\n                No badges to show\n              </Text>\n            )}\n          </MapFilterListWrapper>\n        )}\n\n        {(mapState?.allProfileSettings?.length || 0) > 0 && (\n          <MapFilterListWrapper title=\"Profile Specifications\">\n            {mapState?.allProfileSettings.map((setting) => {\n              return (\n                <Label key={setting} sx={{ alignItems: 'center', gap: 0 }}>\n                  <Checkbox\n                    onClick={() => mapState.toggleActiveProfileSettingFilter(setting)}\n                    defaultChecked={mapState.activeBadgeFilters?.includes(setting)}\n                  />\n                  {/* There is only 1 for now, so it's hardcoded. */}\n                  Open to Visitors\n                </Label>\n              );\n            })}\n          </MapFilterListWrapper>\n        )}\n      </Flex>\n\n      <Flex sx={{ borderTop: '1px solid', padding: 2 }}>\n        <Button\n          data-cy=\"MapFilterList-ShowResultsButton\"\n          icon=\"sliders\"\n          onClick={() => onClose()}\n          sx={{ alignSelf: 'flex-start' }}\n        >\n          {buttonLabel}\n        </Button>\n      </Flex>\n    </Flex>\n  );\n};\n\nconst MapFilterListWrapper = ({\n  title,\n  children,\n}: {\n  title: string;\n  children: React.ReactNode;\n}) => {\n  return (\n    <Flex sx={{ gap: 1, flexDirection: 'column' }}>\n      <Text>{title}</Text>\n      <Flex\n        as=\"ul\"\n        data-cy=\"MapFilterList\"\n        sx={{\n          listStyle: 'none',\n          flexWrap: 'wrap',\n          gap: 2,\n          flexDirection: 'row',\n          padding: 0,\n        }}\n      >\n        {children}\n      </Flex>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/Maps/Maps.client.tsx",
    "content": "import type { LatLngBounds, Map as LeafletMap, Marker } from 'leaflet';\nimport type { ILatLng, MapPin, ProfileBadge, ProfileTag, ProfileType } from 'oa-shared';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useLocation, useNavigate } from 'react-router';\nimport { Box, Flex } from 'theme-ui';\nimport { MapList } from './Content/MapView/MapList';\nimport { MapView } from './Content/MapView/MapView';\nimport { MapContext } from './MapContext';\nimport { mapPinService } from './map.service';\nimport { filterPins, sortPinsByBadgeThenLastActive } from './utils/pinUtils';\n\nimport './styles.css';\n\nconst MapsPage = () => {\n  const navigate = useNavigate();\n  const location = useLocation();\n\n  const [boundaries, setBoundaries] = useState<LatLngBounds | null>(null);\n  const [allPins, setAllPins] = useState<MapPin[] | null>(null);\n  const [allProfileTypes, setAllProfileTypes] = useState<ProfileType[]>([]);\n  const [allBadges, setAllBadges] = useState<ProfileBadge[]>([]);\n  const [allTags, setAllTags] = useState<ProfileTag[]>([]);\n  const [allProfileSettings, setAllProfileSettings] = useState<string[]>([]);\n  const [activeBadgeFilters, setActiveBadges] = useState<string[]>([]);\n  const [activeProfileSettingFilters, setActiveSettings] = useState<string[]>([]);\n  const [activeProfileTypeFilters, setActiveTypes] = useState<string[]>([]);\n  const [activeTagFilters, setActiveTags] = useState<number[]>([]);\n  const [pinLocation, setPinLocation] = useState<ILatLng>({\n    lat: 30.0,\n    lng: 19.0,\n  });\n  const [selectedPin, selectPin] = useState<MapPin | null | undefined>(undefined);\n  const [loadingMessage, setLoadingMessage] = useState<string>('Loading...');\n  const [isMobile, setIsMobile] = useState(false);\n  const [zoom, setZoom] = useState<number>(2);\n  const [mapRef, setMapRef] = useState<LeafletMap | null>(null);\n  const [clusterGroupRef, setClusterGroupRef] = useState<any>(null);\n\n  const updateMapView = useCallback(\n    (location: ILatLng, zoomLevel: number) => {\n      if (mapRef) {\n        mapRef.setView([location.lat, location.lng], zoomLevel);\n      }\n      setPinLocation(location);\n      setZoom(zoomLevel);\n    },\n    [mapRef],\n  );\n\n  const panMapTo = useCallback(\n    (location: ILatLng) => {\n      if (mapRef) {\n        mapRef.panTo([location.lat, location.lng]);\n      }\n    },\n    [mapRef],\n  );\n\n  const fitMapBounds = useCallback(\n    (bounds: LatLngBounds) => {\n      if (mapRef) {\n        mapRef.fitBounds(bounds);\n      }\n    },\n    [mapRef],\n  );\n\n  const selectPinAndHandleCluster = useCallback(\n    (pin: MapPin) => {\n      selectPin(pin);\n\n      const clusterGroup = clusterGroupRef;\n\n      if (clusterGroup?.getLayers && mapRef) {\n        const allMarkers = clusterGroup.getLayers();\n        const marker = allMarkers.find((m: Marker) => {\n          const pos = m.getLatLng();\n          return pos.lat === Number(pin.lat) && pos.lng === Number(pin.lng);\n        });\n\n        if (marker) {\n          const visibleParent = clusterGroup.getVisibleParent(marker);\n          if (visibleParent !== marker && visibleParent.getBounds) {\n            fitMapBounds(visibleParent.getBounds());\n            return;\n          }\n        }\n      }\n\n      panMapTo({ lat: pin.lat, lng: pin.lng });\n    },\n    [clusterGroupRef, mapRef, fitMapBounds, panMapTo],\n  );\n\n  // Pins filtered by tags/types/badges/settings only (no boundary filter).\n  // Used by the map clusters — Leaflet handles viewport clipping internally.\n  const mapPins = useMemo<MapPin[]>(() => {\n    return filterPins(allPins || [], {\n      settings: activeProfileSettingFilters,\n      badges: activeBadgeFilters,\n      types: activeProfileTypeFilters,\n      tags: activeTagFilters,\n    });\n  }, [\n    allPins,\n    activeProfileSettingFilters,\n    activeBadgeFilters,\n    activeProfileTypeFilters,\n    activeTagFilters,\n  ]);\n\n  // Pins filtered by everything including boundaries — used by the list view.\n  const filteredPins = useMemo<MapPin[]>(() => {\n    return filterPins(allPins || [], {\n      settings: activeProfileSettingFilters,\n      badges: activeBadgeFilters,\n      types: activeProfileTypeFilters,\n      tags: activeTagFilters,\n      boundaries: boundaries ?? undefined,\n    });\n  }, [\n    allPins,\n    activeProfileSettingFilters,\n    activeBadgeFilters,\n    activeProfileTypeFilters,\n    activeTagFilters,\n    boundaries,\n  ]);\n\n  useEffect(() => {\n    if (selectedPin && allPins && allPins.length > 0 && boundaries) {\n      const isPinStillVisible = filteredPins.some((pin) => pin.id === selectedPin.id);\n      if (!isPinStillVisible) {\n        selectPin(null);\n      }\n    }\n  }, [filteredPins, selectedPin, allPins, boundaries]);\n\n  useEffect(() => {\n    const init = async () => {\n      try {\n        const [pins, filters, userPin] = await Promise.all([\n          mapPinService.getMapPins(),\n          mapPinService.getMapFilters(),\n          mapPinService.getCurrentUserMapPin(),\n        ]);\n        let pinsToSet: MapPin[] = [];\n        if (pins) {\n          pinsToSet = pins;\n        }\n\n        // might be missing because it's not approved\n        const existingPinIndex = pinsToSet.findIndex((x) => x.id === userPin?.id);\n\n        if (userPin) {\n          if (existingPinIndex >= 0) {\n            pinsToSet[existingPinIndex] = userPin;\n          } else {\n            pinsToSet.push(userPin);\n          }\n        }\n\n        setAllPins(sortPinsByBadgeThenLastActive(pinsToSet, 'pro'));\n\n        if (filters?.filters) {\n          const sortedTypes = (filters.filters.types || [])\n            .slice()\n            .sort((a, b) => a.order - b.order);\n          setAllProfileTypes(sortedTypes);\n          setAllBadges(filters.filters.badges || []);\n          setAllTags(filters.filters.tags || []);\n          setAllProfileSettings(filters.filters.settings || []);\n        }\n        if (filters?.defaultFilters?.types) {\n          setActiveTypes(filters.defaultFilters.types);\n        }\n\n        setLoadingMessage('');\n      } catch (error) {\n        setLoadingMessage(error);\n      }\n    };\n    init();\n  }, []);\n\n  const toggleActiveBadgeFilter = useCallback((value: string) => {\n    setActiveBadges((prev) =>\n      prev.includes(value) ? prev.filter((x) => x !== value) : [...prev, value],\n    );\n  }, []);\n  const toggleActiveProfileSettingFilter = useCallback((value: string) => {\n    setActiveSettings((prev) =>\n      prev.includes(value) ? prev.filter((x) => x !== value) : [...prev, value],\n    );\n  }, []);\n  const toggleActiveProfileTypeFilter = useCallback((value: string) => {\n    setActiveTypes((prev) =>\n      prev.includes(value) ? prev.filter((x) => x !== value) : [...prev, value],\n    );\n  }, []);\n  const toggleActiveTagFilter = useCallback((value: number) => {\n    setActiveTags((prev) =>\n      prev.includes(value) ? prev.filter((x) => x !== value) : [...prev, value],\n    );\n  }, []);\n\n  useEffect(() => {\n    if (selectedPin) {\n      navigate(`/map#${selectedPin.profile!.username}`, { replace: true });\n    } else if (selectedPin === null) {\n      navigate('/map', { replace: true });\n    }\n  }, [selectedPin]);\n\n  useEffect(() => {\n    const pinId = location.hash.slice(1);\n    const username = pinId.length > 0 ? pinId : undefined;\n\n    if (allPins && username) {\n      const foundPin = allPins.find((pin) => pin.profile!.username === username);\n      if (foundPin) {\n        const isPinVisible = filteredPins.some((pin) => pin.id === foundPin.id);\n        if (isPinVisible && selectedPin?.profile?.username !== username) {\n          selectPinAndHandleCluster(foundPin);\n        }\n      } else {\n        selectPin(foundPin);\n      }\n    }\n  }, [location.hash, allPins, filteredPins]);\n\n  const contextValue = useMemo(\n    () => ({\n      allPins,\n      allProfileTypes,\n      allProfileSettings,\n      allBadges,\n      allTags,\n      location: pinLocation,\n      setLocation: setPinLocation,\n      loadingMessage,\n      selectedPin,\n      selectPin,\n      selectPinWithClusterCheck: selectPinAndHandleCluster,\n      mapPins,\n      filteredPins,\n      activeBadgeFilters,\n      activeProfileSettingFilters,\n      activeProfileTypeFilters,\n      activeTagFilters,\n      toggleActiveBadgeFilter,\n      toggleActiveProfileSettingFilter,\n      toggleActiveProfileTypeFilter,\n      toggleActiveTagFilter,\n      isMobile,\n      setIsMobile,\n      boundaries,\n      setBoundaries,\n      zoom,\n      setZoom,\n      setView: updateMapView,\n      panTo: panMapTo,\n      fitBounds: fitMapBounds,\n      setMapRef,\n      setClusterGroupRef,\n    }),\n    [\n      allPins,\n      allProfileTypes,\n      allProfileSettings,\n      allBadges,\n      allTags,\n      pinLocation,\n      loadingMessage,\n      selectedPin,\n      selectPinAndHandleCluster,\n      mapPins,\n      filteredPins,\n      activeBadgeFilters,\n      activeProfileSettingFilters,\n      activeProfileTypeFilters,\n      activeTagFilters,\n      toggleActiveBadgeFilter,\n      toggleActiveProfileSettingFilter,\n      toggleActiveProfileTypeFilter,\n      toggleActiveTagFilter,\n      isMobile,\n      boundaries,\n      zoom,\n      updateMapView,\n      panMapTo,\n      fitMapBounds,\n    ],\n  );\n\n  return (\n    <MapContext.Provider value={contextValue}>\n      <Box id=\"mapPage\" sx={{ height: 'calc(100vh - 80px)', width: '100%' }}>\n        <Flex\n          sx={{\n            flexDirection: 'row',\n            height: '100%',\n          }}\n        >\n          <MapList />\n\n          <MapView />\n        </Flex>\n      </Box>\n    </MapContext.Provider>\n  );\n};\n\nexport default MapsPage;\n"
  },
  {
    "path": "src/pages/Maps/map.service.ts",
    "content": "import type { FilterResponse, MapPin } from 'oa-shared';\nimport { createContext } from 'react';\nimport { logger } from 'src/logger';\n\nexport interface IMapPinService {\n  getMapPins: () => Promise<MapPin[]>;\n  getMapPinById: (id: number) => Promise<MapPin | null>;\n  getCurrentUserMapPin: () => Promise<MapPin | null>;\n  getMapFilters: () => Promise<FilterResponse | null>;\n}\n\nconst getMapPins = async () => {\n  try {\n    const response = await fetch('/api/map-pins');\n    const { mapPins } = await response.json();\n\n    return mapPins;\n  } catch (error) {\n    logger.error('Failed to fetch map pins', { error });\n    return [];\n  }\n};\n\nconst getMapPinById = async (id: number) => {\n  try {\n    const response = await fetch('/api/map-pins/' + id);\n    const { mapPin } = await response.json();\n\n    return mapPin as MapPin;\n  } catch (error) {\n    logger.error('Failed to fetch map pin by user id', { id, error });\n    return null;\n  }\n};\n\nconst getCurrentUserMapPin = async () => {\n  try {\n    const response = await fetch('/api/map-pin');\n    const { mapPin } = await response.json();\n\n    return mapPin as MapPin;\n  } catch (error) {\n    logger.error('Failed to fetch current user map pinprofile-website', {\n      error,\n    });\n    return null;\n  }\n};\n\nconst getMapFilters = async () => {\n  try {\n    const response = await fetch('/api/map-filters');\n    const result = await response.json();\n\n    return result as FilterResponse;\n  } catch (error) {\n    logger.error('Failed to fetch map filters', {\n      error,\n    });\n    return null;\n  }\n};\n\nexport const MapPinServiceContext = createContext<IMapPinService | null>(null);\n\nexport const mapPinService: IMapPinService = {\n  getMapPins,\n  getMapPinById,\n  getCurrentUserMapPin,\n  getMapFilters,\n};\n"
  },
  {
    "path": "src/pages/Maps/styles.css",
    "content": ".leaflet-tile {\n  filter: saturate(0.3);\n}\n"
  },
  {
    "path": "src/pages/Maps/utils/geolocation.ts",
    "content": "/**\n * Prompt browser to access users location and pass back as Position object\n * Rejects on error or non-supported browser\n */\nexport const GetLocation = async (): Promise<GeolocationPosition> => {\n  return new Promise((resolve, reject) => {\n    if (navigator.geolocation) {\n      navigator.geolocation.getCurrentPosition(\n        (position) => {\n          resolve(position);\n        },\n        (error) => {\n          reject(error.message);\n        },\n      );\n    } else {\n      reject('Geolocation not supported');\n    }\n  });\n};\n"
  },
  {
    "path": "src/pages/Maps/utils/pinUtils.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { filterPins, sortPinsByBadgeThenLastActive } from './pinUtils';\n\nimport type { MapPin, ProfileType } from 'oa-shared';\n\ndescribe('filterPins', () => {\n  const workspacePin: MapPin = {\n    id: 1,\n    moderation: 'accepted',\n    lat: 0,\n    lng: 0,\n    name: '',\n    postCode: '',\n    administrative: '',\n    country: '',\n    countryCode: '',\n    profileId: 1,\n    profile: {\n      id: 1,\n      username: 'bob_the_builder',\n      lastActive: new Date(),\n      country: 'uk',\n      coverImages: null,\n      displayName: 'Bob the Builder',\n      isContactable: false,\n      visitorPolicy: null,\n      about: '',\n      photo: null,\n      type: {\n        id: 2,\n        name: 'workspace',\n        displayName: 'Workspace',\n        description: 'teste',\n      } as ProfileType,\n      badges: [\n        {\n          id: 1,\n          name: 'verified',\n          displayName: 'Verified',\n          imageUrl: '',\n        },\n      ],\n      tags: [\n        {\n          id: 1,\n          name: 'Designer',\n          createdAt: new Date(),\n          profileType: 'workspace',\n        },\n      ],\n    },\n  };\n\n  const memberPin = {\n    id: 2,\n    moderation: 'accepted',\n    lat: 0,\n    lng: 0,\n    name: '',\n    postCode: '',\n    administrative: '',\n    country: '',\n    countryCode: '',\n    postcode: '',\n    profileId: 2,\n    profile: {\n      id: 2,\n      username: 'bob_the_member',\n      lastActive: new Date(),\n      countryCode: 'uk',\n      coverImages: null,\n      displayName: 'Bob the Member',\n      isContactable: false,\n      about: '',\n      country: '',\n      visitorPolicy: null,\n      photo: null,\n      openToVisitors: null,\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n        description: 'teste',\n      } as ProfileType,\n      badges: [\n        {\n          id: 1,\n          name: 'verified',\n          displayName: 'Verified',\n          imageUrl: '',\n        },\n      ],\n    },\n  } as MapPin;\n\n  const taggedMemberPin = {\n    id: 3,\n    moderation: 'accepted',\n    lat: 0,\n    lng: 0,\n    administrative: '',\n    name: '',\n    postCode: '',\n    moderationFeedback: '',\n    country: '',\n    countryCode: '',\n    postcode: '',\n    profileId: 3,\n    profile: {\n      id: 3,\n      username: 'bob_the_tagged',\n      lastActive: new Date(),\n      countryCode: 'uk',\n      coverImages: null,\n      displayName: 'Bob the Tagged',\n      isContactable: false,\n      about: '',\n      country: '',\n      photo: null,\n      openToVisitors: null,\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n        description: 'teste',\n      } as ProfileType,\n      visitorPolicy: null,\n      badges: [\n        {\n          id: 1,\n          name: 'verified',\n          displayName: 'Verified',\n          imageUrl: '',\n        },\n      ],\n      tags: [\n        {\n          id: 1,\n          name: 'Designer',\n          createdAt: new Date(),\n          profileType: 'member',\n        },\n      ],\n    },\n  } as MapPin;\n\n  const allPinsInView: MapPin[] = [workspacePin, memberPin, taggedMemberPin];\n\n  it('returns all pins when no filters provided', () => {\n    expect(filterPins(allPinsInView, {})).toEqual(allPinsInView);\n  });\n\n  it('returns only the correct profile type pins when filter is provided', () => {\n    const filtered = filterPins(allPinsInView, {\n      types: ['member'],\n    });\n    expect(filtered.map((x) => x.id)).toEqual([memberPin.id, taggedMemberPin.id]);\n  });\n\n  it('returns only the pins when profile type and tag filters are provided', () => {\n    const filtered = filterPins(allPinsInView, {\n      types: ['member'],\n      tags: [1],\n    });\n    expect(filtered.map((x) => x.id)).toEqual([taggedMemberPin.id]);\n  });\n\n  it('returns an empty arry when no pins meet the filter criteria', () => {\n    const filtered = filterPins(allPinsInView, {\n      tags: [55],\n    });\n    expect(filtered).toEqual([]);\n  });\n});\n\ndescribe('sortPinsByBadgeThenLastActive', () => {\n  const createPin = (\n    id: number,\n    lastActive: Date,\n    badges: { id: number; name: string; displayName: string; imageUrl: string }[] = [],\n  ): MapPin => ({\n    id,\n    moderation: 'accepted',\n    lat: 0,\n    lng: 0,\n    name: '',\n    postCode: '',\n    administrative: '',\n    country: '',\n    countryCode: '',\n    profileId: id,\n    profile: {\n      id,\n      username: `user_${id}`,\n      lastActive,\n      country: 'uk',\n      coverImages: null,\n      displayName: `User ${id}`,\n      isContactable: false,\n      visitorPolicy: null,\n      about: '',\n      photo: null,\n      type: {\n        id: 1,\n        name: 'member',\n        displayName: 'Member',\n        description: 'test',\n      } as ProfileType,\n      badges,\n      tags: [],\n    },\n  });\n\n  const proBadge = { id: 1, name: 'pro', displayName: 'PRO', imageUrl: '' };\n  const supporterBadge = { id: 2, name: 'supporter', displayName: 'Supporter', imageUrl: '' };\n\n  const oldDate = new Date('2024-01-01');\n  const midDate = new Date('2024-06-01');\n  const newDate = new Date('2024-12-01');\n\n  it('sorts pins with matching badge first', () => {\n    const proPin = createPin(1, oldDate, [proBadge]);\n    const regularPin = createPin(2, newDate, []);\n\n    const sorted = sortPinsByBadgeThenLastActive([regularPin, proPin], 'pro');\n\n    expect(sorted.map((p) => p.id)).toEqual([1, 2]);\n  });\n\n  it('sorts pins by lastActive within badge group', () => {\n    const proOld = createPin(1, oldDate, [proBadge]);\n    const proNew = createPin(2, newDate, [proBadge]);\n\n    const sorted = sortPinsByBadgeThenLastActive([proOld, proNew], 'pro');\n\n    expect(sorted.map((p) => p.id)).toEqual([2, 1]);\n  });\n\n  it('sorts pins by lastActive within non-badge group', () => {\n    const regularOld = createPin(1, oldDate, []);\n    const regularNew = createPin(2, newDate, []);\n\n    const sorted = sortPinsByBadgeThenLastActive([regularOld, regularNew], 'pro');\n\n    expect(sorted.map((p) => p.id)).toEqual([2, 1]);\n  });\n\n  it('sorts mixed pins correctly: badge first by lastActive, then non-badge by lastActive', () => {\n    const proOld = createPin(1, oldDate, [proBadge]);\n    const proNew = createPin(2, newDate, [proBadge]);\n    const regularMid = createPin(3, midDate, []);\n    const regularNew = createPin(4, newDate, []);\n\n    const sorted = sortPinsByBadgeThenLastActive([regularMid, proOld, regularNew, proNew], 'pro');\n\n    expect(sorted.map((p) => p.id)).toEqual([2, 1, 4, 3]);\n  });\n\n  it('works with different badge names', () => {\n    const supporterPin = createPin(1, oldDate, [supporterBadge]);\n    const regularPin = createPin(2, newDate, []);\n\n    const sorted = sortPinsByBadgeThenLastActive([regularPin, supporterPin], 'supporter');\n\n    expect(sorted.map((p) => p.id)).toEqual([1, 2]);\n  });\n\n  it('does not mutate the original array', () => {\n    const pin1 = createPin(1, oldDate, []);\n    const pin2 = createPin(2, newDate, [proBadge]);\n    const original = [pin1, pin2];\n\n    sortPinsByBadgeThenLastActive(original, 'pro');\n\n    expect(original.map((p) => p.id)).toEqual([1, 2]);\n  });\n\n  it('handles empty array', () => {\n    const sorted = sortPinsByBadgeThenLastActive([], 'pro');\n    expect(sorted).toEqual([]);\n  });\n\n  it('handles pins with no badges array', () => {\n    const pinWithNoBadges = createPin(1, newDate, []);\n    pinWithNoBadges.profile!.badges = undefined as any;\n    const proPin = createPin(2, oldDate, [proBadge]);\n\n    const sorted = sortPinsByBadgeThenLastActive([pinWithNoBadges, proPin], 'pro');\n\n    expect(sorted.map((p) => p.id)).toEqual([2, 1]);\n  });\n\n  it('handles pins with null lastActive', () => {\n    const pinWithNullDate = createPin(1, null as any, [proBadge]);\n    const proPin = createPin(2, newDate, [proBadge]);\n\n    const sorted = sortPinsByBadgeThenLastActive([pinWithNullDate, proPin], 'pro');\n\n    expect(sorted.map((p) => p.id)).toEqual([2, 1]);\n  });\n});\n"
  },
  {
    "path": "src/pages/Maps/utils/pinUtils.ts",
    "content": "import type { LatLngBounds } from 'leaflet';\nimport type { IBoundingBox, MapPin } from 'oa-shared';\n\nconst filterByLatLong = (boundaries: IBoundingBox, pins: MapPin[]): MapPin[] => {\n  return pins.filter(({ lat, lng }) => {\n    const inLat = lat >= boundaries._southWest.lat && lat <= boundaries._northEast.lat;\n    const inLng = lng >= boundaries._southWest.lng && lng <= boundaries._northEast.lng;\n    return inLat && inLng;\n  });\n};\n\nexport const sortPinsByBadgeThenLastActive = (pins: MapPin[], badgeName: string): MapPin[] => {\n  return [...pins].sort((a, b) => {\n    const aHasBadge = a.profile?.badges?.some((badge) => badge.name === badgeName) ?? false;\n    const bHasBadge = b.profile?.badges?.some((badge) => badge.name === badgeName) ?? false;\n\n    if (aHasBadge && !bHasBadge) return -1;\n    if (!aHasBadge && bHasBadge) return 1;\n\n    const aTime = a.profile?.lastActive ? new Date(a.profile.lastActive).getTime() : 0;\n    const bTime = b.profile?.lastActive ? new Date(b.profile.lastActive).getTime() : 0;\n    return bTime - aTime;\n  });\n};\n\nexport const filterPins = (\n  allPins: MapPin[],\n  filters: {\n    tags?: number[];\n    types?: string[];\n    badges?: string[];\n    settings?: string[];\n    boundaries?: LatLngBounds;\n  },\n): MapPin[] => {\n  if (!allPins?.length) {\n    return [];\n  }\n  const { tags, types, badges, settings, boundaries } = filters;\n\n  let filteredPins = structuredClone(allPins);\n\n  if (tags?.length) {\n    filteredPins = filteredPins.filter((x) =>\n      tags.every((tag) => x.profile?.tags?.some((profileTag) => profileTag.id === tag)),\n    );\n  }\n\n  if (types?.length) {\n    filteredPins = filteredPins.filter(\n      (x) => x.profile?.type?.name && types.includes(x.profile?.type?.name),\n    );\n  }\n\n  if (badges?.length) {\n    filteredPins = filteredPins.filter((x) =>\n      x.profile?.badges?.some((badge) => badges.includes(badge.name)),\n    );\n  }\n\n  if (settings?.length) {\n    // Right now visitor filter is only setting filter. This should be smarter.\n    filteredPins = filteredPins.filter((x) => x.profile?.visitorPolicy?.policy === 'open');\n  }\n\n  if (boundaries) {\n    filteredPins = filterByLatLong(\n      {\n        _northEast: boundaries.getNorthEast(),\n        _southWest: boundaries.getSouthWest(),\n      },\n      filteredPins,\n    );\n  }\n\n  return filteredPins;\n};\n"
  },
  {
    "path": "src/pages/News/Content/Common/FormFields/NewsBodyField.tsx",
    "content": "import { FieldMarkdown } from 'oa-components';\nimport { MediaWithPublicUrl } from 'oa-shared';\nimport { Field } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { fields } from 'src/pages/News/labels';\nimport { required } from 'src/utils/validators';\n\ninterface IProps {\n  imageUpload: (image: File) => Promise<MediaWithPublicUrl | null>;\n}\n\nexport const NewsBodyField = ({ imageUpload }: IProps) => {\n  const name = 'body';\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={fields.body.title} required>\n      <Field\n        data-cy={`field-${name}`}\n        component={FieldMarkdown}\n        id={name}\n        imageUploadHandler={imageUpload}\n        name={name}\n        placeholder={fields.body.placeholder}\n        validate={required}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/News/Content/Common/FormFields/NewsImageField.tsx",
    "content": "import styled from '@emotion/styled';\nimport { ImageInputV2 } from 'oa-components';\nimport type { MediaWithPublicUrl } from 'oa-shared';\nimport { commonStyles } from 'oa-themes';\nimport { useState } from 'react';\nimport { useForm } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { fields } from 'src/pages/News/labels';\nimport { storageService } from 'src/services/storageService';\nimport { Spinner, Text } from 'theme-ui';\n\nconst ImageInputFieldWrapper = styled.div`\n  width: 620px;\n  height: 310px;\n`;\n\ninterface IProps {\n  image: MediaWithPublicUrl | null;\n  removeImage: () => void;\n  contentId: number | null;\n}\n\nexport const NewsImageField = (props: IProps) => {\n  const { image, removeImage, contentId } = props;\n  const form = useForm();\n  const [isUploading, setIsUploading] = useState(false);\n  const [uploadError, setUploadError] = useState<string | null>(null);\n\n  const handleImageSelect = async (file: File | undefined) => {\n    if (!file) {\n      form.change('heroImage', null);\n      setUploadError(null);\n      return;\n    }\n\n    setIsUploading(true);\n    setUploadError(null);\n\n    try {\n      const uploadedImage = await storageService.imageUpload(contentId, 'news', file);\n      form.change('heroImage', uploadedImage);\n    } catch (error) {\n      setUploadError(\n        error instanceof Error ? error.message : 'Failed to upload image. Please try again.',\n      );\n      form.change('heroImage', null);\n    } finally {\n      setIsUploading(false);\n    }\n  };\n\n  const handleImageError = (error: string) => {\n    setUploadError(error);\n  };\n\n  return (\n    <FormFieldWrapper\n      description={fields.heroImage.description}\n      htmlFor=\"images\"\n      text={fields.heroImage.title}\n      required\n    >\n      {uploadError && (\n        <Text sx={{ color: 'error', fontSize: 1, mb: 2, width: '100%' }}>{uploadError}</Text>\n      )}\n\n      {!image && !isUploading && (\n        <ImageInputFieldWrapper data-cy=\"heroImage-upload\">\n          <ImageInputV2 onFilesChange={handleImageSelect} onError={handleImageError} />\n        </ImageInputFieldWrapper>\n      )}\n\n      {isUploading && (\n        <ImageInputFieldWrapper data-cy=\"heroImage-uploading\">\n          <div\n            style={{\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n              height: '100%',\n            }}\n          >\n            <Spinner sx={{ color: commonStyles.colors.darkGrey }} />\n            <Text sx={{ ml: 2 }}>Uploading image...</Text>\n          </div>\n        </ImageInputFieldWrapper>\n      )}\n\n      {image && !isUploading && (\n        <ImageInputFieldWrapper key=\"existingHeroImage\" data-cy=\"existingHeroImage\">\n          <ImageInputV2\n            image={image}\n            onFilesChange={(file) => {\n              if (!file) {\n                removeImage();\n              }\n            }}\n            onError={setUploadError}\n          />\n        </ImageInputFieldWrapper>\n      )}\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/News/Content/Common/FormFields/index.ts",
    "content": "export { NewsBodyField } from './NewsBodyField';\nexport { NewsImageField } from './NewsImageField';\n"
  },
  {
    "path": "src/pages/News/Content/Common/NewsForm.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\nimport { render, screen } from '@testing-library/react';\nimport { FactoryNewsFormData } from 'src/test/factories/News';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport { NewsForm } from './NewsForm';\nimport type { NewsFormData } from 'oa-shared';\n\n// Create mock navigate function\nconst mockNavigate = vi.fn();\n\n// Mock react-router\nvi.mock('react-router', async (importOriginal) => {\n  const actual: any = await importOriginal();\n  return {\n    ...actual,\n    useNavigate: () => mockNavigate,\n  };\n});\n\n// Mock services\nconst mockNewsServiceUpsert = vi.fn();\nconst mockStorageServiceImageUpload = vi.fn();\n\nvi.mock('src/services/newsService', () => ({\n  newsService: {\n    upsert: (...args: any[]) => mockNewsServiceUpsert(...args),\n  },\n}));\n\nvi.mock('src/services/storageService', () => ({\n  storageService: {\n    imageUpload: (...args: any[]) => mockStorageServiceImageUpload(...args),\n  },\n}));\n\n// Mock logger\nvi.mock('src/logger', () => ({\n  logger: {\n    error: vi.fn(),\n  },\n}));\n\n// Mock UnsavedChangesDialog to avoid router context issues\nvi.mock('src/common/Form/UnsavedChangesDialog', () => ({\n  UnsavedChangesDialog: () => null,\n}));\n\n// Mock FormWrapper to simplify testing\nvi.mock('src/common/Form/FormWrapper', () => ({\n  FormWrapper: ({ children }: any) => <div data-testid=\"news-create-form\">{children}</div>,\n}));\n\n// Mock all form field components\nvi.mock('src/pages/common/FormFields/Category.field', () => ({\n  CategoryField: () => <div data-testid=\"category-field\" />,\n}));\n\nvi.mock('src/pages/common/FormFields/Tags.field', () => ({\n  TagsField: () => <div data-testid=\"tags-field\" />,\n}));\n\nvi.mock('src/pages/common/FormFields/ProfileBadgeField', () => ({\n  ProfileBadgeField: () => <div data-testid=\"profile-badge-field\" />,\n}));\n\nvi.mock('src/pages/common/FormFields/Title.field', () => ({\n  TitleField: () => <div data-testid=\"title-field\" />,\n}));\n\nvi.mock('./FormFields', () => ({\n  NewsImageField: () => <div data-testid=\"news-image-field\" />,\n  NewsBodyField: () => <div data-testid=\"news-body-field\" />,\n}));\n\nvi.mock('src/pages/News/Content/Common/NewsPostingGuidelines', () => ({\n  NewsPostingGuidelines: () => <div data-testid=\"guidelines\" />,\n}));\n\n\ndescribe('NewsForm', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockNavigate.mockClear();\n  });\n\n  describe('Component Rendering', () => {\n    it('renders empty form for new news creation', () => {\n      render(<NewsForm id={null} formData={null} formAction=\"create\" />);\n\n      // Check that all required fields are present\n      expect(screen.getByTestId('news-create-form')).toBeInTheDocument();\n      expect(screen.getByTestId('title-field')).toBeInTheDocument();\n      expect(screen.getByTestId('news-body-field')).toBeInTheDocument();\n    });\n\n    it('renders form with existing news data in edit mode', () => {\n      const mockNewsFormData: NewsFormData = FactoryNewsFormData({\n        title: 'Existing News',\n        body: 'Existing body content',\n        isDraft: false,\n        heroImage: {\n          id: 'image-123',\n          path: 'https://example.com/image.jpg',\n          fullPath: 'https://example.com/image.jpg',\n          publicUrl: 'https://example.com/image.jpg',          \n        },\n      });\n\n      render(<NewsForm id={123} formData={mockNewsFormData} formAction=\"edit\" />);\n\n      expect(screen.getByTestId('news-create-form')).toBeInTheDocument();\n    });\n\n    it('displays all required form fields', () => {\n      render(<NewsForm id={null} formData={null} formAction=\"create\" />);\n\n      // All form fields should be present\n      expect(screen.getByTestId('title-field')).toBeInTheDocument();\n      expect(screen.getByTestId('news-body-field')).toBeInTheDocument();\n      expect(screen.getByTestId('category-field')).toBeInTheDocument();\n      expect(screen.getByTestId('tags-field')).toBeInTheDocument();\n      expect(screen.getByTestId('profile-badge-field')).toBeInTheDocument();\n      expect(screen.getByTestId('news-image-field')).toBeInTheDocument();\n    });\n  });\n\n  describe('Form Validation', () => {\n    it('validates required fields exist', () => {\n      render(<NewsForm id={null} formData={null} formAction=\"create\" />);\n\n      // The form should have title and body fields\n      expect(screen.getByTestId('title-field')).toBeInTheDocument();\n      expect(screen.getByTestId('news-body-field')).toBeInTheDocument();\n    });\n\n    it('initializes with correct empty values for new news', () => {\n      render(<NewsForm id={null} formData={null} formAction=\"create\" />);\n\n      // Form should render without errors\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n\n    it('initializes with existing values for edit mode', () => {\n      const mockNewsFormData: NewsFormData = FactoryNewsFormData({\n        title: 'Original Title',\n        body: 'Original body',\n        isDraft: false,\n        category: {\n          label: 'Test Category',\n          value: '1',\n        },\n      });\n\n      render(<NewsForm id={456} formData={mockNewsFormData} formAction=\"edit\" />);\n\n      // Form should render with the news data\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n  });\n\n  describe('Draft Functionality', () => {\n    it('provides draft save capability', () => {\n      render(<NewsForm id={null} formData={null} formAction=\"create\" />);\n\n      // Draft button should be available\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n\n    it('renders draft news correctly', () => {\n      const draftNewsFormData: NewsFormData = FactoryNewsFormData({\n        title: 'Draft News',\n        body: 'Draft content',\n        isDraft: true,\n      });\n\n      render(<NewsForm id={999} formData={draftNewsFormData} formAction=\"edit\" />);\n\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n  });\n\n  describe('Image Handling', () => {\n    it('shows existing hero image when available', () => {\n      const mockNewsFormData: NewsFormData = FactoryNewsFormData({\n        title: 'News with Image',\n        body: 'Body content',\n        heroImage: {\n          id: 'image-789',\n          path: 'https://example.com/hero.jpg',\n          fullPath: 'https://example.com/hero.jpg',\n          publicUrl: 'https://example.com/hero.jpg',\n        },\n      });\n\n      render(<NewsForm id={789} formData={mockNewsFormData} formAction=\"edit\" />);\n\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n\n    it('allows image upload for new news', () => {\n      render(<NewsForm id={null} formData={null} formAction=\"create\" />);\n\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n  });\n\n  describe('Error Handling', () => {\n    it('handles service errors gracefully', async () => {\n      mockNewsServiceUpsert.mockRejectedValueOnce({\n        message: 'Network error occurred',\n        cause: 'network',\n      });\n\n      render(<NewsForm id={null} formData={null} formAction=\"create\" />);\n\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n  });\n\n  describe('Navigation After Save', () => {\n    it('provides navigation capability after successful save', async () => {\n      mockNewsServiceUpsert.mockResolvedValueOnce({\n        slug: 'test-news-slug',\n        isDraft: false,\n      });\n\n      render(<NewsForm id={null} formData={null} formAction=\"create\" />);\n\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n  });\n\n  describe('Profile Badge Support', () => {\n    it('supports profile badge selection', () => {\n      const mockNewsFormData: NewsFormData = FactoryNewsFormData({\n        profileBadge: {\n          label: 'PRO',\n          value: '1',\n        },\n      });\n\n      render(<NewsForm id={111} formData={mockNewsFormData} formAction=\"edit\" />);\n\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n  });\n\n  describe('Form Actions', () => {\n    it('renders form correctly for create mode', () => {\n      render(<NewsForm id={null} formData={null} formAction=\"create\" />);\n\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n\n    it('renders form correctly for edit mode', () => {\n      const mockNewsFormData = FactoryNewsFormData();\n      render(<NewsForm id={123} formData={mockNewsFormData} formAction=\"edit\" />);\n\n      const form = screen.getByTestId('news-create-form');\n      expect(form).toBeInTheDocument();\n    });\n  });\n});\n\n"
  },
  {
    "path": "src/pages/News/Content/Common/NewsForm.tsx",
    "content": "import type { NewsFormData } from 'oa-shared';\nimport { useCallback, useMemo, useState } from 'react';\nimport { Form } from 'react-final-form';\nimport { useNavigate } from 'react-router';\nimport { FormWrapper } from 'src/common/Form/FormWrapper';\nimport type { MainFormAction } from 'src/common/Form/types';\nimport { UnsavedChangesDialog } from 'src/common/Form/UnsavedChangesDialog';\nimport { logger } from 'src/logger';\nimport { CategoryField } from 'src/pages/common/FormFields/Category.field';\nimport { ProfileBadgeField } from 'src/pages/common/FormFields/ProfileBadgeField';\nimport { TagsField } from 'src/pages/common/FormFields/Tags.field';\nimport { TitleField } from 'src/pages/common/FormFields/Title.field';\nimport { errorSet } from 'src/pages/Library/Content/utils/transformLibraryErrors';\nimport { NewsPostingGuidelines } from 'src/pages/News/Content/Common/NewsPostingGuidelines';\nimport * as LABELS from 'src/pages/News/labels';\nimport { newsService } from 'src/services/newsService';\nimport { storageService } from 'src/services/storageService';\nimport { composeValidators, minValue, required } from 'src/utils/validators';\nimport { NEWS_MIN_TITLE_LENGTH } from '../../constants';\nimport { NewsBodyField, NewsImageField } from './FormFields';\n\ninterface IProps {\n  'data-testid'?: string;\n  id: number | null;\n  formData: NewsFormData | null;\n  formAction: MainFormAction;\n}\n\nexport const NewsForm = (props: IProps) => {\n  const navigate = useNavigate();\n  const [saveErrorMessage, setSaveErrorMessage] = useState<string | undefined>();\n  const [intentionalNavigation, setIntentionalNavigation] = useState(false);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const initialValues = useMemo<NewsFormData>(\n    () =>\n      ({\n        body: props.formData?.body || '',\n        category: props.formData?.category || null,\n        heroImage: props.formData?.heroImage || null,\n        isDraft: props.formData?.isDraft || null,\n        profileBadge: props.formData?.profileBadge || null,\n        tags: props.formData?.tags || [],\n        title: props.formData?.title || '',\n      }) satisfies NewsFormData,\n    [],\n  );\n\n  const onSubmit = async (formValues: Partial<NewsFormData>, isDraft = false) => {\n    setIntentionalNavigation(true);\n    setSaveErrorMessage(undefined);\n    setIsSubmitting(true);\n\n    try {\n      const result = await newsService.upsert(props.id, {\n        body: formValues.body!,\n        category: formValues.category || null,\n        heroImage: formValues.heroImage || null,\n        isDraft: isDraft,\n        profileBadge: formValues.profileBadge || null,\n        tags: formValues.tags,\n        title: formValues.title!,\n      });\n\n      if (result) {\n        navigate('/news/' + result.slug);\n      }\n    } catch (e) {\n      if (e.cause && e.message) {\n        setSaveErrorMessage(e.message);\n      }\n      logger.error(e);\n      setIsSubmitting(false);\n      throw e;\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const imageUpload = useCallback(\n    async (imageFile: File) => {\n      if (!imageFile) {\n        return null;\n      }\n      try {\n        const response = await storageService.imageUpload(props.id, 'news', imageFile);\n        return response || null;\n      } catch (e) {\n        if (e.cause && e.message) {\n          setSaveErrorMessage(e.message);\n        }\n        logger.error(e);\n        return null;\n      }\n    },\n    [props.id],\n  );\n\n  const validateForm = useCallback((values) => {\n    const errors = {};\n    if (!values.body?.length) {\n      errors['body'] = 'Body field required. Gotta have something to say...';\n    }\n    if (values.heroImage == null && values.existingHeroImage === null) {\n      errors['heroImage'] = 'An image is required (either new or existing).';\n    }\n    return errors;\n  }, []);\n\n  return (\n    <Form\n      key={props.id || 'new'}\n      data-testid={props['data-testid']}\n      onSubmit={(values) => onSubmit(values, false)}\n      initialValues={initialValues}\n      validate={validateForm}\n      render={({\n        dirty,\n        errors,\n        form,\n        hasValidationErrors,\n        submitFailed,\n        submitting,\n        submitSucceeded,\n        handleSubmit,\n        values,\n      }) => {\n        const removeImage = () => {\n          form.change('heroImage', null);\n        };\n\n        const errorsClientSide = [errorSet(errors, LABELS.fields)];\n\n        const handleSubmitDraft = async (e: React.MouseEvent) => {\n          e.preventDefault();\n          await onSubmit(values, true);\n        };\n\n        const unsavedChangesDialog = (\n          <UnsavedChangesDialog hasChanges={dirty && !submitSucceeded && !intentionalNavigation} />\n        );\n        const validate = composeValidators(required, minValue(NEWS_MIN_TITLE_LENGTH));\n\n        return (\n          <FormWrapper\n            buttonLabel={LABELS.buttons[props.formAction]}\n            errorsClientSide={errorsClientSide}\n            errorSubmitting={saveErrorMessage}\n            guidelines={<NewsPostingGuidelines />}\n            handleSubmit={handleSubmit}\n            handleSubmitDraft={handleSubmitDraft}\n            hasValidationErrors={hasValidationErrors}\n            heading={LABELS.headings[props.formAction]}\n            submitFailed={submitFailed}\n            submitting={submitting || isSubmitting}\n            unsavedChangesDialog={unsavedChangesDialog}\n          >\n            <TitleField\n              placeholder={LABELS.fields.title.placeholder}\n              validate={validate}\n              title={LABELS.fields.title.title}\n            />\n            <NewsImageField\n              image={values.heroImage}\n              removeImage={removeImage}\n              contentId={props.id || null}\n            />\n            <CategoryField type=\"news\" />\n            <TagsField title={LABELS.fields.tags.title} />\n            <ProfileBadgeField\n              placeholder={LABELS.fields.profileBadge.placeholder as string}\n              title={LABELS.fields.profileBadge.title}\n            />\n            <NewsBodyField imageUpload={imageUpload} />\n          </FormWrapper>\n        );\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "src/pages/News/Content/Common/NewsPostingGuidelines.tsx",
    "content": "import { ExternalLink, Guidelines } from 'oa-components';\n\nexport const NewsPostingGuidelines = () => {\n  const steps = [\n    <>\n      Write your news (in English){' '}\n      <span role=\"img\" aria-label=\"raised-hand\">\n        🙌\n      </span>\n    </>,\n    <>\n      Double check if it's already made and{' '}\n      <ExternalLink sx={{ color: 'blue' }} href=\"/news\">\n        search{' '}\n      </ExternalLink>\n    </>,\n    <>\n      Provide enough info for people to help{' '}\n      <span role=\"img\" aria-label=\"archive-box\">\n        🗄️\n      </span>\n    </>,\n    <>\n      Add a category and search so others can find it{' '}\n      <span role=\"img\" aria-label=\"writing-hand\">\n        ✍️\n      </span>\n    </>,\n    <>Come back to comment the answers</>,\n    <>\n      Get your best answer{' '}\n      <span role=\"img\" aria-label=\"simple-smile\">\n        🙂\n      </span>\n    </>,\n  ];\n\n  return <Guidelines title=\"How does it work?\" steps={steps} />;\n};\n"
  },
  {
    "path": "src/pages/News/NewsListHeader.tsx",
    "content": "import { UserRole } from 'oa-shared';\nimport { Link } from 'react-router';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { Button, Flex, Heading } from 'theme-ui';\n\nimport DraftButton from '../common/Drafts/DraftButton';\nimport { headings, listing } from './labels';\n\ninterface IProps {\n  draftCount: number;\n  handleShowDrafts: () => void;\n  showDrafts: boolean;\n}\n\nexport const NewsListHeader = (props: IProps) => {\n  const { draftCount, handleShowDrafts, showDrafts } = props;\n  const { isUserAuthorized } = useProfileStore();\n\n  return (\n    <Flex\n      sx={{\n        flexDirection: 'column',\n        alignItems: 'center',\n        paddingTop: [6, 12],\n        paddingBottom: [4, 8],\n        gap: [4, 8],\n      }}\n    >\n      <Flex>\n        <Heading\n          as=\"h1\"\n          sx={{\n            marginX: 'auto',\n            textAlign: 'center',\n            fontWeight: 'bold',\n            fontSize: 5,\n          }}\n        >\n          {headings.list}\n        </Heading>\n      </Flex>\n      {isUserAuthorized(UserRole.ADMIN) && (\n        <Flex\n          sx={{\n            justifyContent: 'space-between',\n            flexDirection: ['column', 'column', 'row'],\n            gap: [2, 2, 2],\n            paddingX: [2, 0],\n            maxWidth: '100%',\n          }}\n        >\n          <Flex\n            sx={{\n              gap: 2,\n              alignSelf: ['flex-start', 'flex-start', 'flex-end'],\n              display: ['none', 'none', 'flex'],\n            }}\n          >\n            <DraftButton\n              showDrafts={showDrafts}\n              draftCount={draftCount}\n              handleShowDrafts={handleShowDrafts}\n            />\n            <Link to=\"/news/create\">\n              <Button type=\"button\" data-cy=\"create-news\" variant=\"primary\">\n                {listing.create}\n              </Button>\n            </Link>\n          </Flex>\n        </Flex>\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/News/NewsListItem.tsx",
    "content": "import {\n  Category,\n  DisplayDate,\n  Icon,\n  IconCountWithTooltip,\n  InternalLink,\n  ProfileBadgeContentLabel,\n  // ModerationStatus,\n} from 'oa-components';\nimport type { News } from 'oa-shared';\nimport { Highlighter } from 'src/common/Highlighter';\nimport { AspectRatio, Button, Card, Flex, Heading, Image, Text } from 'theme-ui';\nimport { listing } from './labels';\n\ninterface IProps {\n  news: News;\n  query?: string;\n}\n\nexport const NewsListItem = ({ news, query }: IProps) => {\n  const url = `/news/${encodeURIComponent(news.slug)}`;\n  const searchWords = [query || ''];\n\n  return (\n    <Card\n      as=\"li\"\n      data-cy=\"news-list-item\"\n      data-id={news.id}\n      sx={{\n        borderRadius: 4,\n        overflow: 'hidden',\n        position: 'relative',\n      }}\n    >\n      <Flex\n        sx={{\n          flexDirection: 'column',\n          justifyContent: 'space-between',\n        }}\n      >\n        {news.heroImage && (\n          <InternalLink to={url}>\n            <AspectRatio ratio={2 / 1}>\n              <Image\n                src={news.heroImage.publicUrl}\n                sx={{ width: '100%', height: '100%', objectFit: 'cover' }}\n              />\n            </AspectRatio>\n          </InternalLink>\n        )}\n\n        <Flex\n          sx={{\n            flexDirection: 'column',\n            padding: 4,\n          }}\n        >\n          <Flex sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>\n            <InternalLink to={url}>\n              <Heading\n                as=\"h2\"\n                data-cy=\"news-list-item-title\"\n                sx={{\n                  color: 'black',\n                  fontSize: [3, 3, 4],\n                  marginBottom: 0.5,\n                }}\n              >\n                <Highlighter searchWords={searchWords} textToHighlight={news.title} />\n              </Heading>\n            </InternalLink>\n\n            {news.category && <Category category={news.category} sx={{ fontSize: 2 }} />}\n            {news.profileBadge && <ProfileBadgeContentLabel profileBadge={news.profileBadge} />}\n          </Flex>\n\n          <Text variant=\"auxiliary\">\n            {/* Intentionally not passing modifiedAt, news edits are often minor fixes */}\n            <DisplayDate\n              createdAt={news.createdAt}\n              publishedAt={news.publishedAt}\n              publishedAction=\"Published\"\n            />\n          </Text>\n\n          {news.summary && (\n            <Text data-cy=\"news-list-item-summary\" sx={{ paddingY: 2, fontSize: 2 }}>\n              <Highlighter searchWords={searchWords} textToHighlight={news.summary} />\n            </Text>\n          )}\n\n          <Flex sx={{ justifyContent: 'space-between' }}>\n            <InternalLink to={url}>\n              <Button data-cy=\"news-list-item-button\" variant=\"outline\">\n                Read more <Icon glyph=\"arrow-forward\" />\n              </Button>\n            </InternalLink>\n\n            <IconCountWithTooltip\n              count={(news as any).commentCount || 0}\n              icon=\"comment\"\n              text={listing.totalComments}\n            />\n          </Flex>\n        </Flex>\n      </Flex>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "src/pages/News/NewsListing.tsx",
    "content": "import { Loader, Pagination } from 'oa-components';\nimport type { News } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { useSearchParams } from 'react-router';\nimport { logger } from 'src/logger';\nimport { newsContentService } from 'src/pages/News/newsContent.service';\nimport { Flex, Heading } from 'theme-ui';\nimport useDrafts from '../common/Drafts/useDraftsSupabase';\nimport { ITEMS_PER_PAGE } from './constants';\nimport { listing } from './labels';\nimport { NewsListHeader } from './NewsListHeader';\nimport { NewsListItem } from './NewsListItem';\nimport type { NewsSortOption } from './NewsSortOptions';\n\nexport const NewsListing = () => {\n  const [isFetching, setIsFetching] = useState<boolean>(true);\n  const [news, setNews] = useState<News[]>([]);\n  const { draftCount, isFetchingDrafts, drafts, showDrafts, handleShowDrafts } = useDrafts<News>({\n    getDraftCount: newsContentService.getDraftCount,\n    getDrafts: newsContentService.getDrafts,\n  });\n  const [total, setTotal] = useState<number>(0);\n\n  const [searchParams, setSearchParams] = useSearchParams();\n  const q = searchParams.get('q') || '';\n  const sort = searchParams.get('sort') as NewsSortOption;\n  const pageNumber = Math.max(1, parseInt(searchParams.get('page') || '1') || 1);\n\n  useEffect(() => {\n    if (!sort) {\n      const params = new URLSearchParams(searchParams.toString());\n\n      if (q) {\n        params.set('sort', 'MostRelevant');\n      } else {\n        params.set('sort', 'Newest');\n      }\n      setSearchParams(params, { replace: true });\n    } else {\n      // search only when sort is set (avoids duplicate requests)\n      const skip = (pageNumber - 1) * ITEMS_PER_PAGE;\n      fetchNews(skip);\n    }\n  }, [q, sort, pageNumber]);\n\n  const fetchNews = async (skip: number = 0) => {\n    setIsFetching(true);\n\n    try {\n      const result = await newsContentService.search(q, sort, skip);\n      if (result) {\n        setNews(result.items);\n        setTotal(result.total);\n      }\n    } catch (error) {\n      logger.error('error fetching news', error);\n    }\n\n    setIsFetching(false);\n  };\n\n  const updatePageNumber = (value: number) => {\n    const params = new URLSearchParams(searchParams.toString());\n    params.set('page', value.toString());\n    setSearchParams(params, { replace: true });\n  };\n\n  const showLoadMore = !isFetching && news && news.length > 0;\n\n  const newsList = showDrafts ? drafts : news;\n\n  return (\n    <Flex sx={{ flexDirection: 'column', alignItems: 'center' }}>\n      <NewsListHeader\n        draftCount={draftCount}\n        handleShowDrafts={handleShowDrafts}\n        showDrafts={showDrafts}\n      />\n\n      <Flex\n        sx={{\n          flexDirection: 'column',\n          gap: [2, 4],\n          width: '100%',\n          maxWidth: '650px',\n        }}\n      >\n        {news?.length === 0 && !isFetching && (\n          <Heading as=\"h1\" sx={{ marginTop: 4 }}>\n            {listing.noNews}\n          </Heading>\n        )}\n\n        {newsList && newsList.length > 0 && (\n          <Flex\n            as=\"ul\"\n            sx={{\n              listStyle: 'none',\n              padding: 0,\n              margin: 0,\n              flexDirection: 'column',\n              gap: [2, 4],\n            }}\n            variant=\"responsive\"\n          >\n            {newsList.map((news, index) => (\n              <NewsListItem key={index} news={news} query={q} />\n            ))}\n          </Flex>\n        )}\n\n        {showLoadMore && (\n          <Flex sx={{ justifyContent: 'center' }}>\n            <Pagination\n              totalPages={Math.ceil(total / ITEMS_PER_PAGE)}\n              onPageChange={updatePageNumber}\n              page={pageNumber}\n            />\n          </Flex>\n        )}\n      </Flex>\n      {(isFetching || isFetchingDrafts) && <Loader />}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/News/NewsPage.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\nimport { createMemoryRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router';\nimport { act, render, waitFor, within } from '@testing-library/react';\nimport { ThemeProvider } from '@theme-ui/core';\nimport { Provider } from 'mobx-react';\nimport { UserRole } from 'oa-shared';\nimport { FactoryNewsItem } from 'src/test/factories/News';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { afterEach, describe, expect, it, vi } from 'vitest';\nimport { NewsPage } from './NewsPage';\nimport type { News } from 'oa-shared';\nimport { theme } from 'oa-themes';\n\nconst activeUser = FactoryUser({\n  roles: [UserRole.BETA_TESTER],\n});\n\nconst mockNewsItem = FactoryNewsItem({\n  slug: 'testSlug',\n});\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: () => ({\n    profile: FactoryUser(),\n  }),\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('src/stores/Subscription/subscription.store', () => ({\n  useSubscriptionStore: vi.fn(() => ({\n    isSubscribed: vi.fn(() => false),\n    isLoading: vi.fn(() => false),\n    checkAndCacheSubscription: vi.fn(),\n    subscribe: vi.fn(),\n    unsubscribe: vi.fn(),\n    toggleSubscription: vi.fn(),\n  })),\n  SubscriptionStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('src/stores/UsefulVote/usefulVote.store', () => ({\n  useUsefulVoteStore: vi.fn(() => ({\n    getVoteState: vi.fn(() => ({ hasVoted: false, usefulCount: 0, isLoading: false })),\n    hasVoted: vi.fn(() => false),\n    getUsefulCount: vi.fn(() => 0),\n    isLoading: vi.fn(() => false),\n    initializeVote: vi.fn(),\n    toggleVote: vi.fn(),\n    clearCache: vi.fn(),\n  })),\n  UsefulVoteStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\ndescribe('News', () => {\n  afterEach(() => {\n    // Clear all mocks after each test to ensure there's no leakage between tests\n    vi.clearAllMocks();\n  });\n\n  describe('Breadcrumbs', () => {\n    it('displays breadcrumbs without category', async () => {\n      // Arrange\n      mockNewsItem.title = 'Do you prefer camping near a lake or in a forest?';\n      mockNewsItem.category = null;\n\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = getWrapper(mockNewsItem);\n      });\n\n      // Assert: Check the breadcrumb items and chevrons\n      await waitFor(() => {\n        const breadcrumbItems = wrapper.getAllByTestId('breadcrumbsItem');\n        expect(breadcrumbItems).toHaveLength(2);\n        expect(breadcrumbItems[0]).toHaveTextContent('News');\n        expect(breadcrumbItems[1]).toHaveTextContent('Do you prefer camping near a lake or in a forest?');\n\n        // Assert: Check that the first breadcrumb item contains a link\n        const firstLink = within(breadcrumbItems[0]).getByRole('link');\n        expect(firstLink).toBeInTheDocument();\n\n        // Assert: Check for the correct number of chevrons\n        const chevrons = wrapper.getAllByTestId('breadcrumbsChevron');\n        expect(chevrons).toHaveLength(1);\n      });\n    });\n  });\n});\n\nconst getWrapper = (news: News) => {\n  const router = createMemoryRouter(createRoutesFromElements(<Route path=\"/news/:slug\" key={1} element={<NewsPage news={news} />} />), {\n    initialEntries: ['/news/news'],\n  });\n\n  return render(\n    <Provider\n      profileStore={{\n        user: activeUser,\n      }}\n    >\n      <ThemeProvider theme={theme}>\n        <RouterProvider router={router} />\n      </ThemeProvider>\n    </Provider>,\n  );\n};\n"
  },
  {
    "path": "src/pages/News/NewsPage.tsx",
    "content": "import { observer } from 'mobx-react';\nimport {\n  Category,\n  ContentStatistics,\n  DisplayDate,\n  ProfileBadgeContentLabel,\n  TagList,\n  useImageLightbox,\n} from 'oa-components';\nimport type { News } from 'oa-shared';\nimport { useMemo, useRef, useState } from 'react';\nimport { Link } from 'react-router';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport PageHeader from 'src/common/PageHeader';\nimport { Breadcrumbs } from 'src/pages/common/Breadcrumbs/Breadcrumbs';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { buildStatisticsLabel, hasAdminRights } from 'src/utils/helpers';\nimport { AspectRatio, Box, Button, Card, Divider, Flex, Heading, Image, Text } from 'theme-ui';\nimport { CommentSectionSupabase } from '../common/CommentsSupabase/CommentSectionSupabase';\nimport { DraftTag } from '../common/Drafts/DraftTag';\n\ninterface IProps {\n  news: News;\n}\n\nexport const NewsPage = observer(({ news }: IProps) => {\n  const [subscribersCount, setSubscribersCount] = useState<number>(news.subscriberCount);\n  const heroImageRef = useRef<HTMLImageElement>(null);\n  const { profile } = useProfileStore();\n\n  const isEditable = useMemo(() => {\n    return hasAdminRights(profile) || news.author?.username === profile?.username;\n  }, [profile, news.author]);\n\n  const allImages = useMemo(() => {\n    const images: {\n      src: string;\n      alt?: string;\n    }[] = [];\n\n    if (news.heroImage) {\n      images.push({ src: news.heroImage.publicUrl, alt: news.title });\n    }\n\n    news.bodyHtml.replace(/<img[^>]+src=\"([^\">]+)\"[^>]*>/g, (_, src) => {\n      images.push({ src, alt: news.title });\n      return '';\n    });\n    return images;\n  }, [news.id]);\n\n  useImageLightbox({ images: allImages });\n\n  return (\n    <Flex sx={{ flexDirection: 'column', maxWidth: '690px', width: '100%', alignSelf: 'center' }}>\n      <PageHeader>\n        <Breadcrumbs steps={[{ text: 'News', link: '/news' }, { text: news.title }]} />\n      </PageHeader>\n      <Flex\n        sx={{\n          flexDirection: 'column',\n          background: 'white',\n          borderRadius: 4,\n          marginBottom: 4,\n        }}\n      >\n        {news.heroImage && (\n          <AspectRatio ratio={2 / 1}>\n            <Image\n              key={news.id}\n              ref={heroImageRef}\n              src={news.heroImage.publicUrl}\n              sx={{\n                borderTopLeftRadius: 2,\n                borderTopRightRadius: 2,\n                width: '100%',\n                height: '100%',\n                objectFit: 'cover',\n                cursor: 'pointer',\n              }}\n            />\n          </AspectRatio>\n        )}\n\n        <Flex\n          sx={{\n            flexDirection: 'column',\n            padding: [2, 4, 6],\n            paddingBottom: [1, 2, 4],\n          }}\n        >\n          <Flex sx={{ flexDirection: 'column', gap: 2, marginBottom: 3 }}>\n            {news.category || news.profileBadge ? (\n              <Flex sx={{ alignItems: 'center', gap: 2 }}>\n                {news.category && <Category category={news.category} />}\n                {news.profileBadge && <ProfileBadgeContentLabel profileBadge={news.profileBadge} />}\n              </Flex>\n            ) : null}\n\n            <Heading as=\"h1\" data-cy=\"news-title\" data-testid=\"news-title\">\n              {news.title}\n            </Heading>\n\n            <Text variant=\"auxiliary\">\n              {/* Intentionally not passing modifiedAt - news edits are often minor fixes */}\n              <DisplayDate\n                createdAt={news.createdAt}\n                publishedAt={news.publishedAt}\n                publishedAction=\"Published\"\n              />\n            </Text>\n\n            {news.isDraft && <DraftTag />}\n\n            {isEditable && (\n              <ClientOnly fallback={<></>}>\n                {() => (\n                  <Link to={'/news/' + news.slug + '/edit'}>\n                    <Button type=\"button\" variant=\"primary\" data-cy=\"edit\">\n                      Edit\n                    </Button>\n                  </Link>\n                )}\n              </ClientOnly>\n            )}\n          </Flex>\n\n          <Divider />\n\n          <Box\n            data-cy=\"news-body\"\n            sx={{\n              alignSelf: 'stretch',\n              fontFamily: 'body',\n              lineHeight: 2,\n              a: {\n                textDecoration: 'underline',\n                '&:hover': { textDecoration: 'none' },\n              },\n              h1: {\n                lineHeight: 1.2,\n                marginBottom: 0,\n              },\n              h2: {\n                lineHeight: 1.2,\n                marginBottom: 0,\n              },\n              h3: {\n                lineHeight: 1.2,\n                marginBottom: 0,\n              },\n              h4: {\n                lineHeight: 1.2,\n                marginBottom: 0,\n              },\n              h5: {\n                lineHeight: 1.2,\n                marginBottom: 0,\n              },\n              h6: {\n                lineHeight: 1.2,\n                marginBottom: 0,\n              },\n              blockQuote: {\n                paddingX: 4,\n                paddingY: 2,\n                margin: 0,\n                backgroundColor: '#f4f8fd',\n                borderLeft: '3px solid #c8d8ec',\n              },\n              img: {\n                borderRadius: 2,\n                maxWidth: '100%',\n                display: 'block',\n                margin: '0 auto',\n              },\n              iframe: {\n                maxHeight: ['300px', '370px', '420px'],\n              },\n            }}\n          >\n            <div dangerouslySetInnerHTML={{ __html: news.bodyHtml }} />\n          </Box>\n\n          <Divider />\n\n          <Flex\n            sx={{\n              flexDirection: 'column',\n              flexWrap: 'wrap',\n              gap: 3,\n              justifyContent: 'stretch',\n            }}\n          >\n            {news.tags && news.tags.length > 0 && (\n              <TagList data-cy=\"news-tags\" tags={news.tags.map((t) => ({ label: t.name }))} />\n            )}\n\n            <ContentStatistics\n              statistics={[\n                {\n                  icon: 'show',\n                  label: buildStatisticsLabel({\n                    stat: news.totalViews,\n                    statUnit: 'view',\n                    usePlural: true,\n                  }),\n                  stat: news.totalViews,\n                },\n                {\n                  icon: 'thunderbolt-grey',\n                  label: buildStatisticsLabel({\n                    stat: subscribersCount,\n                    statUnit: 'following',\n                    usePlural: false,\n                  }),\n                  stat: subscribersCount,\n                },\n                {\n                  icon: 'comment-outline',\n                  label: buildStatisticsLabel({\n                    stat: news.commentCount,\n                    statUnit: 'comment',\n                    usePlural: true,\n                  }),\n                  stat: news.commentCount,\n                },\n              ]}\n              alwaysShow\n            />\n          </Flex>\n        </Flex>\n      </Flex>\n\n      <ClientOnly fallback={<></>}>\n        {() => (\n          <Card\n            variant=\"responsive\"\n            sx={{\n              background: 'softblue',\n              borderTop: 0,\n              padding: [3, 4],\n            }}\n          >\n            <CommentSectionSupabase\n              authors={news.author?.id ? [news.author?.id] : []}\n              sourceId={news.id}\n              sourceType=\"news\"\n              setSubscribersCount={setSubscribersCount}\n            />\n          </Card>\n        )}\n      </ClientOnly>\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/News/NewsSortOptions.ts",
    "content": "export type NewsSortOption =\n  | 'MostRelevant'\n  | 'Newest'\n  | 'LatestComments'\n  | 'Comments'\n  | 'LeastComments';\n\nconst BaseOptions = new Map<NewsSortOption, string>();\nBaseOptions.set('Newest', 'Newest');\n// BaseOptions.set('LatestComments', 'Latest Comments')\nBaseOptions.set('Comments', 'Comments');\nBaseOptions.set('LeastComments', 'Least Comments');\n\nconst QueryParamOptions = new Map<NewsSortOption, string>(BaseOptions);\nQueryParamOptions.set('MostRelevant', 'Most Relevant');\n\nconst toArray = (hasQueryParam: boolean) => {\n  const options = hasQueryParam ? QueryParamOptions : BaseOptions;\n  return Array.from(options, ([value, label]) => ({\n    label: label,\n    value: value,\n  }));\n};\n\nexport const NewsSortOptions = {\n  get: (key: NewsSortOption) => QueryParamOptions.get(key) ?? '',\n  toArray,\n};\n"
  },
  {
    "path": "src/pages/News/constants.ts",
    "content": "export const NEWS_MIN_TITLE_LENGTH = 5;\nexport const NEWS_MAX_TITLE_LENGTH = 60;\nexport const NEWS_MAX_DESCRIPTION_LENGTH = 10000;\nexport const ITEMS_PER_PAGE = 10;\n"
  },
  {
    "path": "src/pages/News/labels.ts",
    "content": "import type { ILabels } from 'src/common/Form/types';\n\nexport const buttons = {\n  create: 'Publish',\n  draft: { create: 'Save as draft', update: 'Save to draft' },\n  edit: 'Update',\n};\n\nexport const headings = {\n  create: 'Add news',\n  edit: 'Edit your news',\n  list: 'Latest news from the community',\n};\n\nexport const fields: ILabels = {\n  category: {\n    placeholder: 'Start typing to find the perfect category...',\n    title: 'Which category fits your news?',\n  },\n  body: {\n    placeholder: 'Write and structure the body of your article. Markdown is also supported.',\n    title: 'Body',\n  },\n  profileBadge: {\n    title: 'Limit to badged users',\n    placeholder: 'Select if this is for profiles with a certain profile badge',\n  },\n  summary: {\n    title: 'Summary',\n    description: 'What will show on the main page',\n    placeholder: '180 characters max',\n  },\n  tags: {\n    title: 'Select tags',\n  },\n  title: {\n    title: 'Title',\n  },\n  heroImage: {\n    title: 'Cover image',\n    description: 'This image should be landscape with 2:1 aspect ratio. We advise 1240x620px',\n  },\n};\n\nexport const listing = {\n  create: 'Add news',\n  filterCategory: 'Filter by category',\n  incompleteProfile: 'Complete your profile to add news',\n  loadMore: 'Load More',\n  loggedOut: 'Gotta log in please for the awesome power of news adding.',\n  noNews: 'No new has been added yet',\n  search: 'Search for news',\n  sort: 'Sort by',\n  totalComments: 'Total comments',\n  usefulness: 'How useful it is',\n};\n"
  },
  {
    "path": "src/pages/News/newsContent.service.ts",
    "content": "import type { Category, News } from 'oa-shared';\nimport { logger } from 'src/logger';\nimport type { NewsSortOption } from './NewsSortOptions';\n\nexport enum NewsSearchParams {\n  category = 'category',\n  q = 'q',\n  sort = 'sort',\n}\n\nconst search = async (q: string, sort: NewsSortOption, skip: number = 0) => {\n  try {\n    const url = new URL('/api/news', window.location.origin);\n    url.searchParams.set('q', q);\n    url.searchParams.set('sort', sort);\n    if (skip > 0) {\n      url.searchParams.set('skip', skip.toString());\n    }\n    const response = await fetch(url);\n    const json = (await response.json()) as {\n      items: News[];\n      total: number;\n    };\n    const { items, total } = json;\n    return { items, total };\n  } catch (error) {\n    logger.error('Failed to fetch news', { error });\n    return { items: [], total: 0 };\n  }\n};\n\nconst getCategories = async () => {\n  try {\n    const response = await fetch('/api/categories/news');\n    const categories = (await response.json()) as Category[];\n    return categories;\n  } catch (error) {\n    logger.error('Failed to fetch news', { error });\n    return [];\n  }\n};\n\nconst getDraftCount = async () => {\n  try {\n    const response = await fetch('/api/news/drafts/count');\n    const { total } = (await response.json()) as { total: number };\n\n    return total;\n  } catch (error) {\n    logger.error('Failed to fetch draft count', { error });\n    return 0;\n  }\n};\n\nconst getDrafts = async () => {\n  try {\n    const response = await fetch('/api/news/drafts');\n    const { items } = (await response.json()) as { items: News[] };\n\n    return items;\n  } catch (error) {\n    logger.error('Failed to fetch draft news', { error });\n    return [];\n  }\n};\n\nexport const newsContentService = {\n  search,\n  getCategories,\n  getDraftCount,\n  getDrafts,\n};\n"
  },
  {
    "path": "src/pages/NotFound/NotFound.tsx",
    "content": "import type { FC } from 'react';\nimport { Link } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\nimport { Flex, Image, Text } from 'theme-ui';\nimport errorImage from '../../assets/images/404error.png';\n\nexport const NotFoundPage: FC = () => (\n  <Main>\n    <Flex\n      sx={{\n        flex: 1,\n        alignItems: 'center',\n        flexDirection: 'column',\n        justifyContent: 'center',\n        textAlign: 'center',\n      }}\n    >\n      <Image\n        src={errorImage}\n        sx={{\n          maxWidth: '45em',\n          width: '98%',\n          marginBottom: '2vw',\n        }}\n      />\n      <Text data-test=\"NotFound: Heading\">\n        Nada, page not found 💩\n        <br />\n        Go to the <Link to=\"/\">home page</Link>\n      </Text>\n    </Flex>\n  </Main>\n);\n"
  },
  {
    "path": "src/pages/PageList.tsx",
    "content": "import { MODULE } from 'src/modules';\n\ninterface IPageNavigation {\n  module: MODULE;\n  path: string;\n  title: string;\n}\n\nconst QuestionModule: IPageNavigation = {\n  module: MODULE.QUESTIONS,\n  path: '/questions',\n  title: 'Questions',\n};\n\nconst ResearchModule: IPageNavigation = {\n  module: MODULE.RESEARCH,\n  path: '/research',\n  title: 'Research',\n};\n\nconst library: IPageNavigation = {\n  module: MODULE.LIBRARY,\n  path: '/library',\n  title: 'Library',\n};\nconst academy: IPageNavigation = {\n  module: MODULE.ACADEMY,\n  path: '/academy',\n  title: 'Academy',\n};\n\nconst maps: IPageNavigation = {\n  module: MODULE.MAP,\n  path: '/map',\n  title: 'Map',\n};\n\nconst news: IPageNavigation = {\n  module: MODULE.NEWS,\n  path: '/news',\n  title: 'News',\n};\n\nexport const getAvailablePageList = (supportedModules: MODULE[]): IPageNavigation[] => {\n  return COMMUNITY_PAGES.filter((pageItem) => supportedModules.includes(pageItem.module));\n};\n\nexport const COMMUNITY_PAGES: IPageNavigation[] = [\n  news,\n  library,\n  maps,\n  academy,\n  ResearchModule,\n  QuestionModule,\n];\n"
  },
  {
    "path": "src/pages/Question/Content/Common/FormFields/QuestionDescription.field.tsx",
    "content": "import { FieldTextarea } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { QUESTION_MAX_DESCRIPTION_LENGTH } from 'src/pages/Question/constants';\nimport { fields } from 'src/pages/Question/labels';\nimport { composeValidators, required } from 'src/utils/validators';\n\nexport const QuestionDescriptionField = () => {\n  const { placeholder, title } = fields.description;\n  const name = 'description';\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title} required>\n      <Field\n        data-cy={`field-${name}`}\n        component={FieldTextarea}\n        id={name}\n        name={name}\n        maxLength={QUESTION_MAX_DESCRIPTION_LENGTH}\n        placeholder={placeholder}\n        validate={composeValidators(required)}\n        showCharacterCount\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Question/Content/Common/FormFields/QuestionImages.field.tsx",
    "content": "import { ImageInputV2 } from 'oa-components';\nimport type { MediaWithPublicUrl } from 'oa-shared';\nimport { commonStyles } from 'oa-themes';\nimport { useState } from 'react';\nimport { useForm, useFormState } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { ImageInputFieldWrapper } from 'src/pages/common/FormFields/ImageInputFieldWrapper';\nimport { fields } from 'src/pages/Question/labels';\nimport { storageService } from 'src/services/storageService';\nimport { Flex, Spinner, Text } from 'theme-ui';\n\ninterface IProps {\n  contentType: 'questions';\n  contentId: number | null;\n  maxImages: number;\n}\n\nexport const QuestionImagesField = (props: IProps) => {\n  const { contentType, contentId, maxImages } = props;\n  const state = useFormState();\n  const form = useForm();\n  const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);\n  const [uploadError, setUploadError] = useState<string | null>(null);\n\n  const images = (state.values.images as MediaWithPublicUrl[]) || [];\n\n  const handleImageSelect = async (file: File | undefined, imageIndex: number) => {\n    if (!file) {\n      handleDeleteImage(imageIndex);\n      return;\n    }\n\n    setUploadingIndex(imageIndex);\n    setUploadError(null);\n\n    try {\n      const uploadedImage = await storageService.imageUpload(contentId, contentType, file);\n\n      // Add new image and deduplicate by id\n      const allImages = [...images, uploadedImage];\n      const uniqueImagesMap = new Map(allImages.map((img) => [img.id, img]));\n      const uniqueImages = Array.from(uniqueImagesMap.values());\n      form.change('images', uniqueImages);\n    } catch (error) {\n      setUploadError(\n        error instanceof Error ? error.message : 'Failed to upload image. Please try again.',\n      );\n    } finally {\n      setUploadingIndex(null);\n    }\n  };\n\n  const handleDeleteImage = (imageIndex: number) => {\n    const updatedImages = images.filter((_, i) => i !== imageIndex);\n    form.change('images', updatedImages);\n  };\n\n  return (\n    <FormFieldWrapper htmlFor=\"images\" text={fields.images.title}>\n      {uploadError && (\n        <Text sx={{ color: 'error', fontSize: 1, mb: 2, width: '100%' }}>{uploadError}</Text>\n      )}\n\n      <Flex sx={{ gap: 2, flexWrap: 'wrap' }}>\n        {/* Show existing images in order */}\n        {images.map((image, index) => (\n          <ImageInputFieldWrapper key={`image-upload-${index}`} data-cy={`image-upload-${index}`}>\n            <ImageInputV2\n              image={image}\n              onFilesChange={(file) => handleImageSelect(file, index)}\n              onError={setUploadError}\n            />\n          </ImageInputFieldWrapper>\n        ))}\n\n        {/* Show upload slot at the end if under the limit */}\n        {images.length < maxImages && (\n          <ImageInputFieldWrapper data-cy=\"new-image-upload\">\n            {uploadingIndex === images.length ? (\n              <Spinner size={20} sx={{ color: commonStyles.colors.darkGrey }} />\n            ) : (\n              <ImageInputV2\n                onFilesChange={(file) => handleImageSelect(file, images.length)}\n                onError={setUploadError}\n              />\n            )}\n          </ImageInputFieldWrapper>\n        )}\n      </Flex>\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Question/Content/Common/FormFields/index.ts",
    "content": "export { QuestionDescriptionField } from './QuestionDescription.field';\nexport { QuestionImagesField } from './QuestionImages.field';\n"
  },
  {
    "path": "src/pages/Question/Content/Common/QuestionForm.tsx",
    "content": "import { FormApi } from 'node_modules/final-form/dist';\nimport type { QuestionFormData } from 'oa-shared';\nimport { useMemo, useState } from 'react';\nimport { Form } from 'react-final-form';\nimport { FormWrapper } from 'src/common/Form/FormWrapper';\nimport type { MainFormAction } from 'src/common/Form/types';\nimport { useToast } from 'src/common/Toast';\nimport { CategoryField, TagsField, TitleField } from 'src/pages/common/FormFields';\nimport { errorSet } from 'src/pages/Library/Content/utils/transformLibraryErrors';\nimport { QuestionPostingGuidelines } from 'src/pages/Question/Content/Common';\nimport {\n  QuestionDescriptionField,\n  QuestionImagesField,\n} from 'src/pages/Question/Content/Common/FormFields';\nimport * as LABELS from 'src/pages/Question/labels';\nimport { questionService } from 'src/services/questionService';\nimport { composeValidators, endsWithQuestionMark, minValue, required } from 'src/utils/validators';\nimport { QUESTION_MAX_IMAGES, QUESTION_MIN_TITLE_LENGTH } from '../../constants';\n\ninterface IProps {\n  'data-testid'?: string;\n  id: number | null;\n  formData: QuestionFormData | null;\n  formAction: MainFormAction;\n}\n\nexport const QuestionForm = (props: IProps) => {\n  const toast = useToast();\n  const [isSubmittingDraft, setIsSubmittingDraft] = useState(false);\n  const id = props?.id || null;\n\n  const initialValues = useMemo<QuestionFormData>(\n    () =>\n      ({\n        title: props.formData?.title || '',\n        description: props.formData?.description || '',\n        category: props.formData?.category || null,\n        images: props.formData?.images || [],\n        tags: props.formData?.tags || null,\n        isDraft: props.formData?.isDraft || null,\n      }) satisfies QuestionFormData,\n    [],\n  );\n\n  const onSubmit = async (\n    form: FormApi<QuestionFormData, Partial<QuestionFormData>>,\n    values: QuestionFormData,\n    isDraft: boolean = false,\n  ) => {\n    const promise = questionService.upsert(id, {\n      title: values.title!,\n      description: values.description!,\n      tags: values.tags || null,\n      category: values.category || null,\n      images: values.images || null,\n      isDraft: isDraft,\n    });\n\n    toast.promise(promise, {\n      loading: isDraft ? 'Saving draft...' : 'Submitting question...',\n      success: (data) => {\n        form.reset(values);\n        return {\n          message: isDraft ? 'Draft saved!' : 'Question submitted!',\n          actionLink: {\n            href: `/questions/${data.slug}`,\n            label: isDraft ? 'View draft' : 'View question',\n          },\n        };\n      },\n      error: (error) => {\n        console.error(error);\n        return `Error: ${error.message}`;\n      },\n      duration: 10000,\n    });\n\n    await promise;\n\n    await new Promise((resolve) => setTimeout(resolve, 1000)); // to avoid spam clicking\n  };\n\n  return (\n    <Form<QuestionFormData>\n      data-testid={props['data-testid']}\n      onSubmit={async (values, form) => await onSubmit(form, values, false)}\n      initialValues={initialValues}\n      render={({\n        errors,\n        handleSubmit,\n        hasValidationErrors,\n        submitFailed,\n        submitting,\n        form,\n        values,\n      }) => {\n        const errorsClientSide = [errorSet(errors, LABELS.fields)];\n\n        const handleSubmitDraft = async () => {\n          setIsSubmittingDraft(true);\n          try {\n            await onSubmit(form, values, true);\n            form.reset(values);\n          } finally {\n            setIsSubmittingDraft(false);\n          }\n        };\n\n        const validate = composeValidators(\n          required,\n          minValue(QUESTION_MIN_TITLE_LENGTH),\n          endsWithQuestionMark(),\n        );\n\n        return (\n          <FormWrapper\n            buttonLabel={LABELS.buttons[props.formAction]}\n            errorsClientSide={errorsClientSide}\n            guidelines={<QuestionPostingGuidelines />}\n            handleSubmit={handleSubmit}\n            handleSubmitDraft={handleSubmitDraft}\n            hasValidationErrors={hasValidationErrors}\n            heading={LABELS.headings[props.formAction]}\n            submitFailed={submitFailed}\n            submitting={submitting || isSubmittingDraft}\n            hideSubmittingMessage={true}\n          >\n            <TitleField\n              placeholder={LABELS.fields.title.placeholder}\n              validate={validate}\n              title={LABELS.fields.title.title}\n            />\n            <QuestionDescriptionField />\n            <QuestionImagesField\n              contentType=\"questions\"\n              contentId={id}\n              maxImages={QUESTION_MAX_IMAGES}\n            />\n            <CategoryField type=\"questions\" />\n            <TagsField title={LABELS.fields.tags.title} />\n          </FormWrapper>\n        );\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "src/pages/Question/Content/Common/QuestionPostingGuidelines.tsx",
    "content": "import { ExternalLink, Guidelines } from 'oa-components';\nimport { useContext } from 'react';\nimport { TenantContext } from 'src/pages/common/TenantContext';\n\nexport const QuestionPostingGuidelines = () => {\n  const tenantContext = useContext(TenantContext);\n  const guidelinesUrl = tenantContext?.questionsGuidelines;\n\n  const steps = [\n    ...(guidelinesUrl\n      ? [\n          <>\n            Have a look at our{' '}\n            <ExternalLink sx={{ color: 'blue' }} href={guidelinesUrl}>\n              question guidelines.\n            </ExternalLink>\n          </>,\n        ]\n      : []),\n    <>\n      Write your question (in English){' '}\n      <span role=\"img\" aria-label=\"raised-hand\">\n        🙌\n      </span>\n    </>,\n    <>\n      Double check if it's already made and{' '}\n      <ExternalLink sx={{ color: 'blue' }} href=\"/questions\">\n        search{' '}\n      </ExternalLink>\n    </>,\n    <>\n      Provide enough info for people to help{' '}\n      <span role=\"img\" aria-label=\"archive-box\">\n        🗄️\n      </span>\n    </>,\n    <>\n      Add a category and search so others can find it{' '}\n      <span role=\"img\" aria-label=\"writing-hand\">\n        ✍️\n      </span>\n    </>,\n    <>Come back to comment the answers</>,\n    <>\n      Get your best answer{' '}\n      <span role=\"img\" aria-label=\"simple-smile\">\n        🙂\n      </span>\n    </>,\n  ];\n\n  return <Guidelines title=\"How does it work?\" steps={steps} />;\n};\n"
  },
  {
    "path": "src/pages/Question/Content/Common/index.ts",
    "content": "export { QuestionPostingGuidelines } from './QuestionPostingGuidelines';\n"
  },
  {
    "path": "src/pages/Question/QuestionListHeader.tsx",
    "content": "import debounce from 'debounce';\nimport {\n  ButtonIcon,\n  CategoryHorizonalList,\n  ReturnPathLink,\n  SearchField,\n  Select,\n  Tooltip,\n} from 'oa-components';\nimport type { Category } from 'oa-shared';\nimport { useCallback, useEffect, useState } from 'react';\nimport { Link, useSearchParams } from 'react-router';\nimport { FieldContainer } from 'src/common/Form/FieldContainer';\nimport { useClickOutside } from 'src/common/hooks/useClickOutside';\nimport { UserAction } from 'src/common/UserAction';\nimport type { FilterSection } from 'src/pages/common/Layout/MobileSortModal';\nimport { MobileSortModal } from 'src/pages/common/Layout/MobileSortModal';\nimport { categoryService } from 'src/services/categoryService';\nimport { Box, Button, Flex } from 'theme-ui';\nimport DraftButton from '../common/Drafts/DraftButton';\nimport { ListHeader } from '../common/Layout/ListHeader';\nimport { headings, listing } from './labels';\nimport type { QuestionSortOption } from './QuestionSortOptions';\nimport { QuestionSortOptions } from './QuestionSortOptions';\nimport { QuestionSearchParams } from './question.service';\n\ninterface IProps {\n  itemCount?: number;\n  draftCount?: number;\n  handleShowDrafts: () => void;\n  showDrafts: boolean;\n}\n\nconst DEFAULT_SORT: QuestionSortOption = 'Newest';\n\nexport const QuestionListHeader = (props: IProps) => {\n  const { itemCount, draftCount, handleShowDrafts, showDrafts } = props;\n\n  const [categories, setCategories] = useState<Category[]>([]);\n  const [searchParams, setSearchParams] = useSearchParams();\n  const q = searchParams.get(QuestionSearchParams.q);\n  const [searchString, setSearchString] = useState<string>(() => q ?? '');\n\n  const categoryParam = Number(searchParams.get(QuestionSearchParams.category));\n  const category: Category | null = categoryParam\n    ? (categories?.find((x) => x.id === +categoryParam) ?? null)\n    : null;\n  const sort = searchParams.get(QuestionSearchParams.sort) as QuestionSortOption;\n\n  const [isSearchOpen, setIsSearchOpen] = useState(false);\n  const [isSortModalOpen, setIsSortModalOpen] = useState(false);\n  const [pendingSort, setPendingSort] = useState<QuestionSortOption>(sort || DEFAULT_SORT);\n\n  const handleOpenSortModal = () => {\n    setPendingSort(sort || DEFAULT_SORT);\n    setIsSortModalOpen(true);\n  };\n\n  const handleCloseSortModal = () => {\n    setIsSortModalOpen(false);\n  };\n\n  const handleToggleSearchOpen = () => {\n    setIsSearchOpen((x) => !x);\n  };\n\n  useEffect(() => {\n    const initCategories = async () => {\n      const categories = (await categoryService.getCategories('questions')) || [];\n      setCategories(categories);\n    };\n\n    initCategories();\n\n    if (!searchParams.get(QuestionSearchParams.sort)) {\n      const params = new URLSearchParams(searchParams.toString());\n      params.set(QuestionSearchParams.sort, 'Newest');\n      setSearchParams(params, { replace: true });\n    }\n  }, []);\n\n  const updateFilter = useCallback(\n    (key: QuestionSearchParams, value: string) => {\n      const params = new URLSearchParams(searchParams.toString());\n      if (value) {\n        params.set(key, value);\n      } else {\n        params.delete(key);\n      }\n      setSearchParams(params);\n    },\n    [searchParams],\n  );\n\n  const onSearchInputChange = useCallback(\n    debounce((value: string) => {\n      searchValue(value);\n    }, 500),\n    [searchParams],\n  );\n\n  const searchValue = (value: string) => {\n    const params = new URLSearchParams(searchParams.toString());\n    params.set('q', value);\n\n    if (value.length > 0 && sort !== 'MostRelevant') {\n      params.set('sort', 'MostRelevant');\n    }\n\n    if (value.length === 0 || !value) {\n      params.set('sort', 'Newest');\n    }\n\n    setSearchParams(params);\n  };\n\n  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    searchValue(searchString);\n    setIsSearchOpen(false);\n  };\n\n  const sortOptions = QuestionSortOptions.toArray(!!q);\n\n  const effectiveDefaultSort = q ? 'MostRelevant' : DEFAULT_SORT;\n  const activeFilterCount =\n    (sort && sort !== effectiveDefaultSort ? 1 : 0) + (categoryParam > 0 ? 1 : 0);\n\n  const handleApplySort = () => {\n    updateFilter(QuestionSearchParams.sort, pendingSort);\n    handleCloseSortModal();\n  };\n\n  const handleResetSort = () => {\n    updateFilter(QuestionSearchParams.sort, DEFAULT_SORT);\n    setPendingSort(DEFAULT_SORT);\n  };\n\n  const sortSections: FilterSection[] = [\n    {\n      title: 'Sort',\n      options: sortOptions,\n      selectedValue: pendingSort,\n      onSelect: (value) => setPendingSort(value as QuestionSortOption),\n    },\n  ];\n\n  const formRef = useClickOutside(() => {\n    setIsSearchOpen(false);\n  });\n\n  const actionComponents = (\n    <UserAction\n      incompleteProfile={\n        <>\n          <Link\n            to=\"/settings\"\n            data-tooltip-id=\"tooltip\"\n            data-tooltip-content={listing.incompleteProfile}\n          >\n            <Button type=\"button\" data-cy=\"complete-profile-question\" variant=\"disabled\">\n              {listing.create}\n            </Button>\n          </Link>\n          <Tooltip id=\"tooltip\" />\n        </>\n      }\n      loggedIn={\n        <>\n          <DraftButton\n            showDrafts={showDrafts}\n            draftCount={draftCount}\n            handleShowDrafts={handleShowDrafts}\n          />\n          <Link to=\"/questions/create\">\n            <Button type=\"button\" data-cy=\"create-question\" variant=\"primary\">\n              {listing.create}\n            </Button>\n          </Link>\n        </>\n      }\n      loggedOut={\n        <ReturnPathLink to=\"/sign-up\">\n          <Button type=\"button\" data-cy=\"sign-up\" variant=\"primary\">\n            {listing.create}\n          </Button>\n        </ReturnPathLink>\n      }\n    />\n  );\n\n  const categoryComponent = (\n    <CategoryHorizonalList\n      allCategories={categories}\n      activeCategory={category}\n      setActiveCategory={(updatedCategory) =>\n        updateFilter(\n          QuestionSearchParams.category,\n          updatedCategory ? (updatedCategory as Category).id.toString() : '',\n        )\n      }\n    />\n  );\n\n  const filteringComponents = (\n    <Flex\n      sx={{\n        gap: 2,\n        flexDirection: ['column', 'column', 'row'],\n        flexWrap: 'wrap',\n        display: ['none', 'none', 'flex'],\n      }}\n    >\n      <Flex sx={{ width: ['100%', '100%', '230px'] }}>\n        <FieldContainer>\n          <Select\n            options={QuestionSortOptions.toArray(!!q)}\n            placeholder={listing.sort}\n            value={sort ? { label: QuestionSortOptions.get(sort), value: sort } : undefined}\n            onChange={(sortBy) => updateFilter(QuestionSearchParams.sort, sortBy.value)}\n          />\n        </FieldContainer>\n      </Flex>\n      <Flex sx={{ width: ['100%', '100%', '300px'] }}>\n        <SearchField\n          dataCy=\"questions-search-box\"\n          placeHolder={listing.search}\n          value={searchString}\n          onChange={(value) => {\n            setSearchString(value);\n            onSearchInputChange(value);\n          }}\n          onClear={() => {\n            setSearchString('');\n            searchValue('');\n          }}\n          onClickSearch={() => searchValue(searchString)}\n        />\n      </Flex>\n    </Flex>\n  );\n\n  const mobileFilteringComponents = (\n    <Flex sx={{ display: ['flex', 'flex', 'none'], gap: '5px' }}>\n      <Flex sx={{ position: 'relative' }}>\n        <ButtonIcon\n          onClick={handleOpenSortModal}\n          icon=\"sliders\"\n          sx={{\n            borderRadius: 1,\n            padding: '9px',\n            '&:hover': {\n              backgroundColor: 'background',\n            },\n          }}\n        />\n        {activeFilterCount > 0 && (\n          <Box\n            sx={{\n              position: 'absolute',\n              top: '-4px',\n              right: '-4px',\n              minWidth: '18px',\n              height: '18px',\n              borderRadius: '50%',\n              backgroundColor: 'red',\n              color: 'background',\n              fontSize: 0,\n              fontWeight: 'bold',\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n            }}\n          >\n            {activeFilterCount}\n          </Box>\n        )}\n      </Flex>\n      <ButtonIcon\n        onClick={handleToggleSearchOpen}\n        icon=\"search\"\n        sx={{\n          border: 'none',\n          background: 'transparent',\n          '&:hover': {\n            backgroundColor: 'background',\n          },\n        }}\n      />\n    </Flex>\n  );\n\n  const mobileSearchBar = (\n    <Flex sx={{ display: ['flex', 'flex', 'none'], width: '100%' }}>\n      <div ref={formRef} style={{ width: '100%' }}>\n        <form onSubmit={handleSubmit} style={{ width: '100%' }}>\n          <SearchField\n            isExpanded\n            autoFocus\n            dataCy=\"questions-search-box\"\n            placeHolder={listing.search}\n            value={searchString}\n            onChange={(value) => {\n              setSearchString(value);\n              onSearchInputChange(value);\n            }}\n            onClear={() => {\n              setSearchString('');\n              searchValue('');\n            }}\n            onClickSearch={() => searchValue(searchString)}\n            onBack={() => {\n              setIsSearchOpen(false);\n            }}\n          />\n        </form>\n      </div>\n    </Flex>\n  );\n\n  return (\n    <>\n      <ListHeader\n        itemCount={(showDrafts ? draftCount : itemCount) || 0}\n        actionComponents={isSearchOpen ? null : actionComponents}\n        showDrafts={false}\n        headingTitle={headings.list || ''}\n        categoryComponent={categoryComponent}\n        filteringComponents={filteringComponents}\n        mobileFilteringComponents={isSearchOpen ? mobileSearchBar : mobileFilteringComponents}\n        searchString={q || undefined}\n      />\n      <MobileSortModal\n        isOpen={isSortModalOpen}\n        onDismiss={handleCloseSortModal}\n        title=\"Sort\"\n        sections={sortSections}\n        onApply={handleApplySort}\n        onReset={handleResetSort}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "src/pages/Question/QuestionListItem.tsx",
    "content": "import {\n  Category,\n  IconCountWithTooltip,\n  InternalLink,\n  // ModerationStatus,\n} from 'oa-components';\nimport type { Question } from 'oa-shared';\nimport { Highlighter } from 'src/common/Highlighter';\nimport { Box, Card, Flex, Heading } from 'theme-ui';\nimport { UserNameTag } from '../common/UserNameTag/UserNameTag';\nimport { listing } from './labels';\n\ninterface IProps {\n  question: Question;\n  query?: string;\n}\n\nexport const QuestionListItem = ({ question, query }: IProps) => {\n  const url = `/questions/${encodeURIComponent(question.slug)}`;\n  const searchWords = [query || ''];\n\n  return (\n    <Card\n      as=\"li\"\n      data-cy=\"question-list-item\"\n      data-id={question.id}\n      sx={{\n        position: 'relative',\n        border: 0,\n        overflow: 'hidden',\n      }}\n    >\n      <Flex\n        sx={{\n          flex: 1,\n          justifyContent: 'space-between',\n        }}\n      >\n        <Flex\n          sx={{\n            flexDirection: 'column',\n            gap: 1,\n            paddingX: 3,\n            paddingY: 2,\n          }}\n        >\n          <Flex sx={{ gap: 2, flexWrap: 'wrap' }}>\n            {/* {moderation !== 'accepted' && (\n              <Box>\n                <ModerationStatus status={moderation} contentType=\"question\" />\n              </Box>\n            )} */}\n\n            <Heading\n              data-cy=\"question-list-item-title\"\n              as=\"h2\"\n              sx={{\n                color: 'black',\n                fontSize: [3, 3, 4],\n                marginBottom: 0.5,\n              }}\n            >\n              <InternalLink\n                to={url}\n                sx={{\n                  textDecoration: 'none',\n                  color: 'inherit',\n                  '&:focus': {\n                    outline: 'none',\n                    textDecoration: 'none',\n                  },\n                  '&::after': {\n                    content: '\"\"',\n                    position: 'absolute',\n                    left: 0,\n                    top: 0,\n                    right: 0,\n                    bottom: 0,\n                  },\n                }}\n              >\n                <Highlighter searchWords={searchWords} textToHighlight={question.title} />\n              </InternalLink>\n            </Heading>\n\n            {question.category && <Category category={question.category} sx={{ fontSize: 2 }} />}\n          </Flex>\n\n          <Flex>\n            {question.author && (\n              <UserNameTag\n                publishedAction=\"Asked\"\n                author={question.author}\n                createdAt={question.createdAt}\n                publishedAt={question.publishedAt}\n              />\n            )}\n          </Flex>\n        </Flex>\n\n        <Flex\n          sx={{\n            display: ['none', 'flex', 'flex'],\n            justifyContent: 'flex-end',\n            alignItems: 'center',\n            flex: 1,\n            gap: 12,\n            paddingX: 12,\n          }}\n        >\n          <IconCountWithTooltip\n            count={question.usefulCount}\n            icon=\"star-active\"\n            text={listing.usefulness}\n          />\n          <IconCountWithTooltip\n            count={question.commentCount || 0}\n            icon=\"comment\"\n            text={listing.totalComments}\n          />\n        </Flex>\n      </Flex>\n\n      {query && (\n        <Box sx={{ padding: 3, paddingTop: 0 }}>\n          <Highlighter searchWords={searchWords} textToHighlight={question.description} />\n        </Box>\n      )}\n    </Card>\n  );\n};\n"
  },
  {
    "path": "src/pages/Question/QuestionListing.tsx",
    "content": "import { Loader, Pagination } from 'oa-components';\nimport type { Question } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { useSearchParams } from 'react-router';\nimport { logger } from 'src/logger';\nimport { Card, Flex, Heading } from 'theme-ui';\nimport useDrafts from '../common/Drafts/useDraftsSupabase';\nimport { ITEMS_PER_PAGE } from './constants';\nimport { listing } from './labels';\nimport { QuestionListHeader } from './QuestionListHeader';\nimport { QuestionListItem } from './QuestionListItem';\nimport type { QuestionSortOption } from './QuestionSortOptions';\nimport { questionService } from './question.service';\n\nexport const QuestionListing = () => {\n  const [isFetching, setIsFetching] = useState<boolean>(true);\n  const [questions, setQuestions] = useState<Question[]>([]);\n  const { draftCount, isFetchingDrafts, drafts, showDrafts, handleShowDrafts } =\n    useDrafts<Question>({\n      getDraftCount: questionService.getDraftCount,\n      getDrafts: questionService.getDrafts,\n    });\n\n  const [total, setTotal] = useState<number>(0);\n\n  const [searchParams, setSearchParams] = useSearchParams();\n  const q = searchParams.get('q') || '';\n  const category = searchParams.get('category') || '';\n  const sort = searchParams.get('sort') as QuestionSortOption;\n  const pageNumber = Math.max(1, parseInt(searchParams.get('page') || '1') || 1);\n\n  useEffect(() => {\n    if (!sort) {\n      // ensure sort is set\n      const params = new URLSearchParams(searchParams.toString());\n\n      if (q) {\n        params.set('sort', 'MostRelevant');\n      } else {\n        params.set('sort', 'Newest');\n      }\n      setSearchParams(params, { replace: true });\n    } else {\n      // search only when sort is set (avoids duplicate requests)\n      const skip = (pageNumber - 1) * ITEMS_PER_PAGE;\n      fetchQuestions(skip);\n    }\n  }, [q, category, sort, pageNumber]);\n\n  const fetchQuestions = async (skip: number = 0) => {\n    setIsFetching(true);\n\n    try {\n      const result = await questionService.search(q, category, sort, skip);\n\n      if (result) {\n        setQuestions(result.items);\n        setTotal(result.total);\n      }\n    } catch (error) {\n      logger.error('error fetching questions', error);\n    }\n\n    setIsFetching(false);\n  };\n\n  const showLoadMore = !isFetching && questions && questions.length > 0 && questions.length < total;\n\n  const questionsList = showDrafts ? drafts : questions;\n\n  const updatePageNumber = (value: number) => {\n    const params = new URLSearchParams(searchParams.toString());\n    params.set('page', value.toString());\n    setSearchParams(params, { replace: true });\n  };\n\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: [2, 3] }}>\n      <QuestionListHeader\n        itemCount={isFetching ? undefined : total}\n        draftCount={isFetchingDrafts ? undefined : draftCount}\n        handleShowDrafts={handleShowDrafts}\n        showDrafts={showDrafts}\n      />\n\n      {questions?.length === 0 && !isFetching && (\n        <Heading as=\"h1\" sx={{ marginTop: 4 }}>\n          {listing.noQuestions}\n        </Heading>\n      )}\n\n      {questionsList && questionsList.length > 0 && (\n        <Card\n          as=\"ul\"\n          sx={{\n            listStyle: 'none',\n            padding: 0,\n            margin: 0,\n            marginBottom: 2,\n            gap: '2px',\n            ml: [-2, 0, 0],\n            mr: [-2, 0, 0],\n            borderLeft: [0, '2px solid', '2px solid'],\n            borderRight: [0, '2px solid', '2px solid'],\n            background: '#f4f6f7',\n            borderRadius: [0, 2, 2],\n            display: 'flex',\n            flexDirection: 'column',\n          }}\n        >\n          {questionsList.map((question, index) => (\n            <QuestionListItem key={index} question={question} query={q} />\n          ))}\n        </Card>\n      )}\n\n      {showLoadMore && (\n        <Flex sx={{ justifyContent: 'center' }}>\n          <Pagination\n            totalPages={Math.ceil(total / ITEMS_PER_PAGE)}\n            onPageChange={updatePageNumber}\n            page={pageNumber}\n          />\n        </Flex>\n      )}\n\n      {(isFetching || isFetchingDrafts) && <Loader />}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/Question/QuestionPage.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { createMemoryRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router';\nimport { faker } from '@faker-js/faker';\nimport { act, render, waitFor, within } from '@testing-library/react';\nimport { ThemeProvider } from '@theme-ui/core';\nimport { Provider } from 'mobx-react';\nimport { UserRole } from 'oa-shared';\nimport { FactoryQuestionItem } from 'src/test/factories/Question';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { theme } from 'oa-themes';\nimport { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { QuestionPage } from './QuestionPage';\n\nimport type { Question } from 'oa-shared';\n\nconst activeUser = FactoryUser({\n  roles: [UserRole.BETA_TESTER],\n});\n\nconst mockQuestionItem = FactoryQuestionItem({\n  slug: 'testSlug',\n});\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: () => ({\n    profile: FactoryUser(),\n  }),\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('src/stores/Subscription/subscription.store', () => ({\n  useSubscriptionStore: vi.fn(() => ({\n    isSubscribed: vi.fn(() => false),\n    isLoading: vi.fn(() => false),\n    checkAndCacheSubscription: vi.fn(),\n    subscribe: vi.fn(),\n    unsubscribe: vi.fn(),\n    toggleSubscription: vi.fn(),\n  })),\n  SubscriptionStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('src/stores/UsefulVote/usefulVote.store', () => ({\n  useUsefulVoteStore: vi.fn(() => ({\n    getVoteState: vi.fn(() => ({ hasVoted: false, usefulCount: 0, isLoading: false })),\n    hasVoted: vi.fn(() => false),\n    getUsefulCount: vi.fn(() => 0),\n    isLoading: vi.fn(() => false),\n    initializeVote: vi.fn(),\n    toggleVote: vi.fn(),\n    clearCache: vi.fn(),\n  })),\n  UsefulVoteStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('src/services/usefulService', () => {\n  return {\n    usefulService: {\n      hasVoted: () => new Response(null, { status: 200, statusText: 'ALLL good!' }),\n    },\n  };\n});\n\nvi.mock('src/services/commentService', () => {\n  return {\n    commentService: {\n      getComments: () => new Response(null, { status: 200, statusText: 'ALLL good!' }),\n      getCommentSourceId: () => new Response(null, { status: 200, statusText: 'ALLL good!' }),\n    },\n  };\n});\n\ndescribe('Questions', () => {\n  afterEach(() => {\n    // Clear all mocks after each test to ensure there's no leakage between tests\n    vi.clearAllMocks();\n  });\n\n  describe('Breadcrumbs', () => {\n    it('displays breadcrumbs with category', async () => {\n      // Arrange\n      mockQuestionItem.title = 'Do you prefer camping near a lake or in a forest?';\n      mockQuestionItem.category = {\n        createdAt: new Date(),\n        modifiedAt: null,\n        name: 'Preference',\n        id: faker.number.int(),\n        type: 'questions',\n      };\n\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = getWrapper(mockQuestionItem);\n      });\n\n      // Assert: Check the breadcrumb items and chevrons\n      await waitFor(() => {\n        const breadcrumbItems = wrapper.getAllByTestId('breadcrumbsItem');\n        expect(breadcrumbItems).toHaveLength(3);\n        expect(breadcrumbItems[0]).toHaveTextContent('Question');\n        expect(breadcrumbItems[1]).toHaveTextContent('Preference');\n        expect(breadcrumbItems[2]).toHaveTextContent('Do you prefer camping near a lake or in a forest?');\n\n        // Assert: Check that the first two breadcrumb items contain links\n        const firstLink = within(breadcrumbItems[0]).getByRole('link');\n        const secondLink = within(breadcrumbItems[1]).getByRole('link');\n        expect(firstLink).toBeInTheDocument();\n        expect(secondLink).toBeInTheDocument();\n\n        // Assert: Check for the correct number of chevrons\n        const chevrons = wrapper.getAllByTestId('breadcrumbsChevron');\n        expect(chevrons).toHaveLength(2);\n      });\n    });\n\n    it('displays breadcrumbs without category', async () => {\n      // Arrange\n      mockQuestionItem.title = 'Do you prefer camping near a lake or in a forest?';\n      mockQuestionItem.category = null;\n\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = getWrapper(mockQuestionItem);\n      });\n\n      // Assert: Check the breadcrumb items and chevrons\n      await waitFor(() => {\n        const breadcrumbItems = wrapper.getAllByTestId('breadcrumbsItem');\n        expect(breadcrumbItems).toHaveLength(2);\n        expect(breadcrumbItems[0]).toHaveTextContent('Question');\n        expect(breadcrumbItems[1]).toHaveTextContent('Do you prefer camping near a lake or in a forest?');\n\n        // Assert: Check that the first breadcrumb item contains a link\n        const firstLink = within(breadcrumbItems[0]).getByRole('link');\n        expect(firstLink).toBeInTheDocument();\n\n        // Assert: Check for the correct number of chevrons\n        const chevrons = wrapper.getAllByTestId('breadcrumbsChevron');\n        expect(chevrons).toHaveLength(1);\n      });\n    });\n  });\n});\n\nconst getWrapper = (question: Question) => {\n  const router = createMemoryRouter(\n    createRoutesFromElements(<Route path=\"/questions/:slug\" key={1} element={<QuestionPage question={question} />} />),\n    {\n      initialEntries: ['/questions/question'],\n    },\n  );\n\n  return render(\n    <Provider\n      userStore={{\n        user: activeUser,\n      }}\n    >\n      <ThemeProvider theme={theme}>\n        <RouterProvider router={router} />\n      </ThemeProvider>\n    </Provider>,\n  );\n};\n"
  },
  {
    "path": "src/pages/Question/QuestionPage.tsx",
    "content": "import { observer } from 'mobx-react';\nimport {\n  AuthorDisplay,\n  Category,\n  ContentStatistics,\n  DisplayDate,\n  ImageGallery,\n  LinkifyText,\n  TagList,\n  UsefulStatsButton,\n} from 'oa-components';\nimport type { Question } from 'oa-shared';\nimport { PremiumTier } from 'oa-shared';\nimport { useMemo, useState } from 'react';\nimport { Link } from 'react-router';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport PageHeader from 'src/common/PageHeader';\nimport { userHasPremiumTier } from 'src/common/PremiumTierWrapper';\nimport { Breadcrumbs } from 'src/pages/common/Breadcrumbs/Breadcrumbs';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { useUsefulVote } from 'src/stores/UsefulVote/useUsefulVote';\nimport { formatImagesForGallery } from 'src/utils/formatImageListForGallery';\nimport { buildStatisticsLabel, hasAdminRights } from 'src/utils/helpers';\nimport { createUsefulStatistic } from 'src/utils/statistics';\nimport { Box, Button, Card, Divider, Flex, Heading, Text } from 'theme-ui';\nimport { CommentSectionSupabase } from '../common/CommentsSupabase/CommentSectionSupabase';\nimport { DraftTag } from '../common/Drafts/DraftTag';\n\ninterface IProps {\n  question: Question;\n}\n\nexport const QuestionPage = observer(({ question }: IProps) => {\n  const { profile: activeUser } = useProfileStore();\n  const {\n    hasVoted,\n    usefulCount,\n    toggle: toggleVote,\n  } = useUsefulVote('questions', question.id, question.usefulCount);\n  const [subscribersCount, setSubscribersCount] = useState<number>(question.subscriberCount);\n\n  const isEditable = useMemo(() => {\n    return hasAdminRights(activeUser) || question.author?.username === activeUser?.username;\n  }, [activeUser, question.author]);\n\n  return (\n    <Flex\n      sx={{\n        alignSelf: 'center',\n        width: '100%',\n        maxWidth: '1000px',\n        flexDirection: 'column',\n      }}\n    >\n      <PageHeader\n        actions={\n          isEditable && (\n            <Link to={'/questions/' + question.slug + '/edit'}>\n              <Button type=\"button\" variant=\"primary\" data-cy=\"edit\">\n                Edit\n              </Button>\n            </Link>\n          )\n        }\n      >\n        <Breadcrumbs\n          steps={[\n            { text: 'Question', link: '/questions' },\n            ...(question.category\n              ? [\n                  {\n                    text: question.category.name,\n                    link: `/questions?category=${question.category.id}`,\n                  },\n                ]\n              : []),\n            { text: question.title },\n          ]}\n        />\n      </PageHeader>\n\n      <Card data-cy=\"question-body\" sx={{ position: 'relative' }} variant=\"responsive\">\n        <Flex sx={{ flexDirection: 'column', padding: [3, 4], gap: 4 }}>\n          <Heading as=\"h1\" data-cy=\"question-title\" data-testid=\"question-title\">\n            {question.title}\n          </Heading>\n\n          <Flex sx={{ flexDirection: 'row', alignItems: 'center', gap: 2 }}>\n            <AuthorDisplay author={question.author} />\n\n            <Text variant=\"auxiliary\">\n              <DisplayDate\n                createdAt={question.createdAt}\n                publishedAt={question.publishedAt}\n                modifiedAt={question.modifiedAt}\n                publishedAction=\"Asked\"\n              />\n            </Text>\n\n            {question.isDraft && <DraftTag />}\n\n            {question.category && <Category category={question.category} />}\n          </Flex>\n\n          <Text variant=\"paragraph\" data-cy=\"question-description\" sx={{ whiteSpace: 'pre-line' }}>\n            <LinkifyText>{question.description}</LinkifyText>\n          </Text>\n\n          {question.images && (\n            <ImageGallery\n              images={formatImagesForGallery(question.images) as any}\n              allowPortrait={true}\n            />\n          )}\n\n          {question.tags && (\n            <TagList data-cy=\"question-tags\" tags={question.tags.map((t) => ({ label: t.name }))} />\n          )}\n        </Flex>\n\n        <Divider sx={{ border: '1px solid black', margin: 0 }} />\n        <Flex\n          sx={{\n            alignItems: 'center',\n            flexDirection: 'row',\n            padding: [2, 3],\n            gap: 3,\n            flexWrap: 'wrap',\n            justifyContent: 'space-between',\n          }}\n        >\n          <Box>\n            <ClientOnly fallback={<></>}>\n              {() => (\n                <UsefulStatsButton\n                  hasUserVotedUseful={hasVoted}\n                  isLoggedIn={!!activeUser}\n                  onUsefulClick={toggleVote}\n                />\n              )}\n            </ClientOnly>\n          </Box>\n\n          <ContentStatistics\n            statistics={[\n              {\n                icon: 'show',\n                label: buildStatisticsLabel({\n                  stat: question.totalViews,\n                  statUnit: 'view',\n                  usePlural: true,\n                }),\n                stat: question.totalViews,\n              },\n              {\n                icon: 'thunderbolt-grey',\n                label: buildStatisticsLabel({\n                  stat: subscribersCount,\n                  statUnit: 'following',\n                  usePlural: false,\n                }),\n                stat: subscribersCount,\n              },\n              createUsefulStatistic(\n                'questions',\n                question.id,\n                usefulCount,\n                userHasPremiumTier(activeUser, PremiumTier.ONE),\n              ),\n              {\n                icon: 'comment-outline',\n                label: buildStatisticsLabel({\n                  stat: question.commentCount,\n                  statUnit: 'comment',\n                  usePlural: true,\n                }),\n                stat: question.commentCount,\n              },\n            ]}\n            alwaysShow\n          />\n        </Flex>\n      </Card>\n\n      <ClientOnly fallback={<></>}>\n        {() => (\n          <Card\n            data-cy=\"comments-section\"\n            variant=\"responsive\"\n            sx={{\n              background: 'softblue',\n              borderTop: 0,\n              padding: [3, 4],\n              marginTop: [0, 2, 4],\n            }}\n          >\n            <CommentSectionSupabase\n              authors={question.author?.id ? [question.author.id] : []}\n              setSubscribersCount={setSubscribersCount}\n              sourceId={question.id}\n              sourceType=\"questions\"\n            />\n          </Card>\n        )}\n      </ClientOnly>\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/Question/QuestionSortOptions.ts",
    "content": "export type QuestionSortOption =\n  | 'MostRelevant'\n  | 'Newest'\n  | 'LatestComments'\n  | 'Comments'\n  | 'LeastComments';\n\nconst BaseOptions = new Map<QuestionSortOption, string>();\nBaseOptions.set('Newest', 'Newest');\n// BaseOptions.set('LatestComments', 'Latest Comments')\nBaseOptions.set('Comments', 'Most Comments');\nBaseOptions.set('LeastComments', 'Least Comments');\n\nconst QueryParamOptions = new Map<QuestionSortOption, string>(BaseOptions);\nQueryParamOptions.set('MostRelevant', 'Most Relevant');\n\nconst toArray = (hasQueryParam: boolean) => {\n  const options = hasQueryParam ? QueryParamOptions : BaseOptions;\n  return Array.from(options, ([value, label]) => ({\n    label: label,\n    value: value,\n  }));\n};\n\nexport const QuestionSortOptions = {\n  get: (key: QuestionSortOption) => QueryParamOptions.get(key) ?? '',\n  toArray,\n};\n"
  },
  {
    "path": "src/pages/Question/constants.ts",
    "content": "export const QUESTION_MIN_TITLE_LENGTH = 10;\nexport const QUESTION_MAX_TITLE_LENGTH = 60;\nexport const QUESTION_MAX_DESCRIPTION_LENGTH = 1000;\nexport const QUESTION_MAX_IMAGES = 4;\nexport const ITEMS_PER_PAGE = 20;\n"
  },
  {
    "path": "src/pages/Question/labels.ts",
    "content": "import type { ILabels } from 'src/common/Form/types';\n\nexport const buttons = {\n  create: 'Publish',\n  draft: { create: 'Save as draft', update: 'Save to draft' },\n  edit: 'Update',\n};\n\nexport const headings = {\n  create: 'Ask your question to the community',\n  edit: 'Edit your question to the community',\n  list: 'Ask your questions and help others out',\n};\n\nexport const fields: ILabels = {\n  category: {\n    placeholder: 'Start typing to find the perfect category...',\n    title: 'Which category fits your question?',\n  },\n  description: {\n    placeholder: 'What information will help the community understand what you need help with?',\n    title: 'Description',\n  },\n  tags: {\n    title: 'Select tags',\n  },\n  title: {\n    title: 'The Question',\n    placeholder: 'So what do you need to know?',\n  },\n  images: {\n    title: 'Upload image(s) for this question',\n  },\n};\n\nexport const listing = {\n  create: 'Ask a question',\n  filterCategory: 'Filter by category',\n  incompleteProfile: 'Complete your profile to ask your question',\n  loadMore: 'Load More',\n  loggedOut: 'Gotta log in please for that sweet sweet question asking...',\n  noQuestions: 'No questions have been asked yet',\n  search: 'Search for questions',\n  sort: 'Sort by',\n  totalComments: 'Total comments',\n  usefulness: 'How useful it is',\n};\n"
  },
  {
    "path": "src/pages/Question/question.service.ts",
    "content": "import type { Question } from 'oa-shared';\nimport { logger } from 'src/logger';\nimport type { QuestionSortOption } from './QuestionSortOptions';\n\nexport enum QuestionSearchParams {\n  category = 'category',\n  q = 'q',\n  sort = 'sort',\n}\n\nconst getDraftCount = async () => {\n  try {\n    const response = await fetch('/api/questions/drafts/count');\n    const { total } = (await response.json()) as { total: number };\n\n    return total;\n  } catch (error) {\n    logger.error('Failed to fetch draft count', { error });\n    return 0;\n  }\n};\n\nconst getDrafts = async () => {\n  try {\n    const response = await fetch('/api/questions/drafts');\n    const { items } = (await response.json()) as { items: Question[] };\n\n    return items;\n  } catch (error) {\n    logger.error('Failed to fetch draft questions', { error });\n    return [];\n  }\n};\n\nconst search = async (q: string, category: string, sort: QuestionSortOption, skip: number = 0) => {\n  try {\n    const url = new URL('/api/questions', window.location.origin);\n    url.searchParams.set('q', q);\n    url.searchParams.set('category', category);\n    url.searchParams.set('sort', sort);\n    if (skip > 0) {\n      url.searchParams.set('skip', skip.toString());\n    }\n    const response = await fetch(url);\n\n    const { items, total } = (await response.json()) as {\n      items: Question[];\n      total: number;\n    };\n    return { items, total };\n  } catch (error) {\n    logger.error('Failed to fetch questions', { error });\n    return { items: [], total: 0 };\n  }\n};\n\nexport const questionService = {\n  getDraftCount,\n  getDrafts,\n  search,\n};\n"
  },
  {
    "path": "src/pages/Research/Content/Common/DeleteResearchButton.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Button, ConfirmModal } from 'oa-components';\nimport { ResearchItem } from 'oa-shared';\nimport { useMemo, useState } from 'react';\nimport { useNavigate } from 'react-router';\nimport { trackEvent } from 'src/common/Analytics';\nimport { useToast } from 'src/common/Toast';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { hasAdminRights } from 'src/utils/helpers';\nimport { researchService } from '../../research.service';\n\ntype DeleteResearchButtonProps = {\n  research: ResearchItem;\n};\n\nconst DeleteResearchButton = observer(({ research }: DeleteResearchButtonProps) => {\n  const { profile } = useProfileStore();\n  const toast = useToast();\n  const navigate = useNavigate();\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  const isDeletable = useMemo(() => {\n    return !!profile && (hasAdminRights(profile) || research.author?.username === profile.username);\n  }, [profile, research.author]);\n\n  const handleDelete = async (research: ResearchItem) => {\n    setIsDeleting(true);\n    const promise = researchService.deleteResearch(research.id);\n\n    toast.promise(promise, {\n      loading: 'Deleting research...',\n      success: () => {\n        trackEvent({\n          category: 'research',\n          action: 'deleted',\n          label: research.title,\n        });\n        navigate('/research');\n        setIsDeleting(false);\n        return {\n          message: `Research deleted!`,\n        };\n      },\n      error: (error) => {\n        console.error(error);\n        setIsDeleting(false);\n        return `Error: ${error.message}`;\n      },\n    });\n  };\n\n  if (!isDeletable) {\n    return null;\n  }\n\n  return (\n    <>\n      <Button\n        type=\"button\"\n        data-cy=\"Research: delete button\"\n        variant=\"destructive\"\n        disabled={research?.deleted || isDeleting}\n        sx={{ justifyContent: 'center' }}\n        onClick={() => setShowDeleteModal(true)}\n      >\n        Delete\n      </Button>\n\n      <ConfirmModal\n        key={research?.id}\n        isOpen={showDeleteModal}\n        message=\"Are you sure you want to delete this Research?\"\n        confirmButtonText=\"Delete\"\n        handleCancel={() => setShowDeleteModal(false)}\n        handleConfirm={() => handleDelete && handleDelete(research)}\n        confirmVariant=\"destructive\"\n      />\n    </>\n  );\n});\n\nexport default DeleteResearchButton;\n"
  },
  {
    "path": "src/pages/Research/Content/Common/FormFields/ResearchCollaboratorsField.tsx",
    "content": "import { Field } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { UserNameSelect } from 'src/pages/common/UserNameSelect/UserNameSelect';\nimport { researchForm } from 'src/pages/Research/labels';\n\nexport const ResearchCollaboratorsField = () => {\n  const name = 'collaborators';\n  const { placeholder, title } = researchForm.collaborators;\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title}>\n      <Field name={name} component={UserNameSelect} placeholder={placeholder} defaultOptions={[]} />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Research/Content/Common/FormFields/ResearchDescriptionField.tsx",
    "content": "import { FieldTextarea } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { RESEARCH_MAX_LENGTH } from 'src/pages/Research/constants';\nimport { researchForm } from 'src/pages/Research/labels';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { draftValidationWrapper, required } from 'src/utils/validators';\n\nexport const ResearchDescriptionField = () => {\n  const name = 'description';\n  const { placeholder, title } = researchForm.description;\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title} required>\n      <Field\n        id={name}\n        name={name}\n        data-cy=\"intro-description\"\n        validate={(value, allValues) => draftValidationWrapper(value, allValues, required)}\n        isEqual={COMPARISONS.textInput}\n        component={FieldTextarea}\n        rows={10}\n        maxLength={RESEARCH_MAX_LENGTH}\n        showCharacterCount\n        placeholder={placeholder}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Research/Content/Common/FormFields/ResearchTitleField.tsx",
    "content": "import { FieldInput } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { RESEARCH_TITLE_MAX_LENGTH, RESEARCH_TITLE_MIN_LENGTH } from 'src/pages/Research/constants';\nimport { researchForm } from 'src/pages/Research/labels';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { composeValidators, minValue, required } from 'src/utils/validators';\n\nexport const ResearchTitleField = () => {\n  const name = 'title';\n  const { placeholder, title } = researchForm.title;\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title} required>\n      <Field\n        id={name}\n        name={name}\n        data-cy=\"intro-title\"\n        validateFields={[]}\n        validate={composeValidators(required, minValue(RESEARCH_TITLE_MIN_LENGTH))}\n        isEqual={COMPARISONS.textInput}\n        component={FieldInput}\n        maxLength={RESEARCH_TITLE_MAX_LENGTH}\n        minLength={RESEARCH_TITLE_MIN_LENGTH}\n        showCharacterCount\n        placeholder={placeholder}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Research/Content/Common/ResearchCategorySelect.tsx",
    "content": "import type { SelectValue } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { Field } from 'react-final-form';\nimport { CategoriesSelectV2 } from 'src/pages/common/Category/CategoriesSelectV2';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { categoryService } from 'src/services/categoryService';\nimport { researchForm } from '../../labels';\n\nconst ResearchFieldCategory = () => {\n  const [options, setOptions] = useState<SelectValue[]>([]);\n  const name = 'category';\n\n  useEffect(() => {\n    const getCategories = async () => {\n      const categories = await categoryService.getCategories('research');\n      setOptions(\n        categories.map(({ id, name }) => ({\n          value: id.toString(),\n          label: name,\n        })),\n      );\n    };\n\n    getCategories();\n  }, []);\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={researchForm.categories.title}>\n      <Field\n        name={name}\n        render={({ input }) => (\n          <CategoriesSelectV2\n            isForm={true}\n            onChange={(category) => input.onChange(category)}\n            value={input.value}\n            placeholder={researchForm.categories.placeholder || ''}\n            categories={options}\n          />\n        )}\n      />\n    </FormFieldWrapper>\n  );\n};\n\nexport default ResearchFieldCategory;\n"
  },
  {
    "path": "src/pages/Research/Content/Common/ResearchForm.tsx",
    "content": "import arrayMutators from 'final-form-arrays';\nimport { FormApi } from 'node_modules/final-form/dist';\nimport { Button, ResearchEditorOverview } from 'oa-components';\nimport {\n  type ResearchFormData,\n  type ResearchItem,\n  type ResearchStatus,\n  ResearchStatusRecord,\n} from 'oa-shared';\nimport { useMemo, useState } from 'react';\nimport { Form } from 'react-final-form';\nimport { FormWrapper } from 'src/common/Form/FormWrapper';\nimport { useToast } from 'src/common/Toast';\nimport { TagsField } from 'src/pages/common/FormFields';\nimport { ImageField } from 'src/pages/common/FormFields/ImageField';\nimport { errorSet } from 'src/pages/Library/Content/utils/transformLibraryErrors';\nimport { ResearchPostingGuidelines } from 'src/pages/Research/Content/Common';\nimport { buttons, headings, researchForm } from '../../labels';\nimport { researchService } from '../../research.service';\nimport DeleteResearchButton from './DeleteResearchButton';\nimport { ResearchCollaboratorsField } from './FormFields/ResearchCollaboratorsField';\nimport { ResearchDescriptionField } from './FormFields/ResearchDescriptionField';\nimport { ResearchTitleField } from './FormFields/ResearchTitleField';\nimport ResearchFieldCategory from './ResearchCategorySelect';\n\ninterface IProps {\n  id: number | null;\n  formData: ResearchFormData | null;\n  research: ResearchItem | null;\n}\n\nconst ResearchForm = ({ id, formData, research }: IProps) => {\n  const toast = useToast();\n  const [status, setStatus] = useState<ResearchStatus | undefined>(research?.status || undefined);\n  const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);\n  const [isSubmittingDraft, setIsSubmittingDraft] = useState(false);\n\n  const initialValues = useMemo<ResearchFormData>(\n    () =>\n      ({\n        title: formData?.title || '',\n        description: formData?.description || '',\n        category: formData?.category || null,\n        collaborators: formData?.collaborators || [],\n        tags: formData?.tags || [],\n        coverImage: formData?.coverImage || null,\n      }) satisfies ResearchFormData,\n    [],\n  );\n\n  const updateStatus = async (status: ResearchStatus) => {\n    setIsUpdatingStatus(true);\n    const promise = researchService.updateResearchStatus(id!, status);\n\n    toast.promise(promise, {\n      loading: 'Updating research status...',\n      success: () => {\n        setStatus(status);\n        return {\n          message: `Status changed to \"${ResearchStatusRecord[status]}\"`,\n          actionLink: {\n            href: `/research/${research!.slug}`,\n            label: 'View research',\n          },\n        };\n      },\n      error: (error) => {\n        console.error(error);\n        return `Error: ${error.message}`;\n      },\n    });\n\n    try {\n      await promise;\n    } finally {\n      setTimeout(() => {\n        // to avoid spam clicking\n        setIsUpdatingStatus(false);\n      }, 1000);\n    }\n  };\n\n  const onSubmit = async (\n    form: FormApi<ResearchFormData, Partial<ResearchFormData>>,\n    values: ResearchFormData,\n    isDraft = false,\n  ) => {\n    const promise = researchService.upsert(id || null, values, isDraft);\n\n    toast.promise(promise, {\n      loading: isDraft ? 'Saving draft...' : 'Publishing research...',\n      success: (data) => {\n        form.reset(values);\n        return {\n          message: isDraft ? 'Draft saved!' : 'Research published!',\n          actionLink: {\n            href: `/research/${data.research.slug}`,\n            label: isDraft ? 'View draft' : 'View research',\n          },\n        };\n      },\n      error: (error) => {\n        console.error(error);\n        return `Error: ${error.message}`;\n      },\n      duration: 10000,\n    });\n\n    await promise;\n\n    await new Promise((resolve) => setTimeout(resolve, 1000)); // to avoid spam clicking\n  };\n\n  const heading = id ? headings.overview.edit : headings.overview.create;\n\n  return (\n    <Form<ResearchFormData>\n      onSubmit={async (values, form) => await onSubmit(form, values)}\n      initialValues={initialValues}\n      mutators={{\n        ...arrayMutators,\n      }}\n      render={({\n        errors,\n        form,\n        handleSubmit,\n        hasValidationErrors,\n        submitFailed,\n        submitting,\n        values,\n      }) => {\n        const errorsClientSide = [errorSet(errors, researchForm)];\n\n        const handleSubmitDraft = async (e: React.MouseEvent) => {\n          e.preventDefault();\n          setIsSubmittingDraft(true);\n          try {\n            await onSubmit(form, values, true);\n            form.reset(values);\n          } finally {\n            setIsSubmittingDraft(false);\n          }\n        };\n\n        const sidebar = (\n          <>\n            {id && (\n              <Button\n                data-cy=\"draft\"\n                onClick={() => updateStatus(status === 'complete' ? 'in-progress' : 'complete')}\n                variant={status === 'complete' ? 'info' : 'success'}\n                type=\"submit\"\n                disabled={!id || isUpdatingStatus || submitting || isSubmittingDraft}\n                sx={{\n                  width: '100%',\n                  display: 'block',\n                }}\n              >\n                {status === 'complete' ? buttons.markInProgress : buttons.markCompleted}\n              </Button>\n            )}\n\n            {research && <DeleteResearchButton research={research} />}\n\n            {research?.updates && (\n              <ResearchEditorOverview\n                updates={research?.updates\n                  .filter((u) => !u.deleted)\n                  .map((u) => ({\n                    isActive: false,\n                    isDraft: u.isDraft,\n                    title: u.title,\n                    id: u.id,\n                  }))}\n                researchSlug={research?.slug}\n                showCreateUpdateButton={true}\n              />\n            )}\n          </>\n        );\n\n        return (\n          <FormWrapper\n            buttonLabel={buttons.publish}\n            errorsClientSide={errorsClientSide}\n            guidelines={<ResearchPostingGuidelines />}\n            handleSubmit={handleSubmit}\n            handleSubmitDraft={handleSubmitDraft}\n            hasValidationErrors={hasValidationErrors}\n            heading={heading}\n            sidebar={sidebar}\n            submitFailed={submitFailed}\n            submitting={submitting || isSubmittingDraft || isUpdatingStatus}\n            hideSubmittingMessage={true}\n          >\n            <ResearchTitleField />\n            <ResearchDescriptionField />\n            <ImageField title=\"Cover Image\" contentType=\"research\" contentId={id} />\n            <ResearchFieldCategory />\n            <TagsField title={researchForm.tags.title} />\n            <ResearchCollaboratorsField />\n          </FormWrapper>\n        );\n      }}\n    />\n  );\n};\n\nexport default ResearchForm;\n"
  },
  {
    "path": "src/pages/Research/Content/Common/ResearchPostingGuidelines.tsx",
    "content": "import { ExternalLink, Guidelines } from 'oa-components';\n\nexport const ResearchPostingGuidelines = () => (\n  <Guidelines\n    title=\"How does it work?\"\n    steps={[\n      <>\n        Choose a topic you want to research{' '}\n        <span role=\"img\" aria-label=\"raised-hand\">\n          🙌\n        </span>\n      </>,\n      <>\n        Read{' '}\n        <ExternalLink sx={{ color: 'blue' }} href=\"/academy/guides/research\">\n          our guidelines{' '}\n          <span role=\"img\" aria-label=\"nerd-face\">\n            🤓\n          </span>\n        </ExternalLink>\n      </>,\n      <>\n        Write your introduction{' '}\n        <span role=\"img\" aria-label=\"archive-box\">\n          🗄️\n        </span>\n      </>,\n      <>\n        Come back when you made progress{' '}\n        <span role=\"img\" aria-label=\"writing-hand\">\n          ✍️\n        </span>\n      </>,\n      <>Keep doing this</>,\n      <>\n        Be proud{' '}\n        <span role=\"img\" aria-label=\"simple-smile\">\n          🙂\n        </span>\n      </>,\n    ]}\n  />\n);\n"
  },
  {
    "path": "src/pages/Research/Content/Common/ResearchUpdateForm.tsx",
    "content": "import { FormApi } from 'node_modules/final-form/dist';\nimport { Button, ConfirmModal, ResearchEditorOverview } from 'oa-components';\nimport type { ResearchItem, ResearchUpdate, ResearchUpdateFormData } from 'oa-shared';\nimport { useMemo, useState } from 'react';\nimport { Form } from 'react-final-form';\nimport { FormWrapper } from 'src/common/Form/FormWrapper';\nimport { useToast } from 'src/common/Toast/useToast';\nimport { errorSet } from 'src/pages/Library/Content/utils/transformLibraryErrors';\nimport { FilesFields } from '../../../common/FormFields/FilesFields';\nimport { buttons, headings, updateForm } from '../../labels';\nimport { researchService } from '../../research.service';\nimport { DescriptionField } from '../CreateResearch/Form/DescriptionField';\nimport { ResearchImagesField } from '../CreateResearch/Form/ResearchImagesField';\nimport { TitleField } from '../CreateResearch/Form/TitleField';\nimport VideoUrlField from '../CreateResearch/Form/VideoUrlField';\n\ninterface IProps {\n  id: number | null;\n  formData: ResearchUpdateFormData | null;\n  research: ResearchItem;\n}\n\nexport const ResearchUpdateForm = ({ id, formData, research }: IProps) => {\n  const toast = useToast();\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n\n  const initialValues = useMemo<ResearchUpdateFormData>(\n    () =>\n      ({\n        title: formData?.title || '',\n        description: formData?.description || '',\n        images: formData?.images || null,\n        files: formData?.files || null,\n        fileLink: formData?.fileLink || null,\n        videoUrl: formData?.videoUrl || '',\n      }) satisfies ResearchUpdateFormData,\n    [],\n  );\n\n  const onSubmit = async (\n    form: FormApi<ResearchUpdateFormData, Partial<ResearchUpdateFormData>>,\n    formData: ResearchUpdateFormData,\n    isDraft = false,\n  ) => {\n    const promise = researchService.upsertUpdate(research.id, id, formData, isDraft);\n\n    toast.promise(promise, {\n      loading: isDraft ? 'Saving draft...' : 'Publishing research update...',\n      success: (data) => {\n        form.reset(formData);\n\n        return {\n          message: isDraft ? 'Draft saved!' : 'Research update published!',\n          actionLink: {\n            href: `/research/${research.slug}#update_${data.researchUpdate.id}`,\n            label: isDraft ? 'View draft' : 'View research update',\n          },\n        };\n      },\n      error: (error) => {\n        console.error(error);\n        return `Error: ${error.message}`;\n      },\n      duration: 10000,\n    });\n\n    await promise;\n\n    await new Promise((resolve) => setTimeout(resolve, 1000)); // to avoid spam clicking\n  };\n\n  const handleDelete = async () => {\n    if (!id) {\n      return;\n    }\n    setShowDeleteModal(false);\n    await researchService.deleteUpdate(research.id, id);\n    window.location.assign('/research/' + research.slug);\n  };\n\n  const isEdit = !!id;\n  const heading = isEdit ? headings.update.edit : headings.update.create;\n\n  return (\n    <>\n      <Form<ResearchUpdateFormData>\n        onSubmit={async (values, form) => await onSubmit(form, values, false)}\n        initialValues={initialValues}\n        render={({\n          form,\n          handleSubmit,\n          hasValidationErrors,\n          errors,\n          submitFailed,\n          submitting,\n          values,\n        }) => {\n          const errorsClientSide = [errorSet(errors, updateForm)];\n\n          const handleSubmitDraft = async () => {\n            await onSubmit(form, values, true);\n            form.reset(values);\n          };\n\n          const sidebar = (\n            <>\n              {isEdit ? (\n                <Button\n                  data-cy=\"delete\"\n                  onClick={(evt) => {\n                    setShowDeleteModal(true);\n                    evt.preventDefault();\n                  }}\n                  variant=\"destructive\"\n                  type=\"submit\"\n                  disabled={submitting}\n                  sx={{ alignSelf: 'stretch', justifyContent: 'center' }}\n                >\n                  {buttons.deletion.text}\n                </Button>\n              ) : null}\n\n              {research && (\n                <ResearchEditorOverview\n                  updates={getResearchUpdates(research.updates || [], !isEdit, values.title)}\n                  researchSlug={research?.slug}\n                  showCreateUpdateButton={isEdit}\n                  showBackToResearchButton={true}\n                />\n              )}\n            </>\n          );\n\n          return (\n            <FormWrapper\n              buttonLabel={buttons.publish}\n              errorsClientSide={errorsClientSide}\n              handleSubmit={handleSubmit}\n              handleSubmitDraft={handleSubmitDraft}\n              hasValidationErrors={hasValidationErrors}\n              heading={heading}\n              sidebar={sidebar}\n              submitFailed={submitFailed}\n              submitting={submitting}\n              hideSubmittingMessage={true}\n            >\n              <TitleField />\n              <DescriptionField />\n              <ResearchImagesField contentId={research.id} />\n              <VideoUrlField />\n              <FilesFields contentType=\"research\" contentId={research.id} />\n            </FormWrapper>\n          );\n        }}\n      />\n      <ConfirmModal\n        isOpen={showDeleteModal}\n        message={buttons.deletion.message}\n        confirmButtonText={buttons.deletion.confirm}\n        handleCancel={() => setShowDeleteModal(false)}\n        handleConfirm={handleDelete}\n        confirmVariant=\"destructive\"\n      />\n    </>\n  );\n};\n\nconst getResearchUpdates = (\n  updates: ResearchUpdate[],\n  isCreating: boolean,\n  researchTitle: string,\n): any[] =>\n  [\n    ...updates\n      .filter((u) => !u.deleted)\n      .map((u) => ({\n        title: u.title,\n        isDraft: u.isDraft,\n        slug: u.id,\n        id: u.id,\n      })),\n    isCreating\n      ? {\n          title: researchTitle,\n          isDraft: true,\n          slug: null,\n        }\n      : null,\n  ].filter(Boolean);\n"
  },
  {
    "path": "src/pages/Research/Content/Common/index.ts",
    "content": "export { ResearchPostingGuidelines } from './ResearchPostingGuidelines';\nexport { ResearchUpdateForm } from './ResearchUpdateForm';\n"
  },
  {
    "path": "src/pages/Research/Content/CreateResearch/Form/DescriptionField.tsx",
    "content": "import { FieldTextarea } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { draftValidationWrapper, required } from 'src/utils/validators';\nimport { Flex, Label } from 'theme-ui';\n\nimport { RESEARCH_MAX_LENGTH } from '../../../constants';\nimport { updateForm as updateLabels } from '../../../labels';\n\nexport const DescriptionField = () => {\n  const { title, placeholder } = updateLabels.description;\n\n  return (\n    <Flex sx={{ flexDirection: 'column' }} mb={3}>\n      <Label htmlFor=\"description\" sx={{ mb: 2 }}>\n        {title}\n      </Label>\n      <Field\n        id=\"description\"\n        name=\"description\"\n        data-cy=\"intro-description\"\n        validate={(value, allValues) => draftValidationWrapper(value, allValues, required)}\n        validateFields={[]}\n        isEqual={COMPARISONS.textInput}\n        component={FieldTextarea}\n        rows={10}\n        maxLength={RESEARCH_MAX_LENGTH}\n        showCharacterCount\n        placeholder={placeholder}\n      />\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/Research/Content/CreateResearch/Form/ResearchImagesField.tsx",
    "content": "import { ImageInputV2 } from 'oa-components';\nimport { DBMedia, MediaWithPublicUrl, ResearchUpdateFormData } from 'oa-shared';\nimport { commonStyles } from 'oa-themes';\nimport { useState } from 'react';\nimport { useForm, useFormState } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { ImageInputFieldWrapper } from 'src/pages/common/FormFields/ImageInputFieldWrapper';\nimport { storageService } from 'src/services/storageService';\nimport { Flex, Spinner, Text } from 'theme-ui';\n\ninterface IProps {\n  contentId: number;\n}\n\nexport const ResearchImagesField = (props: IProps) => {\n  const form = useForm();\n  const state = useFormState<ResearchUpdateFormData>();\n  const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);\n  const [uploadError, setUploadError] = useState<string | null>(null);\n\n  const handleImageSelect = async (file: File | undefined, index: number) => {\n    if (!file) {\n      return;\n    }\n\n    setUploadingIndex(index);\n    setUploadError(null);\n\n    try {\n      const uploadedImage = await storageService.imageUpload(props.contentId, 'research', file);\n\n      // Set the image at the specific index\n      const currentImages = state.values.images || [];\n      const updatedImages = [...currentImages];\n      updatedImages[index] = uploadedImage;\n      form.change('images', updatedImages);\n    } catch (error) {\n      setUploadError(\n        error instanceof Error ? error.message : 'Failed to upload image. Please try again.',\n      );\n    } finally {\n      setUploadingIndex(null);\n    }\n  };\n\n  const handleImageError = (error: string) => {\n    setUploadError(error);\n  };\n\n  const currentImages: DBMedia[] = state.values.images || [];\n\n  const removeImage = (index: number) => {\n    form.change(\n      'images',\n      state.values.images?.slice(0, index).concat(state.values.images?.slice(index + 1)),\n    );\n  };\n\n  return (\n    <FormFieldWrapper htmlFor=\"images\" text=\"Images\">\n      {uploadError && (\n        <Text sx={{ color: 'error', fontSize: 1, mb: 2, width: '100%' }}>{uploadError}</Text>\n      )}\n\n      <Flex sx={{ gap: 2, flexWrap: 'wrap' }}>\n        {state.values.images?.map((image: MediaWithPublicUrl, i: number) => (\n          <ImageInputFieldWrapper key={`image-upload-${i}`} data-cy={`image-upload-${i}`}>\n            <ImageInputV2\n              image={image}\n              onFilesChange={(file) => {\n                if (!file) {\n                  removeImage(i);\n                }\n              }}\n              onError={setUploadError}\n            />\n          </ImageInputFieldWrapper>\n        ))}\n\n        {currentImages.length < 10 && (\n          <ImageInputFieldWrapper data-cy=\"new-image-upload\">\n            {uploadingIndex === currentImages.length ? (\n              <Spinner size={20} sx={{ color: commonStyles.colors.darkGrey }} />\n            ) : (\n              <ImageInputV2\n                onFilesChange={(file) => handleImageSelect(file, currentImages.length)}\n                onError={handleImageError}\n              />\n            )}\n          </ImageInputFieldWrapper>\n        )}\n      </Flex>\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/Research/Content/CreateResearch/Form/TitleField.tsx",
    "content": "import { FieldInput } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { composeValidators, minValue, required } from 'src/utils/validators';\nimport { Flex, Label } from 'theme-ui';\n\nimport { RESEARCH_TITLE_MAX_LENGTH, RESEARCH_TITLE_MIN_LENGTH } from '../../../constants';\nimport { updateForm as updateLabels } from '../../../labels';\n\nexport const TitleField = () => {\n  const { title, placeholder } = updateLabels.title;\n\n  return (\n    <Flex sx={{ flexDirection: 'column' }} mb={3}>\n      <Label htmlFor=\"title\" sx={{ mb: 2 }}>\n        {title}\n      </Label>\n      <Field\n        id=\"title\"\n        name=\"title\"\n        data-cy=\"intro-title\"\n        validateFields={[]}\n        validate={composeValidators(required, minValue(RESEARCH_TITLE_MIN_LENGTH))}\n        isEqual={COMPARISONS.textInput}\n        component={FieldInput}\n        maxLength={RESEARCH_TITLE_MAX_LENGTH}\n        minLength={RESEARCH_TITLE_MIN_LENGTH}\n        showCharacterCount\n        placeholder={placeholder}\n      />\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/Research/Content/CreateResearch/Form/VideoUrlField.tsx",
    "content": "import { FieldInput } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { Flex, Label } from 'theme-ui';\n\nimport { errors as errorsLabel, updateForm as updateLabels } from '../../../labels';\n\nconst VideoUrlField = () => {\n  const { title, placeholder } = updateLabels.videoUrl;\n\n  return (\n    <Flex sx={{ flexDirection: 'column' }} mb={3}>\n      <Label htmlFor={`videoUrl`} sx={{ mb: 2 }}>\n        {title}\n      </Label>\n      <Field\n        name=\"videoUrl\"\n        data-cy=\"videoUrl\"\n        component={FieldInput}\n        placeholder={placeholder}\n        validate={(url, values) => validateMedia(url, values)}\n        validateFields={[]}\n        isEqual={COMPARISONS.textInput}\n      />\n    </Flex>\n  );\n};\n\nconst validateMedia = (videoUrl: string, values: any) => {\n  const images = values.images;\n  const existingImages = values.existingImages;\n\n  if (videoUrl) {\n    if ((images && images[0]) || (existingImages && existingImages[0])) {\n      return errorsLabel.videoUrl.both;\n    }\n    const youtubeRegex = new RegExp(/(youtu\\.be\\/|youtube\\.com\\/(watch\\?v=|embed\\/|v\\/))/gi);\n    const urlValid = youtubeRegex.test(videoUrl);\n    return urlValid ? null : errorsLabel.videoUrl.invalidUrl;\n  }\n  return (images && images[0]) || (existingImages && existingImages[0])\n    ? null\n    : errorsLabel.videoUrl.empty;\n};\n\nexport default VideoUrlField;\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchArticle.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\nimport { createMemoryRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router';\nimport { faker } from '@faker-js/faker';\nimport { act, render, waitFor, within } from '@testing-library/react';\nimport { ThemeProvider } from '@theme-ui/core';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\nimport { FactoryResearchItem, FactoryResearchItemUpdate } from 'src/test/factories/ResearchItem';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { theme } from 'oa-themes';\nimport { describe, expect, it, vi } from 'vitest';\nimport { ResearchArticlePage } from './ResearchArticlePage';\nimport type { Author, ResearchItem } from 'oa-shared';\n\n\nconst mockUser = FactoryUser({ country: 'AF' });\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: vi.fn(() => ({\n    profile: mockUser,\n  })),\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('src/stores/Subscription/subscription.store', () => ({\n  useSubscriptionStore: vi.fn(() => ({\n    isSubscribed: vi.fn(() => false),\n    isLoading: vi.fn(() => false),\n    checkAndCacheSubscription: vi.fn(),\n    subscribe: vi.fn(),\n    unsubscribe: vi.fn(),\n    toggleSubscription: vi.fn(),\n  })),\n  SubscriptionStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('src/stores/UsefulVote/usefulVote.store', () => ({\n  useUsefulVoteStore: vi.fn(() => ({\n    getVoteState: vi.fn(() => ({ hasVoted: false, usefulCount: 0, isLoading: false })),\n    hasVoted: vi.fn(() => false),\n    getUsefulCount: vi.fn(() => 0),\n    isLoading: vi.fn(() => false),\n    initializeVote: vi.fn(),\n    toggleVote: vi.fn(),\n    clearCache: vi.fn(),\n  })),\n  UsefulVoteStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('src/stores/Research/research.store');\n\ndescribe('Research Article', () => {\n  it('does not display contributors when undefined', async () => {\n    // Act\n    let wrapper;\n    await act(async () => {\n      wrapper = getWrapper(\n        FactoryResearchItem({\n          collaborators: [],\n        }),\n      );\n    });\n\n    // Assert\n    expect(() => {\n      wrapper.getAllByTestId('ArticleCallToAction: collaborators');\n    }).toThrow();\n  });\n\n  it('displays collaborators', async () => {\n    // Act\n    let wrapper;\n    act(() => {\n      wrapper = getWrapper(\n        FactoryResearchItem({\n          collaborators: [\n            { username: 'example-username', country: 'nl' } as Author,\n            { username: 'another-example-username', country: 'nl' } as Author,\n          ],\n        }),\n      );\n    });\n\n    // Assert\n    await waitFor(() => {\n      expect(wrapper.getAllByText('With contributions from')).toHaveLength(1);\n      expect(wrapper.getAllByText('example-username')).toHaveLength(2);\n      expect(wrapper.getAllByText('another-example-username')).toHaveLength(2);\n    });\n  });\n\n  describe('Research Update', () => {\n    it('displays contributors', async () => {\n      // wait for Promise to resolve and state to update\n      let wrapper;\n      act(() => {\n        wrapper = getWrapper(\n          FactoryResearchItem({\n            collaborators: [\n              { username: 'example-username' } as Author,\n              { username: 'another-example-username' } as Author,\n              { username: 'third-example-username' } as Author,\n            ],\n            updates: [\n              FactoryResearchItemUpdate({\n                title: 'Research Update #1',\n                isDraft: false,\n                deleted: false,\n                author: { username: 'another-example-username' } as Author,\n              }),\n              FactoryResearchItemUpdate({\n                title: 'Research Update #3',\n                isDraft: false,\n                deleted: false,\n              }),\n            ],\n            author: { username: 'example-username' } as Author,\n          }),\n        );\n      });\n\n      // Assert\n      await waitFor(\n        () => {\n          expect(wrapper.getAllByText('With contributions from')).toHaveLength(1);\n          expect(wrapper.getAllByText('example-username')).toHaveLength(4);\n          expect(wrapper.getAllByText('another-example-username')).toHaveLength(3);\n          expect(wrapper.getAllByText('third-example-username')).toHaveLength(2);\n          expect(wrapper.queryByText('fourth-example-username')).toBeNull();\n          expect(wrapper.getAllByTestId('collaborator/creator')).toHaveLength(1);\n        },\n        {\n          timeout: 10000,\n        },\n      );\n    });\n\n    it('does show both created and edit timestamp, when different', async () => {\n      const createdAt = faker.date.past({ years: 2 });\n      const modifiedAt = faker.date.past({ years: 1 });\n      const update = FactoryResearchItemUpdate({\n        createdAt,\n        isDraft: false,\n        modifiedAt,\n        publishedAt: createdAt,\n        title: 'A title',\n        description: 'A description',\n        deleted: false,\n      });\n\n      // Act\n      const wrapper = getWrapper(\n        FactoryResearchItem({\n          updates: [update],\n        }),\n      );\n      // Assert\n      await waitFor(() => {\n        expect(() => wrapper.getAllByText((content) => content.includes('Created'))).not.toThrow();\n        expect(() => wrapper.getAllByText((content) => content.includes('edit'))).not.toThrow();\n      });\n    });\n  });\n\n  describe('Breadcrumbs', () => {\n    it('displays breadcrumbs with category', async () => {\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = getWrapper(\n          FactoryResearchItem({\n            title: 'Innovative Study',\n            category: {\n              name: 'Science',\n              id: faker.number.int(),\n              createdAt: faker.date.past(),\n              modifiedAt: null,\n              type: 'research',\n            },\n          }),\n        );\n      });\n\n      // Assert: Check the breadcrumb items and chevrons\n      await waitFor(() => {\n        const breadcrumbItems = wrapper.getAllByTestId('breadcrumbsItem');\n        expect(breadcrumbItems).toHaveLength(3);\n        expect(breadcrumbItems[0]).toHaveTextContent('Research');\n        expect(breadcrumbItems[1]).toHaveTextContent('Science');\n        expect(breadcrumbItems[2]).toHaveTextContent('Innovative Study');\n\n        // Assert: Check that the first two breadcrumb items contain links\n        const firstLink = within(breadcrumbItems[0]).getByRole('link');\n        const secondLink = within(breadcrumbItems[1]).getByRole('link');\n        expect(firstLink).toBeInTheDocument();\n        expect(secondLink).toBeInTheDocument();\n\n        // Assert: Check for the correct number of chevrons\n        const chevrons = wrapper.getAllByTestId('breadcrumbsChevron');\n        expect(chevrons).toHaveLength(2);\n      });\n    });\n\n    it('displays breadcrumbs without category', async () => {\n      // Act\n      let wrapper;\n      act(() => {\n        wrapper = getWrapper(\n          FactoryResearchItem({\n            title: 'Innovative Study',\n            category: undefined, // No category provided\n            updates: [],\n          }),\n        );\n      });\n\n      // Assert: Check the breadcrumb items and chevrons\n      await waitFor(() => {\n        const breadcrumbItems = wrapper.getAllByTestId('breadcrumbsItem');\n        expect(breadcrumbItems).toHaveLength(2);\n        expect(breadcrumbItems[0]).toHaveTextContent('Research');\n        expect(breadcrumbItems[1]).toHaveTextContent('Innovative Study');\n\n        // Assert: Check that the first breadcrumb item contains a link\n        const firstLink = within(breadcrumbItems[0]).getByRole('link');\n        expect(firstLink).toBeInTheDocument();\n\n        // Assert: Check for the correct number of chevrons\n        const chevrons = wrapper.getAllByTestId('breadcrumbsChevron');\n        expect(chevrons).toHaveLength(1);\n      });\n    });\n  });\n});\n\nconst getWrapper = (research: ResearchItem) => {\n  const router = createMemoryRouter(\n    createRoutesFromElements(<Route path=\"/research/:slug\" key={1} element={<ResearchArticlePage research={research} />} />),\n    {\n      initialEntries: ['/research/article'],\n    },\n  );\n\n  return render(\n    <ProfileStoreProvider>\n      <ThemeProvider theme={theme}>\n        <RouterProvider router={router} />\n      </ThemeProvider>\n    </ProfileStoreProvider>,\n  );\n};\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchArticlePage.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Button } from 'oa-components';\nimport type { ResearchItem } from 'oa-shared';\nimport { useEffect, useMemo } from 'react';\nimport { Link, useLocation } from 'react-router';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport PageHeader from 'src/common/PageHeader';\nimport { Breadcrumbs } from 'src/pages/common/Breadcrumbs/Breadcrumbs';\nimport { getResearchCommentId, getResearchUpdateId } from 'src/pages/Research/Content/helper';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { hasAdminRights } from 'src/utils/helpers';\nimport { Box, Flex } from 'theme-ui';\nimport ResearchDescription from './ResearchDescription';\nimport ResearchEngagementSection from './ResearchEngagementSection';\nimport ResearchUpdate from './ResearchUpdate';\n\ninterface IProps {\n  research: ResearchItem;\n}\n\nexport const ResearchArticlePage = observer(({ research }: IProps) => {\n  const location = useLocation();\n  const { profile: activeUser } = useProfileStore();\n\n  const scrollIntoRelevantSection = () => {\n    if (getResearchCommentId(location.hash) === '') return;\n    const section = document.getElementById(`update_${getResearchUpdateId(location.hash)}`);\n    section?.scrollIntoView({ behavior: 'smooth', block: 'start' });\n  };\n\n  useEffect(() => {\n    scrollIntoRelevantSection();\n  }, [location.hash]);\n\n  const isEditable = useMemo(() => {\n    return (\n      !!activeUser &&\n      (hasAdminRights(activeUser) ||\n        research.author?.username === activeUser.username ||\n        research.collaborators?.map((c) => c.username).includes(activeUser.username))\n    );\n  }, [activeUser, research.author]);\n\n  const sortedUpdates = useMemo(() => {\n    return research?.updates\n      ?.slice()\n      .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());\n  }, [research?.updates]);\n\n  return (\n    <Box sx={{ width: '100%', maxWidth: '1000px', alignSelf: 'center' }}>\n      <PageHeader\n        actions={\n          isEditable && (\n            <Flex\n              sx={{\n                gap: 2,\n                paddingLeft: 2,\n                width: ['100%', 'auto', 'auto'],\n                justifyContent: 'flex-end',\n              }}\n            >\n              {isEditable && (\n                <Link to={'/research/' + research.slug + '/edit'}>\n                  <Button type=\"button\" variant=\"primary\" data-cy=\"edit\">\n                    Edit\n                  </Button>\n                </Link>\n              )}\n            </Flex>\n          )\n        }\n      >\n        <Breadcrumbs\n          steps={[\n            { text: 'Research', link: '/research' },\n            ...(research.category\n              ? [\n                  {\n                    text: research.category.name,\n                    link: `/research?category=${research.category.id}`,\n                  },\n                ]\n              : []),\n            { text: research.title },\n          ]}\n        />\n      </PageHeader>\n\n      <ResearchDescription research={research} />\n\n      <Flex\n        sx={{\n          flexDirection: 'column',\n          marginTop: [2, 4],\n          marginBottom: 4,\n          gap: [4, 6],\n        }}\n      >\n        {sortedUpdates?.map((update, index) => (\n          <ResearchUpdate\n            research={research}\n            update={update}\n            key={update.id}\n            updateIndex={index}\n            isEditable={isEditable}\n            slug={research.slug}\n          />\n        ))}\n      </Flex>\n\n      <ClientOnly fallback={<></>}>\n        {() => <ResearchEngagementSection research={research} />}\n      </ClientOnly>\n\n      {isEditable && (\n        <Flex sx={{ my: 4 }}>\n          <Link to={`/research/${research.slug}/new-update`}>\n            <Button\n              type=\"button\"\n              large\n              sx={{\n                marginLeft: 2,\n                marginBottom: [3, 3, 0],\n              }}\n              data-cy=\"addResearchUpdateButton\"\n            >\n              Add update\n            </Button>\n          </Link>\n        </Flex>\n      )}\n    </Box>\n  );\n});\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchDescription.tsx",
    "content": "import { max } from 'date-fns';\nimport {\n  AuthorDisplay,\n  Category,\n  DisplayDate,\n  LinkifyText,\n  TagList,\n  Username,\n} from 'oa-components';\nimport { type ResearchItem, ResearchStatusRecord } from 'oa-shared';\nimport { useMemo } from 'react';\nimport { DraftTag } from 'src/pages/common/Drafts/DraftTag';\nimport { Card, Divider, Flex, Heading, Text } from 'theme-ui';\nimport { researchStatusColour } from '../researchHelpers';\nimport ResearchFooter from './ResearchFooter';\n\ninterface IProps {\n  research: ResearchItem;\n}\n\nconst ResearchDescription = (props: IProps) => {\n  const { research } = props;\n\n  const lastUpdated = useMemo(() => {\n    const dates = [\n      research?.modifiedAt,\n      ...(research?.updates?.map((update) => update?.modifiedAt) || []),\n    ]\n      .filter((date): date is Date => date !== null)\n      .map((date) => new Date(date));\n\n    return dates.length > 0 ? max(dates) : new Date();\n  }, [research]);\n\n  const hasContributors = research.collaborators && research.collaborators.length;\n\n  return (\n    <Card variant=\"responsive\">\n      <Flex\n        data-cy=\"research-basis\"\n        data-id={research.id}\n        sx={{\n          position: 'relative',\n          overflow: 'hidden',\n          flexDirection: 'column',\n          gap: 4,\n          padding: [2, 4],\n        }}\n      >\n        <Flex\n          sx={{\n            flexDirection: 'column',\n            width: '100%',\n            gap: 2,\n          }}\n        >\n          {research.deleted && (\n            <Text color=\"red\" pl={2} mb={2} data-cy=\"research-deleted\">\n              * Marked for deletion\n            </Text>\n          )}\n\n          <Heading as=\"h1\" data-testid=\"research-title\">\n            {research.title}\n          </Heading>\n\n          <Flex\n            sx={{\n              flexDirection: 'row',\n              flexWrap: 'wrap',\n              gap: 2,\n              alignItems: 'center',\n            }}\n          >\n            <AuthorDisplay author={research.author} />\n\n            {hasContributors ? (\n              <Flex sx={{ alignItems: 'center', gap: 1 }}>\n                <Text variant=\"auxiliary\" sx={{ color: 'lightgrey' }}>\n                  With contributions from\n                </Text>\n                {research.collaborators.map((contributor, key) => (\n                  <Username key={key} user={contributor} />\n                ))}\n              </Flex>\n            ) : null}\n\n            {research.isDraft && <DraftTag />}\n\n            <Text variant=\"auxiliary\">\n              <DisplayDate\n                createdAt={research.createdAt}\n                publishedAt={research.publishedAt}\n                modifiedAt={lastUpdated.toISOString()}\n                publishedAction=\"Started\"\n              />\n            </Text>\n\n            {research.category && <Category category={research.category} sx={{ fontSize: 2 }} />}\n\n            <Flex\n              sx={{\n                borderRadius: 1,\n                background: researchStatusColour(research.status),\n              }}\n            >\n              <Text\n                sx={{\n                  fontSize: '14px',\n                  paddingX: 2,\n                  paddingY: 1,\n                }}\n              >\n                {research.status ? ResearchStatusRecord[research.status] : 'In progress'}\n              </Text>\n            </Flex>\n          </Flex>\n        </Flex>\n\n        <Text variant=\"paragraph\" sx={{ whiteSpace: 'pre-line' }}>\n          <LinkifyText>{research.description}</LinkifyText>\n        </Text>\n\n        <TagList tags={research.tags.map((t) => ({ label: t.name }))} />\n      </Flex>\n\n      <Divider sx={{ border: '1px solid black', margin: 0 }} />\n\n      <ResearchFooter research={research} />\n    </Card>\n  );\n};\n\nexport default ResearchDescription;\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchEngagementSection.tsx",
    "content": "import { observer } from 'mobx-react';\nimport {\n  ArticleCallToActionSupabase,\n  Button,\n  FollowButton,\n  UsefulStatsButton,\n  UserEngagementWrapper,\n} from 'oa-components';\nimport type { ResearchItem } from 'oa-shared';\nimport { useState } from 'react';\nimport { trackEvent } from 'src/common/Analytics';\nimport { DonationRequestModalContainer } from 'src/common/DonationRequestModalContainer';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { useSubscription } from 'src/stores/Subscription/useSubscription';\nimport { useUsefulVote } from 'src/stores/UsefulVote/useUsefulVote';\nimport { Box } from 'theme-ui';\n\ntype ResearchEngagementSectionProps = {\n  research: ResearchItem;\n};\n\nconst ResearchEngagementSection = observer(({ research }: ResearchEngagementSectionProps) => {\n  const [isDonationModalOpen, setIsDonationModalOpen] = useState(false);\n  const { profile } = useProfileStore();\n  const { isSubscribed, toggle: toggleSubscription } = useSubscription('research', research.id);\n  const { hasVoted, toggle: toggleVote } = useUsefulVote(\n    'research',\n    research.id,\n    research.usefulCount || 0,\n  );\n\n  return (\n    <UserEngagementWrapper>\n      <Box\n        sx={{\n          marginBottom: [6, 6, 12],\n        }}\n      >\n        {research.author && (\n          <ArticleCallToActionSupabase\n            author={research.author}\n            contributors={research.collaborators}\n          >\n            <UsefulStatsButton\n              isLoggedIn={!!profile}\n              hasUserVotedUseful={hasVoted}\n              onUsefulClick={toggleVote}\n            />\n            <FollowButton\n              isLoggedIn={!!profile}\n              onFollowClick={() => toggleSubscription()}\n              isFollowing={isSubscribed}\n              tooltipFollow=\"Follow to be notified about new updates\"\n              tooltipUnfollow=\"Unfollow to stop being notified about new updates\"\n              sx={{ backgroundColor: '#fff' }}\n            />\n            {research.author?.profileType?.isSpace && research.author?.donationsEnabled && (\n              <>\n                <DonationRequestModalContainer\n                  profileId={research.author?.id}\n                  isOpen={isDonationModalOpen}\n                  onDidDismiss={() => setIsDonationModalOpen(false)}\n                />\n                <Button\n                  icon=\"donate\"\n                  variant=\"outline\"\n                  iconColor=\"primary\"\n                  sx={{ fontSize: '14px', backgroundColor: '#fff' }}\n                  onClick={() => {\n                    trackEvent({\n                      action: 'donationModalOpened',\n                      category: 'research',\n                      label: research.author?.username || '',\n                    });\n                    setIsDonationModalOpen(true);\n                  }}\n                >\n                  Support the author\n                </Button>\n              </>\n            )}\n          </ArticleCallToActionSupabase>\n        )}\n      </Box>\n    </UserEngagementWrapper>\n  );\n});\nexport default ResearchEngagementSection;\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchFooter.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { ContentStatistics, FollowButton, UsefulStatsButton } from 'oa-components';\nimport { PremiumTier, type ResearchItem } from 'oa-shared';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { userHasPremiumTier } from 'src/common/PremiumTierWrapper';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { useSubscription } from 'src/stores/Subscription/useSubscription';\nimport { useUsefulVote } from 'src/stores/UsefulVote/useUsefulVote';\nimport { buildStatisticsLabel } from 'src/utils/helpers';\nimport { createUsefulStatistic } from 'src/utils/statistics';\nimport { Flex } from 'theme-ui';\n\ntype ResearchFooterProps = {\n  research: ResearchItem;\n};\n\nconst ResearchFooter = observer(({ research }: ResearchFooterProps) => {\n  const { profile } = useProfileStore();\n  const { isSubscribed, toggle: toggleSubscription } = useSubscription('research', research.id);\n  const {\n    hasVoted,\n    usefulCount,\n    toggle: toggleVote,\n  } = useUsefulVote('research', research.id, research.usefulCount || 0);\n\n  return (\n    <Flex\n      sx={{\n        flexDirection: 'row',\n        padding: [2, 3],\n        gap: 3,\n        flexWrap: 'wrap',\n        justifyContent: 'space-between',\n      }}\n    >\n      <Flex sx={{ gap: 3 }}>\n        <ClientOnly fallback={<></>}>\n          {() => (\n            <>\n              <UsefulStatsButton\n                hasUserVotedUseful={hasVoted}\n                isLoggedIn={!!profile}\n                onUsefulClick={toggleVote}\n              />\n              <FollowButton\n                isFollowing={isSubscribed}\n                isLoggedIn={!!profile}\n                onFollowClick={toggleSubscription}\n                tooltipFollow=\"Follow to be notified about new updates\"\n                tooltipUnfollow=\"Unfollow to stop being notified about new updates\"\n              />\n            </>\n          )}\n        </ClientOnly>\n      </Flex>\n\n      <ContentStatistics\n        statistics={[\n          {\n            icon: 'show',\n            label: buildStatisticsLabel({\n              stat: research.totalViews || 0,\n              statUnit: 'view',\n              usePlural: true,\n            }),\n            stat: research.totalViews || 0,\n          },\n          {\n            icon: 'thunderbolt-grey',\n            label: buildStatisticsLabel({\n              stat: research.subscriberCount || 0,\n              statUnit: 'following',\n              usePlural: false,\n            }),\n            stat: research.subscriberCount || 0,\n          },\n          createUsefulStatistic(\n            'research',\n            research.id,\n            usefulCount,\n            userHasPremiumTier(profile, PremiumTier.ONE),\n          ),\n          {\n            icon: 'comment-outline',\n            label: buildStatisticsLabel({\n              stat: research.commentCount || 0,\n              statUnit: 'comment',\n              usePlural: true,\n            }),\n            stat: research.commentCount || 0,\n          },\n          {\n            icon: 'update',\n            label: buildStatisticsLabel({\n              stat: research.updateCount || 0,\n              statUnit: 'update',\n              usePlural: true,\n            }),\n            stat: research.updateCount || 0,\n          },\n        ]}\n        alwaysShow\n      />\n    </Flex>\n  );\n});\nexport default ResearchFooter;\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchLinkToUpdate.tsx",
    "content": "import { Icon, Tooltip } from 'oa-components';\nimport type { ResearchItem, ResearchUpdate } from 'oa-shared';\nimport { useState } from 'react';\nimport { Button } from 'theme-ui';\n\ninterface IProps {\n  research: ResearchItem;\n  update: ResearchUpdate;\n}\n\nconst COPY_TO_CLIPBOARD = 'Share this update';\nconst SUCCESS = 'Link copied to clipboard!';\n\nexport const ResearchLinkToUpdate = ({ research, update }: IProps) => {\n  const [showCheck, setShowCheck] = useState(false);\n\n  const copyURLtoClipboard = async (slug: string, id: number) => {\n    try {\n      // this try-catch is mainly for cypress not to error\n      await navigator.clipboard.writeText(`${location.origin}/research/${slug}#update_${id}`);\n    } catch (_) {}\n    setShowCheck(true);\n\n    setTimeout(() => {\n      setShowCheck(false);\n    }, 2000);\n  };\n\n  return (\n    <Button\n      variant=\"subtle\"\n      onClick={() => copyURLtoClipboard(research.slug, update.id)}\n      data-cy=\"ResearchLinkToUpdate\"\n      data-tooltip-id=\"link-update\"\n      data-tooltip-content={showCheck ? SUCCESS : COPY_TO_CLIPBOARD}\n    >\n      {showCheck ? (\n        <Icon glyph=\"check\" color=\"green\" size={30} />\n      ) : (\n        <Icon glyph=\"hyperlink\" size={30} />\n      )}\n      <Tooltip id=\"link-update\" />\n    </Button>\n  );\n};\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchList.tsx",
    "content": "import { Loader, Pagination } from 'oa-components';\nimport type { ResearchItem, ResearchStatus } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { useSearchParams } from 'react-router';\nimport { logger } from 'src/logger';\nimport useDrafts from 'src/pages/common/Drafts/useDraftsSupabase';\nimport { Box, Flex } from 'theme-ui';\nimport { ITEMS_PER_PAGE } from '../constants';\nimport type { ResearchSortOption } from '../ResearchSortOptions';\nimport { researchService } from '../research.service';\nimport { ResearchFilterHeader } from './ResearchListHeader';\nimport ResearchListItem from './ResearchListItem';\nimport { ResearchSearchParams } from './ResearchSearchParams';\n\nconst ResearchList = () => {\n  const [isFetching, setIsFetching] = useState(true);\n  const [researchItems, setResearchItems] = useState<ResearchItem[]>([]);\n  const { draftCount, isFetchingDrafts, drafts, showDrafts, handleShowDrafts } =\n    useDrafts<ResearchItem>({\n      getDraftCount: researchService.getDraftCount,\n      getDrafts: researchService.getDrafts,\n    });\n  const [total, setTotal] = useState(0);\n\n  const [searchParams, setSearchParams] = useSearchParams();\n  const q = searchParams.get(ResearchSearchParams.q) || '';\n  const category = searchParams.get(ResearchSearchParams.category) || '';\n  const status = searchParams.get(ResearchSearchParams.status) as ResearchStatus | null;\n  const sort = searchParams.get(ResearchSearchParams.sort) as ResearchSortOption;\n  const pageNumber = Math.max(1, parseInt(searchParams.get(ResearchSearchParams.page) || '1') || 1);\n\n  useEffect(() => {\n    if (!sort) {\n      // ensure sort is set\n      const params = new URLSearchParams(searchParams.toString());\n\n      if (q) {\n        params.set(ResearchSearchParams.sort, 'MostRelevant');\n      } else {\n        params.set(ResearchSearchParams.sort, 'LatestUpdated');\n      }\n      setSearchParams(params, { replace: true });\n    } else {\n      // search only when sort is set (avoids duplicate requests)\n      const skip = (pageNumber - 1) * ITEMS_PER_PAGE;\n      fetchResearchItems(skip);\n    }\n  }, [q, category, status, sort, pageNumber]);\n\n  const updatePageNumber = (value: number) => {\n    const params = new URLSearchParams(searchParams.toString());\n\n    params.set(ResearchSearchParams.page, value.toString());\n    setSearchParams(params, { replace: true });\n  };\n\n  const fetchResearchItems = async (skip: number = 0) => {\n    setIsFetching(true);\n\n    try {\n      const result = await researchService.search(\n        q?.toLocaleLowerCase(),\n        category,\n        sort,\n        status,\n        skip,\n      );\n\n      if (result) {\n        setResearchItems(result.items);\n        setTotal(result.total);\n      }\n    } catch (error) {\n      logger.error('error fetching research items', error);\n    }\n\n    setIsFetching(false);\n  };\n\n  const researchItemList = showDrafts ? drafts : researchItems;\n\n  return (\n    <Flex\n      sx={{\n        flexDirection: 'column',\n        gap: [2, 3],\n        maxWidth: ['639px', '791px', '1000px'],\n        width: '100%',\n        mx: 'auto',\n      }}\n    >\n      <ResearchFilterHeader\n        itemCount={isFetching ? undefined : total}\n        draftCount={isFetchingDrafts ? undefined : draftCount}\n        handleShowDrafts={handleShowDrafts}\n        showDrafts={showDrafts}\n      />\n\n      {((researchItems && researchItems.length !== 0) || showDrafts) && (\n        <Box\n          as=\"ul\"\n          data-cy=\"ResearchList\"\n          sx={{\n            listStyle: 'none',\n            p: 0,\n            m: 0,\n            display: 'flex',\n            flexDirection: 'column',\n            gap: [0, 2, 2],\n            ml: [-2, 0, 0],\n            mr: [-2, 0, 0],\n          }}\n        >\n          {researchItemList.map((item) => (\n            <ResearchListItem\n              key={item.id}\n              item={item}\n              showWeeklyVotes={sort === 'MostUsefulLastWeek'}\n            />\n          ))}\n        </Box>\n      )}\n\n      {!isFetching && researchItems && researchItems.length > 0 && (\n        <Flex\n          sx={{\n            justifyContent: 'center',\n          }}\n        >\n          <Pagination\n            totalPages={Math.ceil(total / ITEMS_PER_PAGE)}\n            onPageChange={updatePageNumber}\n            page={pageNumber || 1}\n          />\n        </Flex>\n      )}\n\n      {(isFetching || isFetchingDrafts) && <Loader />}\n    </Flex>\n  );\n};\n\nexport default ResearchList;\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchListHeader.tsx",
    "content": "import debounce from 'debounce';\nimport {\n  ButtonIcon,\n  CategoryHorizonalList,\n  ReturnPathLink,\n  SearchField,\n  Select,\n  Tooltip,\n} from 'oa-components';\nimport type { Category, ResearchStatus } from 'oa-shared';\nimport { ResearchStatusRecord } from 'oa-shared';\nimport { useCallback, useContext, useEffect, useState } from 'react';\nimport { Link, useSearchParams } from 'react-router';\nimport { AuthWrapper } from 'src/common/AuthWrapper';\nimport { FieldContainer } from 'src/common/Form/FieldContainer';\nimport { useClickOutside } from 'src/common/hooks/useClickOutside';\nimport { UserAction } from 'src/common/UserAction';\nimport DraftButton from 'src/pages/common/Drafts/DraftButton';\nimport { ListHeader } from 'src/pages/common/Layout/ListHeader';\nimport type { FilterSection } from 'src/pages/common/Layout/MobileSortModal';\nimport { MobileSortModal } from 'src/pages/common/Layout/MobileSortModal';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { categoryService } from 'src/services/categoryService';\nimport { Box, Button, Flex } from 'theme-ui';\nimport { listing } from '../labels';\nimport type { ResearchSortOption } from '../ResearchSortOptions';\nimport { ResearchSortOptions } from '../ResearchSortOptions';\nimport { ResearchSearchParams } from './ResearchSearchParams';\n\ninterface IProps {\n  itemCount?: number;\n  draftCount?: number;\n  handleShowDrafts: () => void;\n  showDrafts: boolean;\n}\n\nconst researchStatusOptions: { label: string; value: ResearchStatus | '' }[] = [\n  { label: 'All', value: '' },\n  { label: 'In Progress', value: 'in-progress' },\n  { label: 'Completed', value: 'complete' },\n];\n\nconst DEFAULT_SORT: ResearchSortOption = 'LatestUpdated';\n\nexport const ResearchFilterHeader = (props: IProps) => {\n  const { itemCount, draftCount, handleShowDrafts, showDrafts } = props;\n\n  const tenantContext = useContext(TenantContext);\n\n  const [categories, setCategories] = useState<Category[]>([]);\n  const [searchParams, setSearchParams] = useSearchParams();\n  const q = searchParams.get(ResearchSearchParams.q);\n  const [searchString, setSearchString] = useState<string>(q ?? '');\n  const [isSearchOpen, setIsSearchOpen] = useState(false);\n\n  const sort = searchParams.get(ResearchSearchParams.sort) as ResearchSortOption;\n  const status = (searchParams.get(ResearchSearchParams.status) as ResearchStatus) || '';\n  const categoryParam = Number(searchParams.get(ResearchSearchParams.category));\n  const category = categories?.find((x) => x.id === categoryParam) ?? null;\n\n  const [isSortModalOpen, setIsSortModalOpen] = useState(false);\n  const [pendingSort, setPendingSort] = useState<ResearchSortOption>(sort || DEFAULT_SORT);\n  const [pendingStatus, setPendingStatus] = useState<ResearchStatus | ''>(status || '');\n\n  const handleApplySort = () => {\n    const params = new URLSearchParams(searchParams.toString());\n    if (pendingSort) {\n      params.set(ResearchSearchParams.sort, pendingSort);\n    } else {\n      params.delete(ResearchSearchParams.sort);\n    }\n    if (pendingStatus) {\n      params.set(ResearchSearchParams.status, pendingStatus);\n    } else {\n      params.delete(ResearchSearchParams.status);\n    }\n    setSearchParams(params);\n    handleCloseSortModal();\n  };\n\n  const handleResetSort = () => {\n    const params = new URLSearchParams(searchParams.toString());\n    params.set(ResearchSearchParams.sort, DEFAULT_SORT);\n    params.delete(ResearchSearchParams.status);\n    setSearchParams(params);\n    setPendingSort(DEFAULT_SORT);\n    setPendingStatus('');\n  };\n\n  const sortOptions = ResearchSortOptions.toArray(!!q);\n\n  const handleOpenSortModal = () => {\n    setPendingSort(sort || DEFAULT_SORT);\n    setPendingStatus(status || '');\n    setIsSortModalOpen(true);\n  };\n\n  const handleCloseSortModal = () => {\n    setIsSortModalOpen(false);\n  };\n\n  const handleToggleSearchOpen = () => {\n    setIsSearchOpen((x) => !x);\n  };\n\n  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    searchValue(searchString);\n    setIsSearchOpen(false);\n  };\n\n  const formRef = useClickOutside(() => {\n    setIsSearchOpen(false);\n  });\n\n  useEffect(() => {\n    const initCategories = async () => {\n      const categories = (await categoryService.getCategories('research')) || [];\n      setCategories(categories);\n    };\n\n    initCategories();\n\n    if (!searchParams.get(ResearchSearchParams.sort)) {\n      const params = new URLSearchParams(searchParams.toString());\n      params.set(ResearchSearchParams.sort, 'LatestUpdated');\n      setSearchParams(params, { replace: true });\n    }\n  }, []);\n\n  const updateFilter = useCallback(\n    (key: ResearchSearchParams, value: string) => {\n      const params = new URLSearchParams(searchParams.toString());\n      if (value) {\n        params.set(key, value);\n      } else {\n        params.delete(key);\n      }\n      setSearchParams(params);\n    },\n    [searchParams],\n  );\n\n  const onSearchInputChange = useCallback(\n    debounce((value: string) => {\n      searchValue(value);\n    }, 500),\n    [searchParams],\n  );\n\n  const searchValue = (value: string) => {\n    const params = new URLSearchParams(searchParams.toString());\n    params.set(ResearchSearchParams.q, value);\n\n    if (value.length > 0 && sort !== 'MostRelevant') {\n      params.set(ResearchSearchParams.sort, 'MostRelevant');\n    }\n\n    if (value.length === 0 || !value) {\n      params.set(ResearchSearchParams.sort, DEFAULT_SORT);\n    }\n\n    setSearchParams(params);\n  };\n\n  const effectiveDefaultSort = q ? 'MostRelevant' : DEFAULT_SORT;\n  const activeFilterCount =\n    (sort && sort !== effectiveDefaultSort ? 1 : 0) +\n    (categoryParam > 0 ? 1 : 0) +\n    (status ? 1 : 0);\n\n  const createResearchRoles = tenantContext?.createResearchRoles;\n\n  const actionComponents = (\n    <UserAction\n      incompleteProfile={\n        <AuthWrapper roleRequired={tenantContext?.createResearchRoles}>\n          <Link to=\"/settings\">\n            <Button\n              type=\"button\"\n              variant=\"disabled\"\n              data-cy=\"complete-profile-research\"\n              data-tooltip-id=\"tooltip\"\n              data-tooltip-content={listing.incompleteProfile}\n            >\n              {listing.create}\n            </Button>\n          </Link>\n          <Tooltip id=\"tooltip\" />\n        </AuthWrapper>\n      }\n      loggedIn={\n        <AuthWrapper roleRequired={createResearchRoles}>\n          <Flex sx={{ gap: 2 }}>\n            <DraftButton\n              showDrafts={showDrafts}\n              draftCount={draftCount}\n              handleShowDrafts={handleShowDrafts}\n            />\n            <Link to=\"/research/create\">\n              <Button type=\"button\" variant=\"primary\" data-cy=\"create\">\n                {listing.create}\n              </Button>\n            </Link>\n          </Flex>\n        </AuthWrapper>\n      }\n      loggedOut={\n        (!createResearchRoles ||\n          (Array.isArray(createResearchRoles) && createResearchRoles.length === 0)) && (\n          <ReturnPathLink to=\"/sign-up\">\n            <Button type=\"button\" variant=\"primary\" data-cy=\"sign-up\">\n              {listing.create}\n            </Button>\n          </ReturnPathLink>\n        )\n      }\n    />\n  );\n\n  const categoryComponent = (\n    <CategoryHorizonalList\n      allCategories={categories}\n      activeCategory={category}\n      setActiveCategory={(updatedCategory) =>\n        updateFilter(\n          ResearchSearchParams.category,\n          updatedCategory ? (updatedCategory as Category).id.toString() : '',\n        )\n      }\n    />\n  );\n\n  const filteringComponents = (\n    <Flex\n      sx={{\n        gap: 2,\n        flexWrap: 'wrap',\n        display: ['none', 'none', 'flex'],\n      }}\n    >\n      <Flex sx={{ width: ['100%', '100%', '220px'] }}>\n        <FieldContainer>\n          <Select\n            options={ResearchSortOptions.toArray(!!q)}\n            placeholder={listing.sort}\n            value={sort ? { label: ResearchSortOptions.get(sort), value: sort } : undefined}\n            onChange={(sortBy) => updateFilter(ResearchSearchParams.sort, sortBy.value)}\n          />\n        </FieldContainer>\n      </Flex>\n\n      <Flex sx={{ width: ['100%', '100%', '160px'] }}>\n        <FieldContainer>\n          <Select\n            options={researchStatusOptions}\n            placeholder={listing.status}\n            value={status ? { label: ResearchStatusRecord[status], value: status } : undefined}\n            onChange={(status) => updateFilter(ResearchSearchParams.status, status.value)}\n          />\n        </FieldContainer>\n      </Flex>\n\n      <Flex sx={{ width: ['100%', '100%', '200px'] }}>\n        <SearchField\n          dataCy=\"research-search-box\"\n          value={searchString}\n          onChange={(value) => {\n            setSearchString(value);\n            onSearchInputChange(value);\n          }}\n          onClear={() => {\n            setSearchString('');\n            searchValue('');\n          }}\n          onClickSearch={() => searchValue(searchString)}\n        />\n      </Flex>\n    </Flex>\n  );\n\n  const modalSections: FilterSection[] = [\n    {\n      title: 'Status',\n      options: researchStatusOptions,\n      selectedValue: pendingStatus,\n      onSelect: (value) => setPendingStatus(value as ResearchStatus | ''),\n    },\n    {\n      title: 'Sort',\n      options: sortOptions,\n      selectedValue: pendingSort,\n      onSelect: (value) => setPendingSort(value as ResearchSortOption),\n    },\n  ];\n\n  const mobileFilteringComponents = (\n    <Flex sx={{ display: ['flex', 'flex', 'none'], gap: '5px' }}>\n      <Flex sx={{ position: 'relative' }}>\n        <ButtonIcon\n          onClick={handleOpenSortModal}\n          icon=\"sliders\"\n          sx={{\n            borderRadius: 1,\n            padding: '9px',\n            '&:hover': {\n              backgroundColor: 'background',\n            },\n          }}\n        />\n        {activeFilterCount > 0 && (\n          <Box\n            sx={{\n              position: 'absolute',\n              top: '-4px',\n              right: '-4px',\n              minWidth: '18px',\n              height: '18px',\n              borderRadius: '50%',\n              backgroundColor: 'red',\n              color: 'background',\n              fontSize: 0,\n              fontWeight: 'bold',\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n            }}\n          >\n            {activeFilterCount}\n          </Box>\n        )}\n      </Flex>\n      <ButtonIcon\n        onClick={handleToggleSearchOpen}\n        icon=\"search\"\n        sx={{\n          border: 'none',\n          background: 'transparent',\n          '&:hover': {\n            backgroundColor: 'background',\n          },\n        }}\n      />\n    </Flex>\n  );\n\n  const mobileSearchBar = (\n    <Flex sx={{ display: ['flex', 'flex', 'none'], width: '100%' }}>\n      <div ref={formRef} style={{ width: '100%' }}>\n        <form onSubmit={handleSubmit} style={{ width: '100%' }}>\n          <SearchField\n            isExpanded\n            autoFocus\n            dataCy=\"research-search-box\"\n            value={searchString}\n            onChange={(value) => {\n              setSearchString(value);\n              onSearchInputChange(value);\n            }}\n            onClear={() => {\n              setSearchString('');\n              searchValue('');\n            }}\n            onClickSearch={() => searchValue(searchString)}\n            onBack={() => {\n              setIsSearchOpen(false);\n            }}\n          />\n        </form>\n      </div>\n    </Flex>\n  );\n\n  return (\n    <>\n      <ListHeader\n        itemCount={(showDrafts ? draftCount : itemCount) || 0}\n        actionComponents={isSearchOpen ? null : actionComponents}\n        showDrafts={showDrafts}\n        headingTitle={listing.heading}\n        categoryComponent={categoryComponent}\n        filteringComponents={filteringComponents}\n        mobileFilteringComponents={isSearchOpen ? mobileSearchBar : mobileFilteringComponents}\n        searchString={q || undefined}\n      />\n      <MobileSortModal\n        isOpen={isSortModalOpen}\n        onDismiss={handleCloseSortModal}\n        title=\"Filter and sort\"\n        sections={modalSections}\n        onApply={handleApplySort}\n        onReset={handleResetSort}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchListItem.tsx",
    "content": "import { formatDistanceToNow } from 'date-fns';\nimport { observer } from 'mobx-react';\nimport {\n  Category,\n  FollowIcon,\n  Icon,\n  IconCountWithTooltip,\n  InternalLink,\n  Username,\n} from 'oa-components';\nimport { type ResearchItem, ResearchStatusRecord, UserRole } from 'oa-shared';\nimport { AuthWrapper } from 'src/common/AuthWrapper';\nimport { useSubscription } from 'src/stores/Subscription/useSubscription';\nimport { Box, Card, Flex, Grid, Heading, Image, Text } from 'theme-ui';\nimport defaultResearchThumbnail from '../../../assets/images/default-research-thumbnail.jpg';\nimport { researchStatusColour } from '../researchHelpers';\n\ninterface IProps {\n  item: ResearchItem;\n  showWeeklyVotes?: boolean;\n}\n\nconst ResearchListItem = observer(({ item, showWeeklyVotes }: IProps) => {\n  const { isSubscribed } = useSubscription('research', item.id);\n  const collaborators = item['collaborators'] || [];\n  const usefulDisplayCount = item.usefulCount ?? 0;\n  const showWeeklyBadge = showWeeklyVotes && (item.usefulVotesLastWeek || 0) > 0;\n\n  const _commonStatisticStyle = {\n    display: 'flex',\n    alignItems: 'center',\n    fontSize: [1, 2, 2],\n  };\n\n  const status = item.status || 'in-progress';\n\n  const relativeLabel =\n    item.modifiedAt != null ? formatDistanceToNow(item.modifiedAt, { addSuffix: true }) : null;\n\n  return (\n    <Card\n      as=\"li\"\n      data-cy=\"ResearchListItem\"\n      data-id={item.id}\n      sx={{\n        position: 'relative',\n        p: [3, 4],\n        border: [0, '2px solid', '2px solid'],\n        borderTop: '2px solid',\n        borderRadius: [0, 2, 2],\n        overflow: 'visible',\n        '&:last-of-type': {\n          borderBottom: '2px solid',\n        },\n      }}\n      variant=\"responsive\"\n    >\n      <Flex sx={{ width: '100%', position: 'relative' }}>\n        <Grid\n          columns={['120px 1fr', '175px 1fr', '263px 1fr']}\n          gap={[3, 4, 4]}\n          sx={{\n            width: '100%',\n            gridTemplateRows: ['minmax(120px, auto)', 'minmax(175px, auto)', 'minmax(175px, auto)'],\n            alignItems: 'start',\n          }}\n        >\n          <Box sx={{ position: 'relative' }}>\n            <Image\n              sx={{\n                width: ['120px', '175px', '263px'],\n                height: ['120px', '175px', '175px'],\n                objectFit: 'cover',\n                borderRadius: 1,\n                display: 'block',\n              }}\n              loading=\"lazy\"\n              src={item.image?.publicUrl || defaultResearchThumbnail}\n              alt={`Thumbnail of ${item.title}`}\n              crossOrigin=\"\"\n            />\n            {isSubscribed && (\n              <FollowIcon\n                tooltip=\"You are following updates\"\n                sx={{\n                  position: 'absolute',\n                  top: 1,\n                  right: 1,\n                  zIndex: 1,\n                  backgroundColor: 'white',\n                  borderWidth: '2px',\n                  borderStyle: 'solid',\n                  borderColor: 'black',\n                  borderRadius: '50%',\n                  padding: 1,\n                }}\n              />\n            )}\n          </Box>\n          <Flex\n            sx={{\n              flexDirection: 'column',\n              alignItems: 'flex-start',\n              justifyContent: 'flex-start',\n              alignSelf: ['auto', 'stretch', 'stretch'],\n              minWidth: 0,\n              width: '100%',\n              height: ['auto', '100%', '100%'],\n              gap: 1,\n            }}\n          >\n            <Flex sx={{ width: '100%', flexDirection: 'column', gap: 1 }}>\n              <Flex>\n                <Heading\n                  sx={{\n                    fontSize: [3, 4],\n                    lineHeight: '1.2',\n                    display: '-webkit-box',\n                    WebkitLineClamp: [2, 2, 2],\n                    WebkitBoxOrient: 'vertical',\n                    overflow: 'hidden',\n                    wordBreak: 'break-word',\n                  }}\n                >\n                  <InternalLink\n                    to={`/research/${encodeURIComponent(item.slug)}`}\n                    sx={{\n                      textDecoration: 'none',\n                      color: 'inherit',\n                      '&:focus': {\n                        outline: 'none',\n                        textDecoration: 'none',\n                      },\n                      '&::after': {\n                        content: '\"\"',\n                        position: 'absolute',\n                        left: 0,\n                        top: 0,\n                        right: 0,\n                        height: ['120px', '175px', '175px'],\n                      },\n                    }}\n                  >\n                    {item.title}\n                  </InternalLink>\n                </Heading>\n              </Flex>\n              <Flex\n                sx={{\n                  width: '100%',\n                  alignItems: 'center',\n                }}\n              >\n                <Flex\n                  sx={{\n                    alignItems: ['flex-start', 'flex-start', 'center'],\n                    flexDirection: ['column', 'row'],\n                    flexWrap: ['nowrap', 'wrap'],\n                    gap: 1,\n                  }}\n                >\n                  {item.author && (\n                    <Username\n                      user={item.author}\n                      sx={{\n                        position: 'relative',\n                        paddingX: 0,\n                        paddingY: 0,\n                        marginLeft: 0,\n                        transform: 'translateY(-1px)',\n                        fontSize: 2,\n                      }}\n                    />\n                  )}\n                  {Boolean(collaborators.length) && (\n                    <Text\n                      sx={{\n                        ml: 4,\n                        display: ['none', 'block'],\n                        fontSize: 1,\n                        color: 'darkGrey',\n                        transform: 'translateY(2px)',\n                      }}\n                    >\n                      {collaborators.length +\n                        (collaborators.length === 1 ? ' contributor' : ' contributors')}\n                    </Text>\n                  )}\n                  <Text\n                    sx={{\n                      fontSize: [1, 2],\n                      color: 'darkGrey',\n                      transform: 'translateX(2px)',\n                    }}\n                  >\n                    {relativeLabel != null && `updated ${relativeLabel}`}\n                  </Text>\n                </Flex>\n              </Flex>\n            </Flex>\n            <Flex\n              sx={{\n                width: '100%',\n                flexDirection: 'column',\n                justifyContent: ['flex-start', 'space-between', 'space-between'],\n                flex: [0, 1, 1],\n                minHeight: [null, 0, 0],\n              }}\n            >\n              <Text\n                sx={{\n                  display: ['none', '-webkit-box'],\n                  fontFamily: 'body',\n                  lineHeight: '1.4',\n                  fontSize: 3,\n                  color: 'darkGrey',\n                  width: '100%',\n                  minHeight: 0,\n                  flexShrink: 1,\n                  WebkitLineClamp: [2, 3, 3],\n                  WebkitBoxOrient: 'vertical',\n                  textOverflow: 'ellipsis',\n                  overflow: 'hidden',\n                  wordBreak: 'break-word',\n                }}\n              >\n                {item.description}\n              </Text>\n              <Flex\n                sx={{\n                  justifyContent: 'space-between',\n                  width: '100%',\n                  mt: [0, 2, 2],\n                }}\n              >\n                <Flex\n                  sx={{\n                    justifyContent: 'flex-start',\n                    gap: 2,\n                  }}\n                >\n                  {item.category && item.category.name?.trim() ? (\n                    <Box sx={{ display: ['none', 'block', 'block'] }}>\n                      <Category category={item.category} sx={{ fontSize: 1 }} />\n                    </Box>\n                  ) : null}\n                  <Text\n                    sx={{\n                      display: 'inline-block',\n                      verticalAlign: 'bottom',\n                      color: 'black',\n                      fontSize: 1,\n                      background: researchStatusColour(status),\n                      padding: 1,\n                      borderRadius: 1,\n                      whiteSpace: 'nowrap',\n                    }}\n                    data-cy=\"ItemResearchStatus\"\n                  >\n                    {ResearchStatusRecord[status]}\n                  </Text>\n                </Flex>\n                <Flex>\n                  {/* Hide these on mobile, show on tablet & above. */}\n                  <Box\n                    sx={{\n                      display: ['none', 'flex', 'flex'],\n                      alignItems: 'center',\n                      justifyContent: 'flex-end',\n                      gap: 2,\n                    }}\n                  >\n                    <IconCountWithTooltip\n                      count={usefulDisplayCount}\n                      icon=\"star-active\"\n                      text=\"How useful is it\"\n                    />\n                    <IconCountWithTooltip\n                      count={item.commentCount || 0}\n                      icon=\"comment\"\n                      text=\"Total comments\"\n                    />\n\n                    <IconCountWithTooltip\n                      count={item.updateCount}\n                      dataCy=\"ItemUpdateText\"\n                      icon=\"update\"\n                      text=\"Amount of updates\"\n                    />\n                  </Box>\n                  {/* Show these on mobile, hide on tablet & above. */}\n                  <Box\n                    sx={{\n                      display: ['flex', 'none', 'none'],\n                      alignItems: 'center',\n                      gap: 2,\n                    }}\n                  >\n                    <Text color=\"black\" sx={_commonStatisticStyle}>\n                      {usefulDisplayCount}\n                      <Icon glyph=\"star-active\" ml={1} />\n                    </Text>\n                    <Text color=\"black\" sx={_commonStatisticStyle}>\n                      {item.commentCount || 0}\n                      <Icon glyph=\"comment\" ml={1} />\n                    </Text>\n                  </Box>\n                </Flex>\n              </Flex>\n            </Flex>\n          </Flex>\n        </Grid>\n      </Flex>\n      {showWeeklyBadge && (\n        <AuthWrapper roleRequired={UserRole.BETA_TESTER} borderLess>\n          <Flex sx={{ justifyContent: 'flex-end' }}>\n            <Box\n              sx={{\n                color: 'red',\n                padding: '2px',\n              }}\n            >\n              {item.usefulVotesLastWeek}\n            </Box>\n          </Flex>\n        </AuthWrapper>\n      )}\n    </Card>\n  );\n});\n\nexport default ResearchListItem;\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchSearchParams.ts",
    "content": "export enum ResearchSearchParams {\n  category = 'category',\n  q = 'q',\n  sort = 'sort',\n  status = 'status',\n  page = 'page',\n}\n"
  },
  {
    "path": "src/pages/Research/Content/ResearchUpdate.tsx",
    "content": "import {\n  AuthorDisplay,\n  Button,\n  DisplayDate,\n  ImageGallery,\n  LinkifyText,\n  Tooltip,\n  VideoPlayer,\n} from 'oa-components';\nimport type { ResearchItem, ResearchUpdate as ResearchUpdateModel } from 'oa-shared';\nimport { useMemo } from 'react';\nimport { Link } from 'react-router';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { DownloadWrapper } from 'src/common/DownloadWrapper';\nimport CollapsableCommentSection from 'src/pages/common/CommentsSupabase/CollapsableCommentSection';\nimport { formatImagesForGallery } from 'src/utils/formatImageListForGallery';\nimport { Box, Card, Flex, Heading, Text } from 'theme-ui';\nimport { ResearchLinkToUpdate } from './ResearchLinkToUpdate';\n\ninterface IProps {\n  research: ResearchItem;\n  update: ResearchUpdateModel;\n  updateIndex: number;\n  isEditable: boolean;\n  slug: string;\n}\n\nconst ResearchUpdate = (props: IProps) => {\n  const { research, update, updateIndex, isEditable, slug } = props;\n\n  const displayNumber = updateIndex + 1;\n\n  const authorIds = useMemo(() => {\n    const ids: number[] = [];\n\n    if (research.author) {\n      ids.push(research.author.id);\n    }\n\n    for (const collaborator of research.collaborators) {\n      if (collaborator) {\n        ids.push(collaborator.id);\n      }\n    }\n    return ids;\n  }, [research.author, research.collaborators]);\n\n  return (\n    <Flex\n      sx={{\n        flexDirection: 'column',\n        border: update.isDraft ? '2px dashed grey' : '',\n        borderRadius: 2,\n        padding: update.isDraft ? 2 : 0,\n        gap: 2,\n      }}\n    >\n      {update.isDraft && (\n        <>\n          <Button\n            data-cy=\"DraftUpdateLabel\"\n            data-tooltip-id=\"visible-tooltip\"\n            data-tooltip-content=\"Only visible to you (and other collaborators)\"\n            sx={{ alignSelf: 'flex-start', backgroundColor: 'softblue' }}\n            variant=\"subtle\"\n            small\n          >\n            Draft Update\n          </Button>\n          <Tooltip id=\"visible-tooltip\" />\n        </>\n      )}\n\n      <Flex\n        data-testid={`ResearchUpdate: ${update.id}`}\n        data-cy={`ResearchUpdate: ${update.id}`}\n        id={`update_${update.id}`}\n        sx={{\n          flexDirection: ['column', 'column', 'row'],\n          gap: [2, 4],\n          scrollMarginTop: 2,\n        }}\n      >\n        <Flex\n          sx={{\n            alignItems: 'center',\n            flexDirection: ['row', 'row', 'column'],\n            gap: [2, 4],\n          }}\n        >\n          <Card sx={{ padding: [2, 3, 4], marginLeft: [2, 0] }}>\n            <Heading sx={{ textAlign: 'center' }}>{displayNumber}</Heading>\n          </Card>\n\n          <ResearchLinkToUpdate research={research} update={update} />\n        </Flex>\n\n        <Flex\n          sx={{\n            width: '100%',\n            flexDirection: 'column',\n            flex: 9,\n            overflow: 'hidden',\n          }}\n        >\n          <Card variant=\"responsive\">\n            <Flex sx={{ flexDirection: 'column', padding: [2, 4], gap: 2 }}>\n              <Flex\n                sx={{\n                  flexDirection: 'row',\n                  gap: 2,\n                  justifyContent: 'space-between',\n                }}\n              >\n                <Heading as=\"h2\">{update.title}</Heading>\n\n                {isEditable && (\n                  <Link to={'/research/' + slug + '/edit-update/' + update.id}>\n                    <Button type=\"button\" variant=\"primary\" data-cy=\"edit-update\">\n                      Edit\n                    </Button>\n                  </Link>\n                )}\n              </Flex>\n\n              <Flex sx={{ flexDirection: ['row'], alignItems: 'center', gap: 2 }}>\n                {update.author && (\n                  <Box data-testid=\"collaborator/creator\">\n                    <AuthorDisplay author={update.author} />\n                  </Box>\n                )}\n\n                <Text variant=\"auxiliary\">\n                  <DisplayDate\n                    createdAt={update.createdAt}\n                    publishedAt={update.publishedAt}\n                    modifiedAt={update.modifiedAt}\n                    publishedAction=\"Published\"\n                  />\n                </Text>\n              </Flex>\n            </Flex>\n\n            <Flex sx={{ padding: 4, paddingBottom: 4, paddingTop: 2 }}>\n              <Text variant=\"paragraph\" color={'grey'} sx={{ whiteSpace: 'pre-line' }}>\n                <LinkifyText>{update.description}</LinkifyText>\n              </Text>\n            </Flex>\n\n            <Box sx={{ width: '100%' }}>\n              {update.videoUrl && (\n                <ClientOnly fallback={<></>}>\n                  {() => <VideoPlayer videoUrl={update.videoUrl!} />}\n                </ClientOnly>\n              )}\n              {update.images && (\n                <ImageGallery\n                  images={formatImagesForGallery(update.images) as any}\n                  allowPortrait={true}\n                />\n              )}\n            </Box>\n            <Flex className=\"file-container\" sx={{ flexDirection: 'column', px: 4, mt: 3 }}>\n              <DownloadWrapper\n                contentType=\"research\"\n                fileDownloadCount={update.fileDownloadCount}\n                fileLink={\n                  update.hasFileLink\n                    ? `/api/documents/research_update/${update.id}/link`\n                    : undefined\n                }\n                files={update.files?.map((x) => ({\n                  id: x.id,\n                  name: x.name,\n                  size: x.size,\n                  url: `/api/documents/research_update/${update.id}/${x.id}`,\n                }))}\n              />\n            </Flex>\n            {!update.isDraft && (\n              <ClientOnly fallback={<></>}>\n                {() => (\n                  <CollapsableCommentSection\n                    authors={authorIds}\n                    open={false}\n                    total={update.commentCount}\n                    researchUpdate={update}\n                  />\n                )}\n              </ClientOnly>\n            )}\n          </Card>\n        </Flex>\n      </Flex>\n    </Flex>\n  );\n};\n\nexport default ResearchUpdate;\n"
  },
  {
    "path": "src/pages/Research/Content/helper.test.tsx",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { getResearchCommentId, getResearchUpdateId } from './helper';\n\ndescribe('getReseachCommentId', () => {\n  it('extracts comment id from well formed input', () => {\n    expect(getResearchCommentId('#update_b1345-comment:abc ')).toBe('abc');\n  });\n\n  it('extracts id from well formed input', () => {\n    expect(getResearchCommentId('#another')).toBe('#another');\n  });\n});\n\ndescribe('getResearchUpdateId', () => {\n  it('extracts comment id from well formed input', () => {\n    expect(getResearchUpdateId('#update_b1345-comment:abc ')).toBe('b1345');\n  });\n});\n"
  },
  {
    "path": "src/pages/Research/Content/helper.ts",
    "content": "const researchCommentUrlPattern = /#update_[\\w\\d]+-comment:/;\nconst researchCommentSectionPattern = /-comment:[\\w\\d]*/;\n\nexport const getResearchCommentId = (s: string) => s.replace(researchCommentUrlPattern, '').trim();\n\nexport const getResearchUpdateId = (s: string) =>\n  s.replace('#update_', '').replace(researchCommentSectionPattern, '').trim();\n"
  },
  {
    "path": "src/pages/Research/ResearchSortOptions.ts",
    "content": "export type ResearchSortOption =\n  | 'MostRelevant'\n  | 'Newest'\n  | 'MostComments'\n  | 'LeastComments'\n  | 'LatestUpdated'\n  | 'MostUseful'\n  | 'MostUsefulLastWeek'\n  | 'MostUpdates';\n\nconst BaseOptions = new Map<ResearchSortOption, string>();\nBaseOptions.set('Newest', 'Newest');\nBaseOptions.set('MostComments', 'Most Comments');\nBaseOptions.set('LeastComments', 'Least Comments');\nBaseOptions.set('LatestUpdated', 'Latest Updated');\nBaseOptions.set('MostUseful', 'Most Useful');\nBaseOptions.set('MostUsefulLastWeek', 'Most Useful Last Week');\nBaseOptions.set('MostUpdates', 'Most Updates');\n\nconst QueryParamOptions = new Map<ResearchSortOption, string>(BaseOptions);\nQueryParamOptions.set('MostRelevant', 'Most Relevant');\n\nconst toArray = (hasQueryParam: boolean) => {\n  const options = hasQueryParam ? QueryParamOptions : BaseOptions;\n  return Array.from(options, ([value, label]) => ({\n    label: label,\n    value: value,\n  }));\n};\n\nexport const ResearchSortOptions = {\n  get: (key: ResearchSortOption) => QueryParamOptions.get(key) ?? '',\n  toArray,\n};\n"
  },
  {
    "path": "src/pages/Research/constants.ts",
    "content": "export const RESEARCH_TITLE_MAX_LENGTH = 60;\nexport const RESEARCH_TITLE_MIN_LENGTH = 5;\nexport const RESEARCH_MAX_LENGTH = 3000;\nexport const ITEMS_PER_PAGE = 10;\n"
  },
  {
    "path": "src/pages/Research/labels.ts",
    "content": "import type { ILabels } from 'src/common/Form/types';\nimport { RESEARCH_MAX_LENGTH, RESEARCH_TITLE_MAX_LENGTH } from './constants';\n\nexport const buttons = {\n  markCompleted: 'Mark as Completed',\n  markInProgress: 'Mark as In Progress',\n  deletion: {\n    text: 'Delete this update',\n    confirm: 'Delete',\n    message: 'Are you sure you want to delete this update?',\n  },\n  files: 'Re-upload files (this will delete the existing ones)',\n  publish: 'Publish',\n};\n\nexport const headings = {\n  overview: {\n    create: 'Start your Research',\n    edit: 'Edit your Research',\n  },\n  update: {\n    create: 'New update',\n    edit: 'Edit your update',\n  },\n};\n\nexport const errors = {\n  videoUrl: {\n    both: 'Do not include both images and video',\n    empty: 'Include either images or a video',\n    invalidUrl: 'Please provide a valid YouTube Url',\n  },\n};\n\nexport const researchForm = {\n  categories: {\n    placeholder: 'Select category',\n    title: 'Which category fit your research?',\n  },\n  collaborators: {\n    placeholder: 'Select collaborators or start typing to find them',\n    title: 'Who is collaborating with you on this research?',\n  },\n  description: {\n    placeholder: `Introduction to your research question. Mention what you want to do, whats the goal and what challenges you see etc (max ${RESEARCH_MAX_LENGTH} characters)`,\n    title: 'What are you trying to find out?',\n  },\n  status: {\n    placeholder: 'Select status',\n    title: 'What is the status of your research?',\n  },\n  tags: {\n    title: 'Select tags',\n  },\n  title: {\n    placeholder: `Can we make a chair from... (max ${RESEARCH_TITLE_MAX_LENGTH} characters)`,\n    title: 'Research Title',\n  },\n  coverImage: {\n    title: 'Cover image',\n  },\n} satisfies ILabels;\n\nexport const updateForm = {\n  description: {\n    placeholder: `Explain what is happening in your research (max ${RESEARCH_MAX_LENGTH} characters)`,\n    title: 'Description of this update',\n  },\n  title: {\n    placeholder: `Title of this update (max ${RESEARCH_TITLE_MAX_LENGTH} characters)`,\n    title: 'Title of this update',\n  },\n  images: {\n    title: 'Upload image(s) for this update',\n  },\n  videoUrl: {\n    title: 'Or embed a YouTube video',\n    placeholder: 'https://youtube.com/watch?v=',\n  },\n} satisfies ILabels;\n\nexport const listing = {\n  author: 'Filter by author',\n  create: 'Add Research',\n  filterCategory: 'Filter by category',\n  heading: 'Help out with Research & Development',\n  incompleteProfile: 'Complete your profile to add your research',\n  loadMore: 'Load More',\n  loggedOut: 'Oh we really want your research knowledge. Trust us. But a login is needed first.',\n  sort: 'Sort by',\n  status: 'Filter by status',\n};\n"
  },
  {
    "path": "src/pages/Research/research.service.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest';\n\nimport { researchService } from './research.service';\n\ndescribe('research.service', () => {\n  describe('search', () => {\n    it('fetches research articles based on search criteria', async () => {\n      // Mock successful fetch response\n      global.fetch = vi.fn().mockResolvedValue({\n        json: () =>\n          Promise.resolve({\n            items: [{ id: '1', title: 'Sample Research' }],\n            total: 1,\n          }),\n      });\n\n      // Call search with mock parameters\n      const result = await researchService.search('sample', 'science', 'Newest', null);\n\n      // Assert results\n      expect(result).toEqual({\n        items: [{ id: '1', title: 'Sample Research' }],\n        total: 1,\n      });\n    });\n\n    it('handles errors in search', async () => {\n      global.fetch = vi.fn().mockRejectedValue('error');\n\n      const result = await researchService.search('sample', 'science', 'Newest', null);\n\n      expect(result).toEqual({ items: [], total: 0 });\n    });\n  });\n\n  describe('getDraftCount', () => {\n    it('fetches draft count for a user', async () => {\n      global.fetch = vi.fn().mockResolvedValue({\n        json: () => Promise.resolve({ total: 5 }),\n      });\n\n      const result = await researchService.getDraftCount();\n\n      expect(result).toBe(5);\n    });\n\n    it('handles errors in fetching draft count', async () => {\n      global.fetch = vi.fn().mockRejectedValue('error');\n\n      const result = await researchService.getDraftCount();\n\n      expect(result).toBe(0);\n    });\n  });\n\n  describe('getDrafts', () => {\n    it('fetches research drafts for a user', async () => {\n      global.fetch = vi.fn().mockResolvedValue({\n        json: () =>\n          Promise.resolve({\n            items: [{ id: 'draft1', title: 'Draft Research' }],\n          }),\n      });\n\n      const result = await researchService.getDrafts();\n\n      expect(result).toEqual([{ id: 'draft1', title: 'Draft Research' }]);\n    });\n\n    it('handles errors in fetching drafts', async () => {\n      global.fetch = vi.fn().mockRejectedValue('error');\n\n      const result = await researchService.getDrafts();\n\n      expect(result).toEqual([]);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/Research/research.service.ts",
    "content": "import {\n  DBMedia,\n  ResearchDTO,\n  type ResearchFormData,\n  type ResearchItem,\n  type ResearchStatus,\n  type ResearchUpdate,\n  type ResearchUpdateDTO,\n  type ResearchUpdateFormData,\n} from 'oa-shared';\nimport { logger } from 'src/logger';\nimport { createFormData } from 'src/services/formDataHelper';\nimport type { ResearchSortOption } from './ResearchSortOptions';\n\nconst search = async (\n  q: string,\n  category: string,\n  sort: ResearchSortOption,\n  status: ResearchStatus | null,\n  skip: number = 0,\n) => {\n  try {\n    const url = new URL('/api/research', window.location.origin);\n    url.searchParams.append('q', q);\n    url.searchParams.append('category', category);\n    url.searchParams.append('sort', sort);\n    url.searchParams.append('status', status ?? '');\n    url.searchParams.append('skip', skip.toString());\n\n    const response = await fetch(url);\n    const { items, total } = (await response.json()) as {\n      items: ResearchItem[];\n      total: number;\n    };\n\n    return { items, total };\n  } catch (error) {\n    logger.error('Failed to fetch research articles', { error });\n    return { items: [], total: 0 };\n  }\n};\n\nconst getDraftCount = async () => {\n  try {\n    const response = await fetch('/api/research/drafts/count');\n    const { total } = (await response.json()) as { total: number };\n\n    return total;\n  } catch (error) {\n    logger.error('Failed to fetch draft count', { error });\n    return 0;\n  }\n};\n\nconst getDrafts = async () => {\n  try {\n    const response = await fetch('/api/research/drafts');\n    const { items } = (await response.json()) as { items: ResearchItem[] };\n\n    return items;\n  } catch (error) {\n    logger.error('Failed to fetch research draft articles', { error });\n    return [];\n  }\n};\n\nconst upsert = async (id: number | null, research: ResearchFormData, isDraft = false) => {\n  const data = createFormData<ResearchDTO>({\n    title: research.title,\n    description: research.description,\n    category: Number(research.category?.value) || null,\n    collaborators: research.collaborators,\n    coverImage: research.coverImage ? DBMedia.fromPublicMedia(research.coverImage) : null,\n    tags: research.tags,\n    isDraft,\n  });\n\n  const response =\n    id === null\n      ? await fetch(`/api/research`, {\n          method: 'POST',\n          body: data,\n        })\n      : await fetch(`/api/research/${id}`, {\n          method: 'PUT',\n          body: data,\n        });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error saving research' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving research';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  return (await response.json()) as { research: ResearchItem };\n};\n\nconst upsertUpdate = async (\n  researchId: number,\n  updateId: number | null,\n  update: ResearchUpdateFormData,\n  isDraft = false,\n) => {\n  const data = createFormData<ResearchUpdateDTO>({\n    title: update.title,\n    description: update.description,\n    fileLink: update.fileLink,\n    files: update.files,\n    images: update.images?.length ? update.images.map(DBMedia.fromPublicMedia) : null,\n    videoUrl: update.videoUrl,\n    isDraft,\n  });\n\n  const response =\n    updateId === null\n      ? await fetch(`/api/research/${researchId}/updates`, {\n          method: 'POST',\n          body: data,\n        })\n      : await fetch(`/api/research/${researchId}/updates/${updateId}`, {\n          method: 'PUT',\n          body: data,\n        });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response\n      .json()\n      .catch(() => ({ error: 'Error saving research update' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving research update';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  return (await response.json()) as { researchUpdate: ResearchUpdate };\n};\n\nconst deleteResearch = async (id: number) => {\n  const response = await fetch(`/api/research/${id}`, {\n    method: 'DELETE',\n  });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error deleting research' }));\n    const errorMessage = errorData.error || errorData.message || 'Error deleting research';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n};\n\nconst updateResearchStatus = async (id: number, status: ResearchStatus) => {\n  const data = new FormData();\n  data.append('status', status);\n\n  const response = await fetch(`/api/research/${id}/status`, {\n    method: 'PATCH',\n    body: data,\n  });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response\n      .json()\n      .catch(() => ({ error: 'Error updating research status' }));\n    const errorMessage = errorData.error || errorData.message || 'Error updating research status';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n};\n\nconst deleteUpdate = async (id: number, updateId: number | null) => {\n  const response = await fetch(`/api/research/${id}/updates/${updateId}`, {\n    method: 'DELETE',\n  });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response\n      .json()\n      .catch(() => ({ error: 'Error deleting research update' }));\n    const errorMessage = errorData.error || errorData.message || 'Error deleting research update';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n};\n\nexport const researchService = {\n  search,\n  getDrafts,\n  getDraftCount,\n  upsert,\n  upsertUpdate,\n  updateResearchStatus,\n  deleteResearch,\n  deleteUpdate,\n};\n"
  },
  {
    "path": "src/pages/Research/researchHelpers.test.ts",
    "content": "import { UserRole } from 'oa-shared';\nimport { describe, expect, it } from 'vitest';\n\nimport { researchUpdateStatusFilter } from './researchHelpers';\n\nimport type { Author, DBProfile, ResearchUpdate } from 'oa-shared';\n\ndescribe('Research Helpers', () => {\n  describe('Research Update Status Filter', () => {\n    it('should not show item when deleted', () => {\n      // prepare\n      const user = { id: 1 } as DBProfile;\n      const update = { deleted: true } as ResearchUpdate;\n\n      // act\n      const show = researchUpdateStatusFilter(null, null, update, user);\n\n      // assert\n      expect(show).toEqual(false);\n    });\n\n    it('should not show item when deleted and draft', () => {\n      // prepare\n      const user = { id: 1 } as DBProfile;\n      const update = {\n        deleted: true,\n        isDraft: true,\n      } as ResearchUpdate;\n\n      // act\n      const show = researchUpdateStatusFilter(null, null, update, user);\n\n      // assert\n      expect(show).toEqual(false);\n    });\n\n    it('should not show when draft and not author', () => {\n      // prepare\n      const user = { id: 1 } as DBProfile;\n      const author = { id: 2 } as Author;\n      const update = { isDraft: true } as ResearchUpdate;\n\n      // act\n      const show = researchUpdateStatusFilter(author, null, update, user);\n\n      // assert\n      expect(show).toEqual(false);\n    });\n\n    it('should not show when draft and not authenticated', () => {\n      // prepare\n      const update = { isDraft: true } as ResearchUpdate;\n\n      // act\n      const show = researchUpdateStatusFilter(null, null, update, undefined);\n\n      // assert\n      expect(show).toEqual(false);\n    });\n\n    it('should show when not draft and not deleted', () => {\n      // prepare\n      const update = {\n        isDraft: false,\n      } as ResearchUpdate;\n\n      // act\n      const show = researchUpdateStatusFilter(null, null, update, undefined);\n\n      // assert\n      expect(show).toEqual(true);\n    });\n\n    it('should show when draft and current user is the author', () => {\n      // prepare\n      const author = { id: 1 } as Author;\n      const user = { id: 1 } as DBProfile;\n      const update = { isDraft: true } as ResearchUpdate;\n\n      // act\n      const show = researchUpdateStatusFilter(author, null, update, user);\n\n      // assert\n      expect(show).toEqual(true);\n    });\n\n    it('should show when draft and current user is a collaborator', () => {\n      // prepare\n      const collaborators = [{ id: 1 }] as Author[];\n      const user = { id: 1 } as DBProfile;\n      const update = { isDraft: true } as ResearchUpdate;\n\n      // act\n      const show = researchUpdateStatusFilter(null, collaborators, update, user);\n\n      // assert\n      expect(show).toEqual(true);\n    });\n\n    it('should show when draft and current user is an Admin', () => {\n      // prepare\n      const user = { id: 1, roles: [UserRole.ADMIN] } as DBProfile;\n      const update = { isDraft: true } as ResearchUpdate;\n\n      // act\n      const show = researchUpdateStatusFilter(null, null, update, user);\n\n      // assert\n      expect(show).toEqual(true);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/Research/researchHelpers.ts",
    "content": "import type { Author, DBProfile, ResearchItem, ResearchStatus, ResearchUpdate } from 'oa-shared';\nimport { UserRole } from 'oa-shared';\n\nexport const researchUpdateStatusFilter = (\n  author: Author | null,\n  collaborators: Author[] | null,\n  update: ResearchUpdate,\n  currentUser?: DBProfile,\n) => {\n  const isCollaborator =\n    currentUser?.id && collaborators && collaborators.map((x) => x.id).includes(currentUser.id);\n\n  const isAuthor = author?.id && author?.id === currentUser?.id;\n  const isAdmin = currentUser?.roles?.includes(UserRole.ADMIN);\n\n  return (isAdmin || isAuthor || isCollaborator || !update.isDraft) && !update.deleted;\n};\n\nexport const getPublicUpdates = (item: ResearchItem, currentUser?: DBProfile) => {\n  if (item.updates) {\n    return item.updates.filter((update) =>\n      researchUpdateStatusFilter(item.author, item.collaborators, update, currentUser),\n    );\n  } else {\n    return [];\n  }\n};\n\nexport const researchStatusColour = (researchStatus?: ResearchStatus): string => {\n  switch (researchStatus) {\n    case 'complete':\n      return 'betaGreen';\n    default:\n      return 'softblue';\n  }\n};\n"
  },
  {
    "path": "src/pages/SignUp/SignUpMessage.tsx",
    "content": "import { HeroBanner, Icon } from 'oa-components';\nimport { Box, Card, Flex, Heading, Text } from 'theme-ui';\n\nconst SignUpMessagePage = ({ email }) => {\n  return (\n    <Flex\n      sx={{\n        bg: 'inherit',\n        px: 2,\n        width: '100%',\n        maxWidth: '620px',\n        mx: 'auto',\n        mt: [5, 10],\n        mb: 3,\n      }}\n    >\n      <Flex sx={{ flexDirection: 'column', width: '100%' }}>\n        <HeroBanner type=\"email\" />\n        <Flex\n          sx={{\n            flexDirection: 'column',\n            transform: 'translateY(-50px)',\n          }}\n        >\n          <Box\n            sx={{\n              alignSelf: 'center',\n              border: '2px solid #000',\n              borderRadius: 25,\n              zIndex: 3,\n            }}\n          >\n            <Icon\n              glyph=\"star-active\"\n              size={60}\n              sx={{\n                backgroundColor: '#ffedd6',\n                border: '5px solid #fff',\n                borderRadius: 25,\n                padding: 2,\n              }}\n            />\n          </Box>\n          <Card sx={{ borderRadius: 3, transform: 'translateY(-25px)' }}>\n            <Flex\n              sx={{\n                padding: 4,\n                paddingTop: 6,\n                gap: 2,\n                flexDirection: 'column',\n              }}\n            >\n              <Flex\n                sx={{\n                  gap: 1,\n                  flexDirection: 'column',\n                  alignItems: 'center',\n                  textAlign: 'center',\n                }}\n              >\n                <Heading>Yay! Welcome to One Army!</Heading>\n              </Flex>\n              <Text sx={{ textAlign: 'center', color: 'grey' }}>\n                <p>\n                  Before you dive in, please confirm you email through the link we've sent to{' '}\n                  <Text\n                    sx={{\n                      background: 'linear-gradient(0deg, #ffe2e1 60%, #fff 40%)',\n                      paddingX: 1,\n                    }}\n                  >\n                    {email}\n                  </Text>\n                </p>\n              </Text>\n            </Flex>\n          </Card>\n        </Flex>\n      </Flex>\n    </Flex>\n  );\n};\n\nexport default SignUpMessagePage;\n"
  },
  {
    "path": "src/pages/User/constants.ts",
    "content": "export const MESSAGE_MIN_CHARACTERS = 20;\nexport const MESSAGE_MAX_CHARACTERS = 500;\n"
  },
  {
    "path": "src/pages/User/contact/UserContactFieldMessage.tsx",
    "content": "import { FieldTextarea } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { MESSAGE_MAX_CHARACTERS, MESSAGE_MIN_CHARACTERS } from 'src/pages/User/constants';\nimport { contact } from 'src/pages/User/labels';\nimport { minValue, required } from 'src/utils/validators';\nimport { Flex, Label } from 'theme-ui';\n\nexport const UserContactFieldMessage = () => {\n  const name = 'message';\n\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n      <Label htmlFor={name}>{`${contact.message.title} *`}</Label>\n      <Field\n        name={name}\n        placeholder={contact.message.placeholder}\n        minLength={MESSAGE_MIN_CHARACTERS}\n        maxLength={MESSAGE_MAX_CHARACTERS}\n        data-cy={name}\n        data-testid={name}\n        modifiers={{ capitalize: true, trim: true }}\n        component={FieldTextarea}\n        sx={{ backgroundColor: 'white' }}\n        validate={(value) => required(value) || minValue(MESSAGE_MIN_CHARACTERS)(value)}\n        validateFields={[]}\n        showCharacterCount\n      />\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/contact/UserContactFieldName.tsx",
    "content": "import { FieldInput } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { contact } from 'src/pages/User/labels';\nimport { required } from 'src/utils/validators';\nimport { Flex, Label } from 'theme-ui';\n\nexport const UserContactFieldName = () => {\n  const { title, placeholder } = contact.name;\n  const name = 'name';\n\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n      <Label htmlFor={name}>{title}</Label>\n      <Field\n        component={FieldInput}\n        data-cy={name}\n        data-testid={name}\n        name={name}\n        placeholder={placeholder}\n        sx={{ backgroundColor: 'white' }}\n        validate={required}\n        validateFields={[]}\n      />\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/contact/UserContactForm.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { Toaster } from 'sonner';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { contact } from '../labels';\nimport { UserContactForm } from './UserContactForm';\n\nimport type { Profile } from 'oa-shared';\n\nvi.mock('src/services/messageService', () => {\n  return {\n    messageService: {\n      sendMessage: () => Promise.resolve(new Response(null, { status: 200, statusText: 'ALLL good!' })),\n    },\n  };\n});\n\ndescribe('UserContactForm', () => {\n  const profileUser = FactoryUser({ isContactable: true });\n\n  it('sends an message to the store', async () => {\n    const user = userEvent.setup();\n\n    render(\n      <ProfileStoreProvider>\n        <Toaster />\n        <UserContactForm user={profileUser as Profile} />\n      </ProfileStoreProvider>,\n    );\n\n    await screen.findByText(`Send a message to ${profileUser.displayName}`);\n\n    await user.type(screen.getByTestId('name'), 'Bob');\n    await user.type(screen.getByTestId('message'), 'I need to learn about plastics');\n\n    const submitButton = screen.getByTestId('contact-submit');\n    await user.click(submitButton);\n    await screen.findByText(contact.successMessage);\n  });\n\n  it('renders nothing if not profile is not contactable', () => {\n    const uncontactable = FactoryUser({ isContactable: false });\n\n    const { container } = render(\n      <ProfileStoreProvider>\n        <UserContactForm user={uncontactable as Profile} />\n      </ProfileStoreProvider>,\n    );\n\n    expect(container.innerHTML).toBe('');\n  });\n});\n"
  },
  {
    "path": "src/pages/User/contact/UserContactForm.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Button } from 'oa-components';\nimport type { Profile } from 'oa-shared';\nimport { Form } from 'react-final-form';\nimport { useToast } from 'src/common/Toast';\nimport { UserContactFieldMessage, UserContactFieldName } from 'src/pages/User/contact';\nimport { contact } from 'src/pages/User/labels';\nimport { messageService } from 'src/services/messageService';\nimport { isUserContactable } from 'src/utils/helpers';\nimport { Box, Flex, Heading } from 'theme-ui';\n\ninterface Props {\n  user: Profile;\n}\n\nexport const UserContactForm = observer(({ user }: Props) => {\n  const toast = useToast();\n\n  if (!isUserContactable(user)) {\n    return null;\n  }\n\n  const buttonName = 'contact-submit';\n  const formId = 'contact-form';\n\n  const onSubmit = async (formValues, form) => {\n    const promise = messageService.sendMessage({\n      to: user.username!,\n      message: formValues.message,\n      name: formValues.name,\n    });\n\n    toast.promise(promise, {\n      loading: 'Sending your message...',\n      success: () => {\n        form.restart();\n        return contact.successMessage;\n      },\n      error: (error) => `Error: ${error.message}`,\n    });\n  };\n\n  return (\n    <Flex sx={{ flexDirection: 'column' }} data-cy=\"UserContactForm\">\n      <Heading as=\"h3\" variant=\"small\" mb={2}>\n        {`${contact.title} ${user.displayName}`}\n      </Heading>\n      <Form\n        onSubmit={onSubmit}\n        id={formId}\n        validateOnBlur\n        render={({ handleSubmit, submitting }) => {\n          return (\n            <form>\n              <Flex sx={{ flexDirection: 'column', gap: 3 }}>\n                <UserContactFieldName />\n                <UserContactFieldMessage />\n\n                <Box sx={{ flexSelf: 'flex-start' }}>\n                  <Button\n                    onClick={handleSubmit}\n                    data-cy={buttonName}\n                    data-testid={buttonName}\n                    variant=\"primary\"\n                    type=\"submit\"\n                    disabled={submitting}\n                    form={formId}\n                  >\n                    {contact.button}\n                  </Button>\n                </Box>\n              </Flex>\n            </form>\n          );\n        }}\n      />\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/User/contact/UserContactFormAvailable.tsx",
    "content": "import { Alert, Flex, Text } from 'theme-ui';\n\ninterface IProps {\n  isUserProfileContactable: boolean;\n}\n\nexport const UserContactFormAvailable = ({ isUserProfileContactable }: IProps) => {\n  return (\n    <Alert variant=\"info\">\n      <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n        {isUserProfileContactable ? (\n          <Text sx={{ textAlign: 'left' }} data-cy=\"UserContactForm-Available\">\n            Other users are able to contact you\n          </Text>\n        ) : (\n          <Text sx={{ textAlign: 'left' }} data-cy=\"UserContactForm-NotAvailable\">\n            Other users are not able to contact you\n          </Text>\n        )}\n        <Text sx={{ textAlign: 'left' }}>You can change that by editing your profile</Text>\n      </Flex>\n    </Alert>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/contact/UserContactFormNotLoggedIn.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Button } from 'oa-components';\nimport type { Profile } from 'oa-shared';\nimport { Form } from 'react-final-form';\nimport { useNavigate } from 'react-router';\nimport { UserContactFieldMessage, UserContactFieldName } from 'src/pages/User/contact';\nimport { contact } from 'src/pages/User/labels';\nimport { isUserContactable } from 'src/utils/helpers';\nimport { Box, Flex, Heading } from 'theme-ui';\n\ninterface Props {\n  user: Profile;\n}\n\nexport const UserContactFormNotLoggedIn = observer(({ user }: Props) => {\n  const navigate = useNavigate();\n\n  if (!isUserContactable(user)) {\n    return null;\n  }\n\n  const { button, title } = contact;\n  const buttonName = 'contact-submit';\n  const formId = 'contact-form';\n\n  return (\n    <Box sx={{ position: 'relative' }}>\n      <Box\n        sx={{\n          position: 'absolute',\n          inset: -1,\n          zIndex: 10,\n          backdropFilter: 'blur(6px)',\n          WebkitBackdropFilter: 'blur(6px)',\n          backgroundColor: 'rgba(160, 160, 160, 0.4)',\n          pointerEvents: 'auto',\n          borderRadius: 2,\n          border: '2px solid black',\n        }}\n      />\n      <Box\n        sx={{\n          position: 'absolute',\n          inset: 0,\n          zIndex: 20,\n          display: 'flex',\n          flexDirection: 'column',\n          justifyContent: 'center',\n          alignItems: 'center',\n          pointerEvents: 'auto',\n        }}\n      >\n        <Flex sx={{ gap: 4, alignItems: 'center' }}>\n          <Button variant=\"primary\" onClick={() => navigate('/sign-up')}>\n            Register\n          </Button>\n          or\n          <Button variant=\"secondary\" onClick={() => navigate('/sign-in')}>\n            Log In\n          </Button>\n        </Flex>\n        <Heading\n          as=\"h4\"\n          sx={{\n            padding: 3,\n          }}\n        >\n          To send a message.\n        </Heading>\n      </Box>\n      <Flex\n        sx={{ flexDirection: 'column', margin: 30, pointerEvents: 'none' }}\n        data-cy=\"UserContactNotLoggedIn\"\n      >\n        <Heading as=\"h3\" variant=\"small\" my={2}>\n          {`${title} ${user.displayName}`}\n        </Heading>\n        <Form\n          onSubmit={() => {}}\n          id={formId}\n          validateOnBlur\n          render={() => {\n            return (\n              <form>\n                <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n                  <UserContactFieldName />\n                  <UserContactFieldMessage />\n\n                  <Box sx={{ flexSelf: 'flex-start' }}>\n                    <Button\n                      large\n                      data-cy={buttonName}\n                      data-testid={buttonName}\n                      variant=\"primary\"\n                      type=\"submit\"\n                      form={formId}\n                    >\n                      {button}\n                    </Button>\n                  </Box>\n                </Flex>\n              </form>\n            );\n          }}\n        />\n      </Flex>\n    </Box>\n  );\n});\n"
  },
  {
    "path": "src/pages/User/contact/UserContactNotLoggedIn.tsx",
    "content": "import { Button, ReturnPathLink } from 'oa-components';\nimport { Alert, Flex, Text } from 'theme-ui';\n\ninterface Props {\n  displayName: string;\n}\n\nexport const UserContactNotLoggedIn = ({ displayName }: Props) => {\n  return (\n    <Alert variant=\"info\" data-cy=\"UserContactNotLoggedIn\">\n      <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n        <Text sx={{ textAlign: 'left' }}>\n          {`${displayName} would love to hear from you...but you're not logged in!`}\n        </Text>\n        <Text sx={{ textAlign: 'left' }}>If you were you'd able to send them a message...</Text>\n        <Flex sx={{ alignItems: 'center', flexDirection: 'row', gap: 2 }}>\n          <ReturnPathLink\n            to=\"/sign-in\"\n            style={{\n              textDecoration: 'underline',\n              color: 'inherit',\n            }}\n          >\n            Login\n          </ReturnPathLink>\n          <ReturnPathLink to=\"/sign-up\">\n            <Button type=\"button\" icon=\"star\">\n              Sign-up now\n            </Button>\n          </ReturnPathLink>\n        </Flex>\n      </Flex>\n    </Alert>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/contact/index.ts",
    "content": "export { UserContactFieldMessage } from './UserContactFieldMessage';\nexport { UserContactFieldName } from './UserContactFieldName';\nexport { UserContactFormAvailable } from './UserContactFormAvailable';\nexport { UserContactNotLoggedIn } from './UserContactNotLoggedIn';\n"
  },
  {
    "path": "src/pages/User/content/ProfileContact.tsx",
    "content": "import { ProfileLink } from 'oa-components';\nimport type { Profile } from 'oa-shared';\nimport { useContext } from 'react';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { UserAction } from 'src/common/UserAction';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { isUserContactable } from 'src/utils/helpers';\nimport { Box, Flex } from 'theme-ui';\nimport { UserContactFormAvailable } from '../contact';\nimport { UserContactForm } from '../contact/UserContactForm';\nimport { UserContactFormNotLoggedIn } from '../contact/UserContactFormNotLoggedIn';\n\ninterface IProps {\n  user: Profile;\n  isViewingOwnProfile: boolean;\n}\n\nexport const ProfileContact = ({ user, isViewingOwnProfile }: IProps) => {\n  const isUserProfileContactable = isUserContactable(user);\n  const tenantContext = useContext(TenantContext);\n  const shouldShowContactOutput = !tenantContext?.noMessaging;\n\n  return (\n    <Flex sx={{ flexDirection: 'column' }}>\n      {shouldShowContactOutput && (\n        <Box data-cy=\"UserContactWrapper\">\n          <ClientOnly fallback={<></>}>\n            {() => (\n              <UserAction\n                loggedIn={\n                  isViewingOwnProfile ? (\n                    <UserContactFormAvailable isUserProfileContactable={isUserProfileContactable} />\n                  ) : (\n                    <UserContactForm user={user} />\n                  )\n                }\n                loggedOut={\n                  isUserProfileContactable ? (\n                    <UserContactFormNotLoggedIn user={user} />\n                  ) : (\n                    <UserContactFormNotLoggedIn user={user} />\n                  )\n                }\n              />\n            )}\n          </ClientOnly>\n        </Box>\n      )}\n\n      {user.website && (\n        <Flex sx={{ flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>\n          <span>Website</span>\n          <ProfileLink url={user.website} />\n        </Flex>\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/content/ProfileDetails.tsx",
    "content": "import { Button, ProfileTagsList, UserStatistics, VisitorModal } from 'oa-components';\nimport type { MapPin, Profile, UserCreatedDocs } from 'oa-shared';\nimport { PremiumTier } from 'oa-shared';\nimport { useEffect, useMemo, useState } from 'react';\nimport { trackEvent } from 'src/common/Analytics';\nimport { DonationRequestModalContainer } from 'src/common/DonationRequestModalContainer';\nimport { PremiumTierWrapper } from 'src/common/PremiumTierWrapper';\nimport { mapPinService } from 'src/pages/Maps/map.service';\nimport { Box, Divider, Flex, Paragraph } from 'theme-ui';\n\ninterface IProps {\n  docs: UserCreatedDocs;\n  profile: Profile;\n  selectTab: (target: string) => void;\n}\n\nexport const ProfileDetails = ({ docs, profile, selectTab }: IProps) => {\n  const { about, tags, visitorPolicy } = profile;\n  const [showVisitorModal, setShowVisitorModal] = useState(false);\n  const [isDonationModalOpen, setIsDonationModalOpen] = useState(false);\n\n  const [pin, setPin] = useState<MapPin | undefined>(undefined);\n\n  useEffect(() => {\n    const getPin = async () => {\n      try {\n        const pin = await mapPinService.getMapPinById(profile.id);\n        if (pin) {\n          setPin(pin);\n        }\n      } catch (error) {\n        console.error(error);\n      }\n    };\n    getPin();\n  }, [profile.id]);\n\n  const hideVisitorDetails = (target?: string) => {\n    setShowVisitorModal(false);\n    if (target) {\n      selectTab(target);\n    }\n  };\n\n  const userTotalUseful = useMemo(() => {\n    if (!profile?.authorUsefulVotes) {\n      return 0;\n    }\n\n    return profile.authorUsefulVotes.reduce((sum, vote) => sum + vote.voteCount, 0);\n  }, [profile.authorUsefulVotes]);\n\n  return (\n    <Box style={{ height: '100%' }}>\n      <Flex\n        sx={{\n          alignItems: 'stretch',\n          flexDirection: ['column', 'row', 'row'],\n          gap: 4,\n          justifyContent: 'space-between',\n        }}\n      >\n        <Flex\n          sx={{\n            flexDirection: 'column',\n            flex: 1,\n            gap: 2,\n          }}\n        >\n          {(tags || visitorPolicy) && (\n            <ProfileTagsList\n              tags={tags || null}\n              visitorPolicy={visitorPolicy}\n              isSpace={profile.type?.isSpace || false}\n              showVisitorModal={() => setShowVisitorModal(true)}\n              large={true}\n            />\n          )}\n          {about && (\n            <Paragraph\n              sx={{\n                whiteSpace: 'pre-wrap',\n              }}\n            >\n              {about}\n            </Paragraph>\n          )}\n\n          {visitorPolicy && (\n            <VisitorModal show={showVisitorModal} hide={hideVisitorDetails} user={profile} />\n          )}\n        </Flex>\n        <Divider\n          sx={{\n            width: ['100%', '1px', '1px'],\n            height: ['1px', 'auto', 'auto'],\n            alignSelf: 'stretch',\n            border: ['none', '2px solid #0000001A', '2px solid #0000001A'],\n            borderTop: '2px solid #0000001A',\n            margin: 0,\n          }}\n        />\n        <Flex sx={{ flexDirection: 'column', gap: 4, alignItems: 'flex-start' }}>\n          {profile.type?.isSpace && profile?.donationsEnabled && (\n            <>\n              <DonationRequestModalContainer\n                profileId={profile?.id}\n                isOpen={isDonationModalOpen}\n                onDidDismiss={() => setIsDonationModalOpen(false)}\n              />\n              <Button\n                icon=\"donate\"\n                variant=\"outline\"\n                iconColor=\"primary\"\n                sx={{ backgroundColor: 'white', borderBottom: '4px solid' }}\n                onClick={() => {\n                  trackEvent({\n                    action: 'donationModalOpened',\n                    category: 'profiles',\n                    label: profile.username || undefined,\n                  });\n                  setIsDonationModalOpen(true);\n                }}\n              >\n                Support this {profile.type?.displayName || 'space'}\n              </Button>\n            </>\n          )}\n          <PremiumTierWrapper\n            tierRequired={PremiumTier.ONE}\n            fallback={\n              <UserStatistics\n                profile={profile}\n                pin={pin}\n                libraryCount={docs?.projects.length || 0}\n                usefulCount={userTotalUseful}\n                researchCount={docs?.research.length || 0}\n                questionCount={docs?.questions.length || 0}\n                showViews={false}\n              />\n            }\n          >\n            <UserStatistics\n              profile={profile}\n              pin={pin}\n              libraryCount={docs?.projects.length || 0}\n              usefulCount={userTotalUseful}\n              researchCount={docs?.research.length || 0}\n              questionCount={docs?.questions.length || 0}\n              showViews\n            />\n          </PremiumTierWrapper>\n        </Flex>\n      </Flex>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/content/ProfileHeader.tsx",
    "content": "import { MemberBadge, Username } from 'oa-components';\nimport type { Profile } from 'oa-shared';\nimport DefaultMemberImage from 'src/assets/images/default_member.svg';\nimport { Avatar, Box, Flex, Heading } from 'theme-ui';\n\ninterface IProps {\n  user: Profile;\n}\n\nexport const ProfileHeader = ({ user }: IProps) => {\n  const profileImageSrc = user.photo?.publicUrl ?? DefaultMemberImage;\n\n  return (\n    <Box sx={{ position: 'relative' }}>\n      {user.type?.isSpace && (\n        <Box\n          sx={{\n            display: 'block',\n            position: 'absolute',\n            top: 0,\n            right: 0,\n            transform: 'translateY(-50%)',\n          }}\n        >\n          <Box sx={{ display: ['none', 'none', 'block'] }}>\n            <MemberBadge size={150} profileType={user.type} />\n          </Box>\n          <Box sx={{ display: ['none', 'block', 'none'] }}>\n            <MemberBadge size={100} profileType={user.type} />\n          </Box>\n          <Box sx={{ display: ['block', 'none', 'none'] }}>\n            <MemberBadge size={75} profileType={user.type} />\n          </Box>\n        </Box>\n      )}\n      <Flex sx={{ gap: 2, alignItems: 'center', paddingBottom: [2, 4] }}>\n        {profileImageSrc && user.type?.isSpace && (\n          <Avatar\n            data-cy=\"userImage\"\n            src={profileImageSrc}\n            sx={{\n              objectFit: 'cover',\n              width: '50px',\n              height: '50px',\n            }}\n          />\n        )}\n\n        {!user.type?.isSpace && (\n          <Avatar\n            data-cy=\"profile-avatar\"\n            loading=\"lazy\"\n            src={profileImageSrc}\n            sx={{\n              objectFit: 'cover',\n              width: '120px',\n              height: '120px',\n            }}\n          />\n        )}\n        <Flex sx={{ flexDirection: 'column' }}>\n          <Username user={user} sx={{ alignSelf: 'flex-start' }} />\n          <Heading\n            as=\"h1\"\n            color={'black'}\n            style={{ wordBreak: 'break-word' }}\n            data-cy=\"userDisplayName\"\n          >\n            {user.displayName}\n          </Heading>\n        </Flex>\n      </Flex>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/content/ProfileImage.tsx",
    "content": "import { ImageGallery } from 'oa-components';\nimport type { Profile } from 'oa-shared';\nimport { formatImagesForGallery } from 'src/utils/formatImageListForGallery';\nimport { AspectRatio, Box, Flex } from 'theme-ui';\n\ninterface IProps {\n  user: Profile;\n}\n\nexport const ProfileImage = ({ user }: IProps) => {\n  if (!user.type?.isSpace) {\n    return null;\n  }\n\n  const getCoverImages = (user: Profile) => {\n    if (user.coverImages && user.coverImages.length) {\n      return user.coverImages;\n    }\n\n    return [];\n  };\n\n  const coverImage = getCoverImages(user);\n\n  return (\n    <Box>\n      {coverImage.length ? (\n        <ImageGallery\n          images={formatImagesForGallery(coverImage) as any}\n          hideThumbnails={true}\n          showNextPrevButton={true}\n        />\n      ) : (\n        <AspectRatio ratio={24 / 3}>\n          <Flex\n            sx={{\n              width: '100%',\n              height: '100%',\n              background: '#ddd',\n              justifyContent: 'center',\n              alignItems: 'center',\n            }}\n          >\n            No images available.\n          </Flex>\n        </AspectRatio>\n      )}\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/content/ProfilePage.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Button, ExternalLink, InternalLink } from 'oa-components';\nimport type { Profile, UserCreatedDocs } from 'oa-shared';\nimport { useMemo } from 'react';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { trackEvent } from 'src/common/Analytics';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { Flex, Image } from 'theme-ui';\nimport { UserProfile } from './UserProfile';\n\ninterface IProps {\n  profile: Profile;\n  userCreatedDocs: UserCreatedDocs;\n}\n\n/**\n * High level wrapper which loads state, then determines\n * whether to render a MemberProfile or SpaceProfile.\n */\nexport const ProfilePage = observer((props: IProps) => {\n  const { profile, userCreatedDocs } = props;\n  const { profile: activeUser, upgradeBadgeForCurrentUser } = useProfileStore();\n\n  const isViewingOwnProfile = useMemo(\n    () => activeUser?.username === profile?.username,\n    [activeUser?.username],\n  );\n  const showMemberProfile = !profile?.type?.isSpace;\n\n  const upgradeBadge = upgradeBadgeForCurrentUser;\n  const shouldShowUpgrade = upgradeBadge && isViewingOwnProfile;\n\n  return (\n    <Flex\n      sx={{\n        alignSelf: 'center',\n        maxWidth: showMemberProfile ? '42em' : '60em',\n        flexDirection: 'column',\n        width: '100%',\n        marginTop: isViewingOwnProfile ? 4 : [6, 8],\n      }}\n    >\n      <ClientOnly fallback={<></>}>\n        {() => (\n          <>\n            {isViewingOwnProfile && (\n              <Flex\n                sx={{\n                  alignSelf: ['center', 'flex-end'],\n                  marginBottom: 6,\n                  zIndex: 2,\n                  gap: 2,\n                  flexDirection: 'row',\n                }}\n              >\n                {shouldShowUpgrade && (\n                  <ExternalLink\n                    href={upgradeBadge.actionUrl}\n                    data-cy=\"UpgradeBadge\"\n                    onClick={() => {\n                      trackEvent({\n                        category: 'profiles',\n                        action: 'upgradeBadgeClicked',\n                        label: upgradeBadge.actionLabel,\n                      });\n                    }}\n                    sx={{ textDecoration: 'none' }}\n                  >\n                    <Button\n                      type=\"button\"\n                      sx={{\n                        backgroundColor: 'white',\n                      }}\n                    >\n                      <Flex sx={{ alignItems: 'center', gap: 1 }}>\n                        {upgradeBadge.badge?.imageUrl && (\n                          <Image\n                            src={upgradeBadge.badge.imageUrl}\n                            sx={{ height: 20, width: 20, flexShrink: 0 }}\n                            alt={upgradeBadge.badge.displayName || 'badge'}\n                          />\n                        )}\n                        {upgradeBadge.actionLabel}\n                      </Flex>\n                    </Button>\n                  </ExternalLink>\n                )}\n                <InternalLink to=\"/settings\">\n                  <Button type=\"button\" data-cy=\"EditYourProfile\">\n                    Edit Your Profile\n                  </Button>\n                </InternalLink>\n              </Flex>\n            )}\n          </>\n        )}\n      </ClientOnly>\n\n      <ClientOnly fallback={<></>}>\n        {() => (\n          <UserProfile\n            user={profile}\n            docs={userCreatedDocs}\n            isViewingOwnProfile={isViewingOwnProfile}\n          />\n        )}\n      </ClientOnly>\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/User/content/UserCreatedDocuments.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { MemoryRouter } from 'react-router';\nimport { render, screen } from '@testing-library/react';\nimport { describe, expect, it } from 'vitest';\n\nimport UserCreatedDocuments from './UserCreatedDocuments';\n\nimport type { Question } from 'oa-shared';\n\nconst mockQuestions: Partial<Question>[] = [\n  {\n    id: 1,\n    slug: 'question-doc-1',\n    title: 'Question Doc 1',\n    usefulCount: 2,\n  },\n];\n\ndescribe('UserCreatedDocuments', () => {\n  it('renders only questions section', () => {\n    render(\n      <MemoryRouter>\n        <UserCreatedDocuments docs={{ projects: [], research: [], questions: mockQuestions }} columns={2} />\n      </MemoryRouter>,\n    );\n    expect(screen.queryByText('Library')).not.toBeInTheDocument();\n    expect(screen.queryByText('Research')).not.toBeInTheDocument();\n    expect(screen.getByText('Questions')).toBeInTheDocument();\n    expect(screen.getByText('Question Doc 1')).toBeInTheDocument();\n  });\n\n  it(\"renders nothing if questions aren't present\", () => {\n    const { container } = render(\n      <MemoryRouter>\n        <UserCreatedDocuments docs={{ projects: [], research: [], questions: [] }} columns={2} />\n      </MemoryRouter>,\n    );\n    expect(container).toBeEmptyDOMElement();\n  });\n});\n"
  },
  {
    "path": "src/pages/User/content/UserCreatedDocuments.tsx",
    "content": "import type { UserCreatedDocs } from 'oa-shared';\nimport { Flex, Grid, Heading } from 'theme-ui';\nimport UserCreatedDocumentsItem from './UserCreatedDocumentsItem';\n\ninterface IProps {\n  columns: number;\n  docs: UserCreatedDocs;\n}\n\nconst UserCreatedDocuments = ({ columns, docs }: IProps) => {\n  return (\n    <>\n      {(docs.projects.length > 0 || docs.research.length > 0 || docs.questions.length > 0) && (\n        <Grid columns={[1, 1, columns]} gap={5}>\n          {docs?.projects.length > 0 && (\n            <Flex data-testid=\"library-contributions\" sx={{ flexDirection: 'column', gap: 2 }}>\n              <Heading as=\"h3\" variant=\"small\">\n                Library\n              </Heading>\n              {docs.projects.map((item) => {\n                return (\n                  <UserCreatedDocumentsItem\n                    key={item.id}\n                    type=\"library\"\n                    item={{\n                      id: item.id!,\n                      commentCount: item.commentCount,\n                      coverImage: item.coverImage || undefined,\n                      slug: item.slug!,\n                      title: item.title!,\n                      usefulCount: item.usefulCount || 0,\n                    }}\n                  />\n                );\n              })}\n            </Flex>\n          )}\n          {docs?.research.length > 0 && (\n            <Flex data-testid=\"research-contributions\" sx={{ flexDirection: 'column', gap: 2 }}>\n              <Heading as=\"h3\" variant=\"small\">\n                Research\n              </Heading>\n              {docs?.research.map((item) => {\n                return (\n                  <UserCreatedDocumentsItem\n                    key={item.id}\n                    type=\"research\"\n                    item={{\n                      id: item.id!,\n                      coverImage: item.image || undefined,\n                      slug: item.slug!,\n                      title: item.title!,\n                      usefulCount: item.usefulCount || 0,\n                    }}\n                  />\n                );\n              })}\n            </Flex>\n          )}\n          {docs?.questions.length > 0 && (\n            <Flex data-testid=\"question-contributions\" sx={{ flexDirection: 'column', gap: 2 }}>\n              <Heading as=\"h3\" variant=\"small\">\n                Questions\n              </Heading>\n              {docs?.questions.map((item) => {\n                return (\n                  <UserCreatedDocumentsItem\n                    key={item.id}\n                    type=\"questions\"\n                    item={{\n                      id: item.id!,\n                      commentCount: item.commentCount || 0,\n                      coverImage: item.images && item.images[0] ? item.images[0] : undefined,\n                      slug: item.slug!,\n                      title: item.title!,\n                      usefulCount: item.usefulCount || 0,\n                    }}\n                  />\n                );\n              })}\n            </Flex>\n          )}\n        </Grid>\n      )}\n    </>\n  );\n};\n\nexport default UserCreatedDocuments;\n"
  },
  {
    "path": "src/pages/User/content/UserCreatedDocumentsItem.tsx",
    "content": "import { IconCountWithTooltip } from 'oa-components';\nimport type { Image as ImageType } from 'oa-shared';\nimport { Link } from 'react-router';\nimport { Box, Flex, Image, Text } from 'theme-ui';\n\ninterface IProps {\n  type: 'library' | 'research' | 'questions';\n  item: {\n    id: string | number;\n    commentCount?: number;\n    coverImage?: ImageType;\n    title: string;\n    slug: string;\n    usefulCount: number;\n  };\n}\n\nconst UserDocumentItem = ({ type, item }: IProps) => {\n  const { id, commentCount, coverImage, slug, title, usefulCount } = item;\n  const url = `/${type}/${encodeURIComponent(slug)}?utm_source=user-profile`;\n\n  return (\n    <Flex\n      sx={{\n        background: 'white',\n        borderRadius: 2,\n      }}\n    >\n      <Link\n        to={url}\n        key={id}\n        style={{ width: '100%' }}\n        data-testid={`${type}-link`}\n        data-cy={`${item.slug}-link`}\n      >\n        <Flex\n          sx={{\n            flexDirection: 'row',\n            justifyItems: 'center',\n          }}\n        >\n          {coverImage && coverImage.publicUrl && (\n            <Box sx={{ width: '70px' }}>\n              <Image\n                data-cy={`UserDocumentItem: coverImage for ${title}`}\n                loading=\"lazy\"\n                src={coverImage.publicUrl}\n                sx={{\n                  borderRadius: 2,\n                  height: '100%',\n                  objectFit: 'cover',\n                }}\n                crossOrigin=\"\"\n                alt={`Cover image for ${title}`}\n              />\n            </Box>\n          )}\n\n          <Flex\n            sx={{\n              flexDirection: 'row',\n              justifyItems: 'center',\n              alignItems: 'center',\n              justifyContent: 'space-between',\n              flex: 1,\n              padding: 3,\n              gap: 2,\n            }}\n          >\n            <Text\n              as=\"p\"\n              color=\"black\"\n              sx={{\n                textOverflow: 'ellipsis',\n                whiteSpace: 'nowrap',\n                overflow: 'hidden',\n              }}\n            >\n              {title}\n            </Text>\n            <Flex\n              sx={{\n                alignItems: 'center',\n                justifyContent: 'flex-end',\n              }}\n            >\n              <Flex\n                sx={{\n                  flex: 1,\n                  gap: 3,\n                  justifyContent: 'flex-end',\n                }}\n              >\n                <IconCountWithTooltip count={usefulCount} icon=\"star-active\" text=\"Useful count\" />\n                <IconCountWithTooltip\n                  count={commentCount || 0}\n                  icon=\"comment\"\n                  text=\"Comment count\"\n                />\n              </Flex>\n            </Flex>\n          </Flex>\n        </Flex>\n      </Link>\n    </Flex>\n  );\n};\n\nexport default UserDocumentItem;\n"
  },
  {
    "path": "src/pages/User/content/UserProfile.tsx",
    "content": "import { MemberBadge, MemberHistory, Tab, TabPanel, Tabs, TabsList } from 'oa-components';\nimport type { Profile, UserCreatedDocs } from 'oa-shared';\nimport { PremiumTier } from 'oa-shared';\nimport { useContext, useState } from 'react';\nimport { useLocation } from 'react-router';\nimport { PremiumTierWrapper } from 'src/common/PremiumTierWrapper';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { isContactable } from 'src/utils/helpers';\nimport { Alert, Box, Card, Flex } from 'theme-ui';\nimport { Impact } from '../impact/Impact';\nimport { heading } from '../impact/labels';\nimport { ProfileContact } from './ProfileContact';\nimport { ProfileDetails } from './ProfileDetails';\nimport { ProfileHeader } from './ProfileHeader';\nimport { ProfileImage } from './ProfileImage';\nimport UserCreatedDocuments from './UserCreatedDocuments';\n\ninterface IProps {\n  docs: UserCreatedDocs;\n  isViewingOwnProfile: boolean;\n  user: Profile;\n}\n\nexport const UserProfile = ({ docs, isViewingOwnProfile, user }: IProps) => {\n  const { about, impact, type, tags } = user;\n  const location = useLocation();\n  const { isComplete } = useProfileStore();\n  const tenantContext = useContext(TenantContext);\n\n  const isMember = !type?.isSpace;\n  const hasContactOption =\n    (!tenantContext?.noMessaging && isContactable(user.isContactable)) || !!user.website;\n  const hasContributed = docs?.projects.length + docs?.research.length + docs?.questions.length > 0;\n  const hasImpacted = !!impact;\n  const hasProfile = about || (tags && Object.keys(tags).length !== 0) || hasContributed;\n\n  const showEmptyProfileAlert = isViewingOwnProfile && isComplete === false;\n\n  const defaultValue = location?.hash?.slice(1) || (hasProfile ? 'profile' : 'contact');\n\n  const [selectedTab, setSelectedTab] = useState(defaultValue);\n\n  return (\n    <Flex\n      data-cy={isMember ? 'MemberProfile' : 'SpaceProfile'}\n      sx={{\n        width: '100%',\n        height: '100%',\n        flexDirection: 'column',\n      }}\n    >\n      {isMember && (\n        <MemberBadge\n          profileType={type || undefined}\n          size={50}\n          sx={{\n            alignSelf: 'center',\n            position: 'absolute',\n            transform: 'translateY(-25px)',\n          }}\n          useLowDetailVersion\n        />\n      )}\n      <Card variant=\"responsive\" sx={{ borderRadius: [3, 3, 3] }}>\n        <ProfileImage user={user} />\n        <Flex\n          sx={{\n            borderTop: isMember ? '' : '2px solid',\n            flexDirection: 'column',\n            gap: 4,\n            padding: [2, 4],\n          }}\n        >\n          {showEmptyProfileAlert && (\n            <Alert variant=\"info\" data-cy=\"emptyProfileMessage\">\n              Oh hey! Your profile is looking SO empty. Fancy filling it in...?\n            </Alert>\n          )}\n\n          <Box sx={{ width: '100%' }}>\n            <ProfileHeader user={user} />\n\n            <Tabs\n              value={selectedTab}\n              onChange={(_: any, value: string | number | null) => {\n                typeof value === 'string' && setSelectedTab(value);\n              }}\n            >\n              <TabsList>\n                {hasProfile && <Tab value=\"profile\">Profile</Tab>}\n                {hasContributed && (\n                  <Tab data-cy=\"ContribTab\" value=\"contributions\">\n                    Contributions\n                  </Tab>\n                )}\n                {hasImpacted && tenantContext?.showImpact && (\n                  <Tab data-cy=\"ImpactTab\" value=\"impact\">\n                    {heading}\n                  </Tab>\n                )}\n                {hasContactOption && (\n                  <Tab data-cy=\"contact-tab\" value=\"contact\">\n                    Contact\n                  </Tab>\n                )}\n              </TabsList>\n              <TabPanel value=\"profile\">\n                <ProfileDetails docs={docs} profile={user} selectTab={setSelectedTab} />\n              </TabPanel>\n              {hasContributed && (\n                <TabPanel value=\"contributions\">\n                  <UserCreatedDocuments columns={isMember ? 1 : 2} docs={docs} />\n                </TabPanel>\n              )}\n              {hasImpacted && tenantContext?.showImpact && (\n                <TabPanel value=\"impact\">\n                  <Impact impact={impact} user={user} />\n                </TabPanel>\n              )}\n              {hasContactOption && (\n                <TabPanel value=\"contact\">\n                  <ProfileContact user={user} isViewingOwnProfile={isViewingOwnProfile} />\n                </TabPanel>\n              )}\n            </Tabs>\n          </Box>\n          <PremiumTierWrapper tierRequired={PremiumTier.ONE}>\n            <MemberHistory memberSince={user.createdAt} lastActive={user.lastActive} />\n          </PremiumTierWrapper>\n        </Flex>\n      </Card>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/impact/Impact.test.tsx",
    "content": "import { createMemoryRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router';\nimport { render, screen } from '@testing-library/react';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { IMPACT_YEARS } from './constants';\nimport { Impact } from './Impact';\nimport { invisible, missing } from './labels';\n\nimport type { Profile } from 'oa-shared';\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: () => ({\n    profile: FactoryUser({\n      username: 'activeUser',\n    }),\n  }),\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\ndescribe('Impact', () => {\n  describe('[public]', () => {\n    it('renders all fields with data formatted correctly', async () => {\n      const impact = {\n        2022: [\n          {\n            id: 'plastic',\n            value: 23000,\n            isVisible: true,\n          },\n          {\n            id: 'revenue',\n            value: 54000,\n            isVisible: true,\n          },\n          {\n            id: 'volunteers',\n            value: 45,\n            isVisible: true,\n          },\n          {\n            id: 'machines',\n            value: 13,\n            isVisible: false,\n          },\n        ],\n        2021: [\n          {\n            id: 'machines',\n            value: 8,\n            isVisible: false,\n          },\n        ],\n      };\n\n      render(\n        <ProfileStoreProvider>\n          <Impact impact={impact} user={undefined} />\n        </ProfileStoreProvider>,\n      );\n\n      await screen.findByText('45 volunteers');\n      await screen.findByText('23,000 Kg of plastic recycled');\n      await screen.findByText('USD 54,000 revenue');\n      const machineField = screen.queryByText('13 machines built');\n      expect(machineField).toBe(null);\n\n      for (const year of IMPACT_YEARS) {\n        await screen.findByText(year);\n      }\n\n      await screen.findAllByText(missing.user.label);\n      await screen.findAllByText(invisible.user.label);\n    });\n  });\n\n  describe('[page owner]', () => {\n    it('renders all fields with data formatted correctly', async () => {\n      const impact = {\n        2022: [\n          {\n            id: 'volunteers',\n            value: 45,\n            isVisible: true,\n          },\n        ],\n        2021: [\n          {\n            id: 'volunteers',\n            value: 45,\n            isVisible: false,\n          },\n        ],\n      };\n      const user = FactoryUser({ impact, username: 'activeUser' });\n\n      const router = createMemoryRouter(\n        createRoutesFromElements(\n          <Route\n            index\n            element={\n              <ProfileStoreProvider>\n                <Impact impact={impact} user={user as Profile} />\n              </ProfileStoreProvider>\n            }\n          ></Route>,\n        ),\n      );\n\n      const screen = render(<RouterProvider router={router} />);\n\n      await screen.findAllByText(missing.owner.label);\n      await screen.findAllByText(invisible.owner.label);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/User/impact/Impact.tsx",
    "content": "import type { IUserImpact, Profile } from 'oa-shared';\nimport { Flex } from 'theme-ui';\nimport { IMPACT_YEARS } from './constants';\nimport { ImpactItem } from './ImpactItem';\n\ninterface Props {\n  impact: IUserImpact | null;\n  user: Profile | undefined;\n}\n\nexport const Impact = (props: Props) => {\n  const impact = props.impact || [];\n\n  const renderByYear = IMPACT_YEARS.map((year, index) => {\n    const foundYear = impact ? Object.keys(impact).find((key) => Number(key) === year) : undefined;\n\n    return (\n      <ImpactItem\n        fields={foundYear && impact && impact[foundYear]}\n        year={year}\n        key={index}\n        user={props.user}\n      />\n    );\n  });\n\n  return (\n    <Flex sx={{ flexFlow: 'row wrap' }} data-cy=\"ImpactPanel\">\n      {renderByYear.map((year) => {\n        return year;\n      })}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/impact/ImpactField.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport { describe, expect, it } from 'vitest';\n\nimport { ImpactField } from './ImpactField';\n\ndescribe('ImpactField', () => {\n  it('renders field with expected question data', async () => {\n    const field = {\n      id: 'plastic',\n      value: 23000,\n      isVisible: true,\n    };\n\n    render(<ImpactField field={field} />);\n\n    await screen.findByText('23,000 Kg of plastic recycled');\n  });\n\n  it('renders nothing when field is not set to be visible', () => {\n    const field = {\n      id: 'plastic',\n      value: 3,\n      isVisible: false,\n    };\n\n    const { container } = render(<ImpactField field={field} />);\n\n    expect(container.innerHTML).toBe('');\n  });\n\n  it(\"renders nothing when field isn't found in question data\", () => {\n    const field = {\n      id: 'nothing',\n      value: 3,\n      isVisible: true,\n    };\n\n    const { container } = render(<ImpactField field={field} />);\n\n    expect(container.innerHTML).toBe('');\n  });\n});\n"
  },
  {
    "path": "src/pages/User/impact/ImpactField.tsx",
    "content": "import type { IImpactDataField } from 'oa-shared';\nimport { impactQuestions } from 'src/pages/UserSettings/content/impactQuestions';\nimport { numberWithCommas } from 'src/utils/helpers';\nimport { Flex, Text } from 'theme-ui';\nimport { ImpactIcon } from './ImpactIcon';\n\ninterface Props {\n  field: IImpactDataField;\n}\n\nexport const ImpactField = ({ field }: Props) => {\n  const { id, isVisible, value } = field;\n\n  const impactQuestion = impactQuestions.find((question) => question.id === id);\n  if (!impactQuestion || !isVisible) return null;\n\n  const sx = {\n    alignItems: 'center',\n    backgroundColor: 'background',\n    borderRadius: 1,\n    gap: 1,\n    padding: 1,\n  };\n\n  const prefix = impactQuestion?.prefix || '';\n  const suffix = impactQuestion?.suffix || '';\n  const label = impactQuestion.label;\n  const text = `${prefix} ${numberWithCommas(value)} ${suffix} ${label}`;\n\n  return (\n    <Flex sx={sx}>\n      <ImpactIcon id={id} />\n      <Text variant=\"label\">{text}</Text>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/impact/ImpactIcon.tsx",
    "content": "import { Icon } from 'oa-components';\nimport type { IImpactDataField } from 'oa-shared';\nimport { impactQuestions } from 'src/pages/UserSettings/content/impactQuestions';\n\ninterface Props {\n  id: IImpactDataField['id'];\n}\n\nexport const ImpactIcon = ({ id }: Props) => {\n  const question = impactQuestions.find((question) => question.id === id);\n\n  if (!question || !question.icon) return null;\n\n  const glyph = question.icon;\n\n  return <Icon glyph={glyph as any} />;\n};\n"
  },
  {
    "path": "src/pages/User/impact/ImpactItem.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { ImpactItem } from './ImpactItem';\n\nimport type { Profile } from 'oa-shared';\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: () => ({\n    profile: FactoryUser({\n      username: 'activeUser',\n    }),\n  }),\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\ndescribe('ImpactItem', () => {\n  it('renders an impact item with visible fields for a specific year, with impact fields in order: plastic, revenue, employees, volunteers, machines ', async () => {\n    const user = FactoryUser({ username: 'activeUser' });\n    const fields = [\n      {\n        id: 'machines',\n        value: 15,\n        isVisible: true,\n      },\n      {\n        id: 'revenue',\n        value: 54000,\n        isVisible: true,\n      },\n      { id: 'plastic', value: 30000, isVisible: true },\n    ];\n    render(\n      <ProfileStoreProvider>\n        <ImpactItem fields={fields} user={user as Profile} year={2022} />\n      </ProfileStoreProvider>,\n    );\n    const plasticItem = await screen.findByText('30,000 Kg of plastic recycled');\n    const revenueItem = await screen.findByText('USD 54,000 revenue');\n\n    expect(plasticItem.compareDocumentPosition(revenueItem)).toBe(4);\n  });\n});\n"
  },
  {
    "path": "src/pages/User/impact/ImpactItem.tsx",
    "content": "import type { IImpactDataField, IImpactYear, Profile } from 'oa-shared';\nimport { sortImpactYearDisplayFields } from 'src/pages/UserSettings/utils';\nimport { Box, Flex, Heading } from 'theme-ui';\nimport { ImpactField } from './ImpactField';\nimport { ImpactMissing } from './ImpactMissing';\n\ninterface Props {\n  year: IImpactYear;\n  fields: IImpactDataField[] | undefined;\n  user: Profile | undefined;\n}\n\nexport const ImpactItem = ({ fields, user, year }: Props) => {\n  const outterBox = {\n    flexBasis: ['100%', '100%', '50%'],\n    padding: 2,\n  };\n\n  const innerBox = {\n    backgroundColor: 'white',\n    borderRadius: 1,\n    height: '100%',\n    padding: 2,\n  };\n\n  const sortedFields = sortImpactYearDisplayFields(fields);\n  const visibleFields = sortedFields?.filter((field) => field.isVisible);\n\n  return (\n    <Box sx={outterBox} cy-data={`ImpactItem-${year}`}>\n      <Box sx={innerBox}>\n        <Heading as=\"h3\" variant=\"small\">\n          {year}\n        </Heading>\n        {visibleFields && visibleFields.length > 0 ? (\n          <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n            {visibleFields.map((field, index) => {\n              return <ImpactField field={field} key={index} />;\n            })}\n          </Flex>\n        ) : (\n          <ImpactMissing fields={fields} owner={user} visibleFields={visibleFields} year={year} />\n        )}\n      </Box>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/User/impact/ImpactMissing.test.tsx",
    "content": "import { createMemoryRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router';\nimport { render, screen } from '@testing-library/react';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { describe, it, vi } from 'vitest';\n\nimport { ImpactMissing } from './ImpactMissing';\nimport { invisible, missing, reportYearLabel } from './labels';\n\nimport type { Profile } from 'oa-shared';\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: vi.fn(() => ({\n    profile: FactoryUser({ username: 'activeUser' }),\n  })),\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\ndescribe('ImpactMissing', () => {\n  describe('[public]', () => {\n    it('renders message that impact is missing', async () => {\n      render(\n        <ProfileStoreProvider>\n          <ImpactMissing\n            fields={undefined}\n            owner={undefined}\n            year={2023}\n            visibleFields={undefined}\n          />\n        </ProfileStoreProvider>,\n      );\n\n      await screen.findByText(missing.user.label);\n    });\n\n    it('renders right message and button for impact report year', async () => {\n      render(\n        <ProfileStoreProvider>\n          <ImpactMissing\n            fields={undefined}\n            owner={undefined}\n            year={2022}\n            visibleFields={undefined}\n          />\n        </ProfileStoreProvider>,\n      );\n\n      await screen.findByText(reportYearLabel);\n      await screen.findByText(`2022 ${missing.user.link}`);\n    });\n\n    it('renders message that all data is invisible', async () => {\n      const fields = [\n        {\n          id: 'volunteers',\n          value: 45,\n          isVisible: true,\n        },\n      ];\n\n      render(\n        <ProfileStoreProvider>\n          <ImpactMissing fields={fields} owner={undefined} year={2022} visibleFields={[]} />\n        </ProfileStoreProvider>,\n      );\n      await screen.findByText(invisible.user.label);\n    });\n  });\n\n  describe('[page owner]', () => {\n    it('renders right message for impact owner', async () => {\n      const user = FactoryUser({ username: 'activeUser' }) as Profile;\n      const router = createMemoryRouter(\n        createRoutesFromElements(\n          <Route\n            index\n            element={\n              <ImpactMissing\n                fields={undefined}\n                owner={user}\n                year={2023}\n                visibleFields={undefined}\n              />\n            }\n          />,\n        ),\n      );\n\n      const container = render(\n        <ProfileStoreProvider>\n          <RouterProvider router={router} />\n        </ProfileStoreProvider>,\n      );\n\n      await container.findByText(missing.owner.label);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/User/impact/ImpactMissing.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Button, ExternalLink } from 'oa-components';\nimport type { IImpactDataField, IImpactYear, Profile } from 'oa-shared';\nimport { Link } from 'react-router';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { Flex, Text } from 'theme-ui';\nimport { IMPACT_REPORT_LINKS } from './constants';\nimport { invisible, missing, reportYearLabel } from './labels';\n\ninterface Props {\n  fields: IImpactDataField[] | undefined;\n  owner: Profile | undefined;\n  visibleFields: IImpactDataField[] | undefined;\n  year: IImpactYear;\n}\n\nconst isAllInvisible = (fields?: IImpactDataField[], visibleFields?: IImpactDataField[]) => {\n  if (visibleFields && visibleFields.length === 0 && fields && fields.length > 0) {\n    return true;\n  }\n\n  return false;\n};\n\nconst isPageOwnerCheck = (activeUser?: Profile, owner?: Profile) => {\n  const usersPresent = activeUser && owner;\n  const usersTheSame = activeUser?.username === owner?.username;\n\n  return usersPresent && usersTheSame ? true : false;\n};\n\nexport const ImpactMissing = observer((props: Props) => {\n  const { fields, owner, visibleFields, year } = props;\n  const { profile } = useProfileStore();\n\n  const labelSet = isAllInvisible(fields, visibleFields) ? invisible : missing;\n\n  const isPageOwner = isPageOwnerCheck(profile, owner);\n  const isReportYear = IMPACT_REPORT_LINKS[year] ? true : false;\n\n  const button = `${year} ${labelSet.user.link}`;\n  const label = isPageOwner ? labelSet.owner.label : labelSet.user.label;\n\n  return (\n    <Flex sx={{ flexFlow: 'column', gap: 2, mt: 2 }}>\n      <Text>{label}</Text>\n      {!isPageOwner && isReportYear && (\n        <>\n          <Text>{reportYearLabel}</Text>\n          <ExternalLink href={IMPACT_REPORT_LINKS[year]}>\n            <Button type=\"button\">{button}</Button>\n          </ExternalLink>\n        </>\n      )}\n      {isPageOwner && (\n        <Link to={`/settings/impact/#year_${year}`}>\n          <Button type=\"button\">{labelSet.owner.link}</Button>\n        </Link>\n      )}\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/User/impact/constants.ts",
    "content": "import type { IImpactYear } from 'oa-shared';\n\nexport const IMPACT_REPORT_LINKS = {\n  2024: 'https://www.preciousplastic.com/impact/2025',\n  2023: 'https://www.preciousplastic.com/impact/2024',\n  2022: 'https://www.preciousplastic.com/impact/2023',\n  2019: 'https://www.preciousplastic.com/impact',\n};\n\nexport const IMPACT_YEARS: IImpactYear[] = [2025, 2024, 2023, 2022, 2021, 2020, 2019];\n"
  },
  {
    "path": "src/pages/User/impact/labels.ts",
    "content": "export const heading = 'Impact';\n\nexport const invisible = {\n  user: {\n    label:\n      \"Their data is part of the global impact report (but they'd rather keep the specifics private).\",\n    link: 'Impact Report',\n  },\n  owner: {\n    label: \"You've set all your data for this year to be private.\",\n    link: 'Update impact data',\n  },\n};\n\nexport const missing = {\n  user: {\n    label: \"Looks like they haven't shared their Impact Data yet.\",\n    link: 'Impact Report',\n  },\n  owner: {\n    label: \"Looks like you haven't added your Impact Data yet.\",\n    link: 'Enter impact data',\n  },\n};\n\nexport const reportYearLabel = 'In the meantime, check out the global report! :)';\n"
  },
  {
    "path": "src/pages/User/labels.ts",
    "content": "import { MESSAGE_MAX_CHARACTERS } from './constants';\n\nexport const contact = {\n  button: 'Send Message',\n  email: {\n    title: 'Email (currently fixed to your email on record)',\n    placeholder: 'hey@jack.com',\n  },\n  title: 'Send a message to',\n  message: {\n    title: `Message (max ${MESSAGE_MAX_CHARACTERS} characters)`,\n    placeholder: 'What do you want to say?',\n  },\n  name: {\n    title: 'Name',\n    placeholder: \"What's your name?\",\n  },\n  successMessage: 'All sent',\n};\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsFormTab.tsx",
    "content": "import { TabPanel } from '@mui/base/TabPanel';\nimport { Card } from '@theme-ui/components';\n\nimport type { ISettingsTab } from './types';\n\ninterface IProps {\n  tab: ISettingsTab;\n  value: string;\n}\n\nexport const SettingsFormTab = (props: IProps) => {\n  const { tab, value } = props;\n\n  const sx = {\n    borderRadius: 3,\n    marginBottom: 4,\n    padding: [2, 4],\n    overflow: 'visible',\n  };\n\n  return (\n    <TabPanel\n      value={value}\n      style={{ display: 'flex', flexDirection: 'column', alignSelf: 'stretch' }}\n    >\n      {tab.header && (\n        <Card sx={{ ...sx, backgroundColor: 'softblue', padding: [3, 5] }}>{tab.header}</Card>\n      )}\n      <Card sx={sx}>\n        <tab.body />\n      </Card>\n    </TabPanel>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsFormTabList.tsx",
    "content": "import styled from '@emotion/styled';\nimport { Tab as BaseTab, tabClasses } from '@mui/base/Tab';\nimport { TabsList as BaseTabsList } from '@mui/base/TabsList';\nimport { prepareForSlot } from '@mui/base/utils';\nimport { Icon, InternalLink, Select } from 'oa-components';\nimport { useMemo } from 'react';\nimport { Flex } from 'theme-ui';\n\nimport type { ISettingsTab } from './types';\n\nconst Tab = styled(BaseTab)`\n  color: grey;\n  cursor: pointer;\n  background-color: transparent;\n  padding: 12px 18px;\n  outline: none;\n  border-radius: 12px;\n  display: flex;\n  gap: 8px;\n  justify-content: flex-start;\n  font-size: 18px;\n  font-family: Varela round;\n  align-items: center;\n\n  &:hover {\n    background-color: white;\n  }\n\n  &:focus {\n    outline: 2px solid #666;\n  }\n\n  &.${tabClasses.disabled} {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  &.${tabClasses.selected} {\n    color: #1b1b1b;\n    outline: 2px solid #1b1b1b;\n    background-color: #e2edf7;\n  }\n`;\n\nconst TabsList = styled(BaseTabsList)`\n  width: 100%;\n  display: flex;\n  gap: 12px;\n  flex-direction: column;\n  justify-content: flex-start;\n  align-content: flex-start;\n`;\n\ninterface IProps {\n  currentTab: string;\n  tabs: ISettingsTab[];\n  onTabChange: (path: string) => void;\n}\n\nexport const SettingsFormTabList = (props: IProps) => {\n  const { currentTab, tabs, onTabChange } = props;\n\n  const currentValue = useMemo(\n    () => ({\n      label: tabs.find(({ route }) => route === currentTab)?.title || '',\n      value: currentTab,\n    }),\n    [currentTab, tabs],\n  );\n\n  const selectOptions = useMemo(\n    () =>\n      tabs.map(({ title, route }) => ({\n        label: title,\n        value: route,\n      })),\n    [tabs],\n  );\n\n  if (tabs.length === 1) return null;\n\n  return (\n    <>\n      <Flex sx={{ display: ['none', 'flex'] }}>\n        <TabsList>\n          {tabs.map(({ glyph, title, route }) => {\n            return (\n              <Tab\n                key={title}\n                to={route}\n                value={route}\n                data-cy={`tab-${title}`}\n                slots={{ root: prepareForSlot(InternalLink) }}\n              >\n                <Icon glyph={glyph} size={20} /> {title}\n              </Tab>\n            );\n          })}\n        </TabsList>\n      </Flex>\n\n      <Flex sx={{ display: ['flex', 'none'] }}>\n        <TabsList>\n          <Select\n            value={currentValue}\n            onChange={(event) => onTabChange(event.value)}\n            variant=\"tabs\"\n            options={selectOptions}\n          />\n        </TabsList>\n      </Flex>\n    </>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsPage.client.tsx",
    "content": "import { Tabs } from '@mui/base/Tabs';\nimport { observer } from 'mobx-react';\nimport type { availableGlyphs } from 'oa-components';\nimport { useContext, useMemo } from 'react';\nimport { useLocation, useNavigate } from 'react-router';\nimport { isModuleSupported, MODULE } from 'src/modules';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { Box, Flex, Text } from 'theme-ui';\nimport { TenantContext } from '../common/TenantContext';\nimport { SettingsFormTab } from './SettingsFormTab';\nimport { SettingsFormTabList } from './SettingsFormTabList';\nimport { SettingsPageAccount } from './SettingsPageAccount';\nimport { SettingsPageImpact } from './SettingsPageImpact';\nimport { SettingsPageMapPin } from './SettingsPageMapPin';\nimport { SettingsPageNotifications } from './SettingsPageNotifications';\nimport { SettingsPageUserProfile } from './SettingsPageUserProfile';\nimport type { ISettingsTab } from './types';\n\nimport '../../styles/leaflet.css';\n\nexport const SettingsPage = observer(() => {\n  const tenantContext = useContext(TenantContext);\n  const { isComplete, missingFields, profile } = useProfileStore();\n  const navigate = useNavigate();\n  const { pathname } = useLocation();\n\n  const isMember = !profile?.type?.isSpace;\n  const showImpactTab = !isMember && tenantContext?.showImpact;\n  const showMapTab = isModuleSupported(tenantContext?.supportedModules || '', MODULE.MAP);\n\n  const tabs: ISettingsTab[] = useMemo(\n    () => [\n      {\n        title: 'Profile',\n        route: '/settings/profile',\n        header: isComplete === false && (\n          <Flex sx={{ gap: 2, flexDirection: 'column' }} data-cy=\"CompleteProfileHeader\">\n            <Text as=\"h3\">✏️ Complete your profile</Text>\n            <Text>\n              In order to post comments or create content, we'd like you to share something about\n              yourself.\n            </Text>\n            {missingFields && missingFields.length > 0 && (\n              <Text>\n                Missing required fields:\n                <ul style={{ margin: '0.5em 0 0 0', paddingLeft: '1.5em' }}>\n                  {missingFields.map((field) => (\n                    <li key={field}>{field}</li>\n                  ))}\n                </ul>\n              </Text>\n            )}\n          </Flex>\n        ),\n        body: SettingsPageUserProfile,\n        glyph: 'profile' as availableGlyphs,\n      },\n      ...(showMapTab\n        ? [\n            {\n              title: 'Map',\n              route: '/settings/map',\n              body: SettingsPageMapPin,\n              glyph: 'map' as availableGlyphs,\n            },\n          ]\n        : []),\n      ...(showImpactTab\n        ? [\n            {\n              title: 'Impact',\n              route: '/settings/impact',\n              body: SettingsPageImpact,\n              glyph: 'impact' as availableGlyphs,\n            },\n          ]\n        : []),\n      {\n        title: 'Notifications',\n        route: '/settings/notifications',\n        body: SettingsPageNotifications,\n        glyph: 'megaphone' as availableGlyphs,\n      },\n      {\n        title: 'Account',\n        route: '/settings/account',\n        body: SettingsPageAccount,\n        glyph: 'account' as availableGlyphs,\n      },\n    ],\n    [showMapTab, showImpactTab, isComplete, missingFields],\n  );\n\n  if (!profile) {\n    return null;\n  }\n\n  return (\n    <Box\n      sx={{\n        maxWidth: '1000px',\n        width: '100%',\n        alignSelf: 'center',\n        paddingTop: [3, 5, 10],\n      }}\n    >\n      <Tabs value={pathname}>\n        <Flex\n          sx={{\n            alignContent: 'stretch',\n            alignSelf: 'stretch',\n            justifyContent: 'stretch',\n            flexDirection: ['column', 'row'],\n            gap: 4,\n          }}\n        >\n          <SettingsFormTabList\n            tabs={tabs}\n            currentTab={pathname}\n            onTabChange={(path) => navigate(path)}\n          />\n          <Flex\n            sx={{\n              alignContent: 'stretch',\n              justifyContent: 'stretch',\n              flexDirection: 'column',\n              flex: 1,\n            }}\n          >\n            {tabs.map((tab) => (\n              <SettingsFormTab key={tab.title} value={tab.route} tab={tab} />\n            ))}\n          </Flex>\n        </Flex>\n      </Tabs>\n    </Box>\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsPageAccount.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { ExternalLink } from 'oa-components';\nimport { DISCORD_INVITE_URL } from 'src/constants';\nimport { fields, headings } from 'src/pages/UserSettings/labels';\nimport { Flex, Heading, Text } from 'theme-ui';\n\nimport { PatreonIntegration } from './content/fields/PatreonIntegration';\nimport { ChangeEmailForm } from './content/sections/ChangeEmail.form';\nimport { ChangePasswordForm } from './content/sections/ChangePassword.form';\n\nexport const SettingsPageAccount = observer(() => {\n  const { description, title } = fields.deleteAccount;\n\n  return (\n    <Flex\n      sx={{\n        justifyContent: 'space-between',\n        flexDirection: 'column',\n        gap: 4,\n      }}\n    >\n      <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n        <Heading as=\"h2\">{headings.accountSettings}</Heading>\n        <Text variant=\"quiet\">Here you can manage the core settings of your account.</Text>\n      </Flex>\n\n      <PatreonIntegration />\n      <ChangePasswordForm />\n      <ChangeEmailForm />\n\n      <Text variant=\"body\">\n        {title}{' '}\n        <ExternalLink sx={{ ml: 1, textDecoration: 'underline' }} href={DISCORD_INVITE_URL}>\n          {description}\n        </ExternalLink>\n      </Text>\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsPageImpact.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { act, waitFor } from '@testing-library/react';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { FormProvider } from './__mocks__/FormProvider';\nimport { SettingsPageImpact } from './SettingsPageImpact';\n\nimport type { ProfileType } from 'oa-shared';\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: () => ({\n    profile: FactoryUser({\n      type: {\n        id: 1,\n        displayName: 'space',\n        name: 'space',\n        isSpace: true,\n      } as ProfileType,\n      impact: {\n        2023: [\n          {\n            id: 'plastic',\n            value: 43000,\n            isVisible: true,\n          },\n          {\n            id: 'volunteers',\n            value: 45,\n            isVisible: false,\n          },\n        ],\n      },\n    }),\n  }),\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\ndescribe('SettingsPageImpact', () => {\n  it('renders existing and missing impact', async () => {\n    // Act\n    let wrapper;\n    act(() => {\n      wrapper = FormProvider(<SettingsPageImpact />);\n    });\n\n    await waitFor(() => {\n      expect(wrapper.getAllByText('43,000 Kg of plastic', { exact: false })).toHaveLength(1);\n      expect(wrapper.getAllByText('45 volunteers', { exact: false })).toHaveLength(1);\n\n      expect(wrapper.getAllByText('Edit data', { exact: false })).toHaveLength(7);\n      expect(wrapper.getAllByText('Do you have impact data for this year?')).toHaveLength(6);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsPageImpact.tsx",
    "content": "import { IMPACT_YEARS } from 'src/pages/User/impact/constants';\nimport { ImpactYearSection } from 'src/pages/UserSettings/content/sections/ImpactYear.section';\nimport { fields } from 'src/pages/UserSettings/labels';\nimport { Box, Flex, Heading, Text } from 'theme-ui';\n\nexport const SettingsPageImpact = () => {\n  const { description, title } = fields.impact;\n\n  return (\n    <Flex\n      sx={{\n        justifyContent: 'space-between',\n        flexDirection: 'column',\n        gap: 4,\n      }}\n    >\n      <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n        <Heading as=\"h2\">{title}</Heading>\n        <Text variant=\"quiet\">{description}</Text>\n      </Flex>\n\n      <Box>\n        {IMPACT_YEARS.map((year) => {\n          return <ImpactYearSection year={year} key={year} />;\n        })}\n      </Box>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsPageMapPin.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\nimport { act, waitFor } from '@testing-library/react';\nimport { FactoryMapPin } from 'src/test/factories/MapPin';\nimport { factoryImage, FactoryUser } from 'src/test/factories/User';\nimport { describe, expect, it, vi } from 'vitest';\nimport { FormProvider } from './__mocks__/FormProvider';\nimport { SettingsPageMapPin } from './SettingsPageMapPin';\nimport type { PinProfile, ProfileType } from 'oa-shared';\n\nconst completeProfile = {\n  about: 'A member',\n  displayName: 'Jeffo',\n  website: 'www.example.com',\n  type: {\n    id: 1,\n    name: 'member',\n    isSpace: false,\n  } as ProfileType,\n  photo: factoryImage,\n};\nconst mockUseProfileStore = vi.hoisted(() => vi.fn());\nconst mockGetMapPinById = vi.hoisted(() => vi.fn());\nconst mockGetCurrentUserMapPin = vi.hoisted(() => vi.fn());\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: mockUseProfileStore,\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('../Maps/map.service', () => ({\n  mapPinService: {\n    getMapPinById: mockGetMapPinById,\n    getCurrentUserMapPin: mockGetCurrentUserMapPin,\n  },\n}));\n\ndescribe('SettingsPageMapPin', () => {\n  it('renders for no pin', async () => {\n    const mockUser = FactoryUser(completeProfile);\n    mockUseProfileStore.mockReturnValue({\n      profile: mockUser,\n      update: vi.fn(),\n      isComplete: true,\n    });\n    mockGetCurrentUserMapPin.mockResolvedValue(null);\n\n    // Act\n    let wrapper;\n    act(() => {\n      wrapper = FormProvider(<SettingsPageMapPin />);\n    });\n\n    await waitFor(() => {\n      expect(wrapper.getAllByTestId('NoLocationDataTextDisplay')).toHaveLength(1);\n      expect(wrapper.queryAllByTestId('LocationDataTextDisplay')).toHaveLength(0);\n    });\n  });\n\n  it('renders for member', async () => {\n    const mockUser = FactoryUser(completeProfile);\n    mockUseProfileStore.mockReturnValue({\n      profile: mockUser,\n      update: vi.fn(),\n    });\n    mockGetCurrentUserMapPin.mockResolvedValue(null);\n\n    // Act\n    let wrapper;\n    act(() => {\n      wrapper = FormProvider(<SettingsPageMapPin />);\n    });\n\n    await waitFor(() => {\n      expect(wrapper.getAllByTestId('descriptionMember')).toHaveLength(1);\n      expect(wrapper.queryAllByTestId('descriptionSpace')).toHaveLength(0);\n    });\n  });\n\n  it('renders for space', async () => {\n    const moderationFeedback = 'Need a better name';\n    const mockUser = FactoryUser({\n      about: 'An important space',\n      displayName: 'Jeffo',\n      type: {\n        id: 1,\n        name: 'community-builder',\n        isSpace: true,\n      } as ProfileType,\n      coverImages: [factoryImage],\n    });\n    const mockPin = FactoryMapPin({\n      country: 'Portugal',\n      countryCode: 'pt',\n      name: 'Super cool place',\n      moderation: 'improvements-needed',\n      moderationFeedback,\n      profile: mockUser as PinProfile,\n    });\n\n    mockUseProfileStore.mockReturnValue({\n      profile: mockUser,\n      update: vi.fn(),\n      isComplete: true,\n    });\n    mockGetCurrentUserMapPin.mockResolvedValue(mockPin); // Mock a pin for a space\n\n    // Act\n    let wrapper;\n    act(() => {\n      wrapper = FormProvider(<SettingsPageMapPin />);\n    });\n\n    await waitFor(() => {\n      expect(wrapper.queryAllByTestId('descriptionMember')).toHaveLength(0);\n      expect(wrapper.getAllByTestId('descriptionSpace')).toHaveLength(1);\n      expect(wrapper.getAllByText(mockPin.name, { exact: false })).toHaveLength(1);\n      expect(wrapper.getAllByText(moderationFeedback, { exact: false })).toHaveLength(1);\n    });\n  });\n\n  it('renders for user with incomplete profile', async () => {\n    const mockUser = FactoryUser({\n      displayName: 'Jeffo',\n      type: {\n        id: 1,\n        name: 'member',\n        isSpace: false,\n      } as ProfileType,\n      photo: factoryImage,\n    });\n    mockUseProfileStore.mockReturnValue({\n      profile: mockUser,\n      update: vi.fn(),\n      isComplete: false,\n    });\n    mockGetCurrentUserMapPin.mockResolvedValue(null);\n\n    let wrapper;\n    act(() => {\n      wrapper = FormProvider(<SettingsPageMapPin />);\n    });\n\n    await waitFor(() => {\n      expect(wrapper.queryAllByTestId('IncompleteProfileTextDisplay')).toHaveLength(1);\n      expect(wrapper.queryAllByTestId('complete-profile-button')).toHaveLength(1);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsPageMapPin.tsx",
    "content": "import type { DivIcon, Map as LeafletMap } from 'leaflet';\nimport { observer } from 'mobx-react';\nimport { Button, ConfirmModal, FlagIcon, Icon, MapWithPin, ModerationRecord } from 'oa-components';\nimport type { ILatLng, MapPin } from 'oa-shared';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { Field, Form } from 'react-final-form';\nimport { Link, useNavigate } from 'react-router';\nimport { useToast } from 'src/common/Toast';\nimport { buttons, headings, inCompleteProfile, mapForm } from 'src/pages/UserSettings/labels';\nimport { profileService } from 'src/services/profileService';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { getLocationData } from 'src/utils/getLocationData';\nimport { Alert, Card, Flex, Heading, Text } from 'theme-ui';\nimport { createMarkerIcon } from '../Maps/Content/MapView/Sprites';\nimport { mapPinService } from '../Maps/map.service';\n\nexport const SettingsPageMapPin = observer(() => {\n  const [mapPin, setMapPin] = useState<MapPin | undefined>();\n  const [previewMapPin, setPreviewMapPin] = useState<MapPin | undefined>();\n  const [markerIcon, setMarkerIcon] = useState<DivIcon>();\n  const [showConfirmModal, setShowConfirmModal] = useState(false);\n  const newMapRef = useRef<LeafletMap>(null);\n  const navigate = useNavigate();\n  const toast = useToast();\n\n  const { profile, isComplete } = useProfileStore();\n\n  const isMember = !profile?.type?.isSpace;\n  const formId = 'MapSection';\n\n  const initialValues = useMemo<{ location: ILatLng | null }>(() => {\n    if (!mapPin) {\n      return { location: null };\n    }\n    return {\n      location: {\n        lat: mapPin?.lat,\n        lng: mapPin?.lng,\n      },\n    };\n  }, [mapPin]);\n\n  useEffect(() => {\n    const init = async () => {\n      const pin = await mapPinService.getCurrentUserMapPin();\n\n      if (pin) {\n        setMapPin(pin);\n        setMarkerIcon(createMarkerIcon(pin, true));\n      }\n    };\n\n    init();\n  }, []);\n\n  const onSubmit = async (obj: { location: ILatLng }) => {\n    const pinData = await getLocationData(obj.location);\n    const promise = profileService.upsertPin(pinData);\n\n    toast.promise(promise, {\n      loading: 'Saving your map pin...',\n      success: (newPin) => {\n        setMapPin(newPin);\n        setPreviewMapPin(undefined);\n\n        return 'Map pin saved!';\n      },\n      error: (error) => `Error: ${error.message}`,\n    });\n  };\n\n  const onSubmitDelete = async () => {\n    const promise = profileService.deletePin();\n    toast.promise(promise, {\n      loading: 'Deleting your map pin...',\n      success: () => {\n        setMapPin(undefined);\n        setShowConfirmModal(false);\n\n        return 'Map pin deleted!';\n      },\n      error: (error) => `Error: ${error.message}`,\n    });\n  };\n\n  if (!profile) {\n    return null;\n  }\n\n  return (\n    <Flex\n      sx={{\n        flexDirection: 'column',\n        alignItems: 'stretch',\n        gap: 4,\n      }}\n    >\n      <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n        <Heading as=\"h2\" id=\"your-map-pin\">\n          {headings.map.yourPinTitle}\n        </Heading>\n        {isMember && (\n          <Text variant=\"quiet\" data-cy=\"descriptionMember\" data-testid=\"descriptionMember\">\n            {mapForm.descriptionMember}\n          </Text>\n        )}\n\n        {!isMember && mapPin?.moderation !== 'accepted' && (\n          <Text variant=\"quiet\" data-cy=\"descriptionSpace\" data-testid=\"descriptionSpace\">\n            {mapForm.descriptionSpace}\n          </Text>\n        )}\n      </Flex>\n\n      {isComplete ? (\n        <Form\n          id={formId}\n          onSubmit={onSubmit}\n          initialValues={initialValues}\n          render={({ values, submitting, handleSubmit }) => {\n            return (\n              <>\n                {!mapPin && (\n                  <Text\n                    variant=\"paragraph\"\n                    sx={{ fontStyle: 'italic' }}\n                    data-cy=\"NoLocationDataTextDisplay\"\n                    data-testid=\"NoLocationDataTextDisplay\"\n                  >\n                    {mapForm.noLocationLabel}\n                  </Text>\n                )}\n\n                {mapPin && mapPin.moderation !== 'accepted' && (\n                  <Alert variant=\"warning\" sx={{ gap: 1 }}>\n                    <Text sx={{ fontSize: 1 }}>\n                      Your pin status is {ModerationRecord[mapPin.moderation].toLowerCase()}\n                    </Text>\n                    {mapPin.moderationFeedback && (\n                      <>\n                        {' - '}\n                        <Text sx={{ fontSize: 1 }}>\n                          Moderator feedback:{' '}\n                          <Text sx={{ fontWeight: 'bold' }}>{mapPin.moderationFeedback}</Text>\n                        </Text>\n                      </>\n                    )}\n                  </Alert>\n                )}\n\n                <Field\n                  name=\"location\"\n                  render={({ input }) => {\n                    const { onChange, value } = input;\n\n                    return (\n                      <MapWithPin\n                        mapRef={newMapRef}\n                        position={{ lat: value.lat, lng: value.lng }}\n                        updatePosition={async (newPosition: ILatLng) => {\n                          onChange(newPosition);\n                          const data = await getLocationData(newPosition);\n                          const previewPin = {\n                            ...data,\n                            lat: newPosition.lat,\n                            lng: newPosition.lng,\n                            moderation: 'accepted',\n                            profile: profile as any,\n                          } as MapPin;\n                          setPreviewMapPin(previewPin);\n                        }}\n                        markerIcon={markerIcon}\n                        zoom={2}\n                        center={[0, 0]}\n                      />\n                    );\n                  }}\n                />\n\n                {(mapPin || previewMapPin) && (\n                  <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n                    {mapPin && (\n                      <Flex\n                        sx={{ gap: 1 }}\n                        variant=\"paragraph\"\n                        data-cy=\"LocationDataTextDisplay\"\n                        data-testid=\"LocationDataTextDisplay\"\n                      >\n                        {mapForm.locationLabel}\n                        <Flex sx={{ gap: 1, alignItems: 'center' }}>\n                          <FlagIcon countryCode={mapPin.countryCode} />\n                          {mapPin.name}\n                        </Flex>\n                      </Flex>\n                    )}\n\n                    {previewMapPin && (\n                      <Flex sx={{ gap: 1 }}>\n                        <Text variant=\"paragraph\">Your updated map pin:</Text>\n                        <Text\n                          variant=\"paragraph\"\n                          data-cy=\"LocationDataTextDisplay\"\n                          data-testid=\"LocationDataTextDisplay\"\n                        >\n                          <Flex sx={{ gap: 1, alignItems: 'center' }}>\n                            <FlagIcon countryCode={previewMapPin.countryCode} />\n                            {previewMapPin.name ||\n                              previewMapPin.administrative ||\n                              previewMapPin.country}\n                          </Flex>\n                        </Text>\n                      </Flex>\n                    )}\n                  </Flex>\n                )}\n                <Flex sx={{ gap: 2 }}>\n                  <Button\n                    type=\"submit\"\n                    form={formId}\n                    data-cy=\"save-map-pin\"\n                    variant=\"primary\"\n                    onClick={handleSubmit}\n                    disabled={!values.location || submitting}\n                    sx={{ alignSelf: 'flex-start' }}\n                  >\n                    {buttons.editPin}\n                  </Button>\n\n                  {mapPin && (\n                    <Button\n                      type=\"button\"\n                      onClick={() => setShowConfirmModal(true)}\n                      data-cy=\"remove-map-pin\"\n                      variant=\"destructive\"\n                      sx={{ alignSelf: 'flex-start' }}\n                      icon=\"delete\"\n                    >\n                      {buttons.removePin}\n                    </Button>\n                  )}\n\n                  {mapPin?.moderation === 'accepted' && (\n                    <Button\n                      onClick={() => navigate(`/map#${mapPin.profile!.username}`)}\n                      sx={{ alignSelf: 'flex-start' }}\n                      icon=\"map\"\n                      variant=\"secondary\"\n                    >\n                      See your pin on the map\n                    </Button>\n                  )}\n                </Flex>\n\n                {mapPin && (\n                  <ConfirmModal\n                    isOpen={showConfirmModal}\n                    message={mapForm.confirmDeletePin}\n                    confirmButtonText={buttons.removePin}\n                    handleCancel={() => setShowConfirmModal(false)}\n                    handleConfirm={onSubmitDelete}\n                    width={450}\n                    confirmVariant=\"destructive\"\n                  />\n                )}\n              </>\n            );\n          }}\n        />\n      ) : (\n        <Card variant=\"borderless\" bg=\"#e3edf6\" sx={{ borderRadius: '5px' }}>\n          <Flex\n            sx={{\n              flexDirection: 'column',\n              gap: '2',\n              padding: '20px',\n            }}\n          >\n            <Text\n              variant=\"paragraph\"\n              data-cy=\"IncompleteProfileTextDisplay\"\n              data-testid=\"IncompleteProfileTextDisplay\"\n              sx={{ fontSize: 1 }}\n            >\n              {inCompleteProfile}\n            </Text>\n            <Link\n              to=\"/settings\"\n              data-testid=\"complete-profile-button\"\n              data-cy=\"complete-profile-button\"\n            >\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                data-cy=\"mapPinPage\"\n                backgroundColor=\"white\"\n                sx={{ borderRadius: 3 }}\n              >\n                <Flex sx={{ gap: 2 }}>\n                  <Icon glyph={'profile'} size={20} />\n                  <Text variant=\"paragraph\">Complete your profile</Text>\n                </Flex>\n              </Button>\n            </Link>\n          </Flex>\n        </Card>\n      )}\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsPageNotifications.tsx",
    "content": "import { fields } from 'src/pages/UserSettings/labels';\nimport { Flex, Heading, Text } from 'theme-ui';\n\nimport { SupabaseNotifications } from './SupabaseNotifications';\n\nexport const SettingsPageNotifications = () => {\n  const { description, title } = fields.emailNotifications;\n\n  return (\n    <Flex\n      sx={{\n        flexDirection: 'column',\n        alignItems: 'stretch',\n        gap: 4,\n      }}\n    >\n      <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n        <Heading as=\"h2\">{title}</Heading>\n        <Text variant=\"quiet\">{description}</Text>\n      </Flex>\n\n      <SupabaseNotifications />\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsPageUserProfile.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\nimport { faker } from '@faker-js/faker';\nimport { act, waitFor } from '@testing-library/react';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport { TenantContext, TenantSettingsContext } from '../common/TenantContext';\nimport { FormProvider } from './__mocks__/FormProvider';\nimport { SettingsPageUserProfile } from './SettingsPageUserProfile';\nimport { UserRole, type ProfileTag, type ProfileType } from 'oa-shared';\n\nconst mockUseProfileStore = vi.hoisted(() => vi.fn());\n\nconst mockProfileTypes = [\n  {\n    id: 1,\n    name: 'member',\n    isSpace: false,\n    description: 'Member',\n    displayName: 'Member',\n    imageUrl: '',\n    mapPinName: '',\n    order: 1,\n    smallImageUrl: '',\n  },\n  {\n    id: 2,\n    name: 'collection-point',\n    isSpace: true,\n    description: 'Collection Point',\n    displayName: 'Collection Point',\n    imageUrl: '',\n    mapPinName: '',\n    order: 2,\n    smallImageUrl: '',\n  },\n];\n\nvi.mock('src/stores/Profile/profile.store', () => ({\n  useProfileStore: mockUseProfileStore,\n  ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nvi.mock('src/services/profileTypesService', () => ({\n  profileTypesService: {\n    getProfileTypes: vi.fn().mockResolvedValue(mockProfileTypes),\n  },\n}));\n\nvi.mock('src/services/profileTagsService', () => ({\n  profileTagsService: {\n    getAllTags: vi.fn().mockResolvedValue([\n      { id: 1, name: 'member', profileType: 'member' },\n      { id: 2, name: 'space', profileType: 'space' },\n    ] as ProfileTag[]),\n  },\n}));\n\ndescribe('UserSettings', () => {\n  beforeEach(() => {\n    vi.resetAllMocks();\n  });\n\n  it('renders fields for member', async () => {\n    const mockUser = FactoryUser({\n      type: {\n        id: 1,\n        name: 'Member',\n        isSpace: false,\n      } as ProfileType,\n    });\n    mockUseProfileStore.mockReturnValue({\n      profile: mockUser,\n      profileTypes: mockProfileTypes,\n      update: vi.fn(),\n      isUserAuthorized: vi.fn().mockReturnValue(false),\n    });\n\n    // Act\n    let wrapper;\n    act(() => {\n      wrapper = FormProvider(<SettingsPageUserProfile />);\n    });\n\n    await waitFor(() => {\n      expect(wrapper.getAllByTestId('UserInfosSection')).toHaveLength(1);\n      expect(wrapper.getAllByTestId('PublicContactSection')).toHaveLength(1);\n      expect(wrapper.getAllByTestId('photo')).toHaveLength(1);\n      expect(wrapper.queryByTestId('coverImages')).toBeNull();\n    });\n  });\n\n  it('renders fields for collection point', async () => {\n    const avatarUrl = faker.image.avatar();\n    const mockUser = FactoryUser({\n      type: {\n        id: 2,\n        name: 'collection-point',\n        description: 'Collection Point',\n        displayName: 'Collection Point',\n        imageUrl: '',\n        mapPinName: '',\n        order: 1,\n        smallImageUrl: '',\n        isSpace: true,\n      } as ProfileType,\n      coverImages: [\n        {\n          id: '123',\n          path: avatarUrl,\n          fullPath: avatarUrl,\n          publicUrl: avatarUrl,\n        },\n      ],\n    });\n    mockUseProfileStore.mockReturnValue({\n      profile: mockUser,\n      profileTypes: mockProfileTypes,\n      update: vi.fn(),\n      isUserAuthorized: vi.fn().mockReturnValue(false),\n    });\n\n    // Act\n    let wrapper;\n    act(() => {\n      wrapper = FormProvider(<SettingsPageUserProfile />);\n    });\n\n    await waitFor(() => {\n      expect(wrapper.getAllByTestId('UserInfosSection')).toHaveLength(1);\n      expect(wrapper.getAllByTestId('PublicContactSection')).toHaveLength(1);\n      expect(wrapper.getAllByTestId('photo')).toHaveLength(1);\n      expect(wrapper.getAllByTestId('coverImages')).toHaveLength(1);\n    });\n  });\n\n  it('hides PublicContactSection when noMessaging is true', async () => {\n    const mockUser = FactoryUser({\n      type: {\n        id: 1,\n        name: 'Member',\n        isSpace: false,\n      } as ProfileType,\n    });\n\n    mockUseProfileStore.mockReturnValue({\n      profile: mockUser,\n      profileTypes: mockProfileTypes,\n      update: vi.fn(),\n      isUserAuthorized: vi.fn().mockReturnValue(false),\n    });\n\n    const mockTenantContext: TenantSettingsContext = {\n      patreonId: '',\n      siteName: 'Test Site',\n      siteDescription: '',\n      siteUrl: 'https://test.com',\n      messageSignOff: 'Test',\n      emailFrom: 'test@test.com',\n      siteImage: 'test.png',\n      noMessaging: true,\n      libraryHeading: 'Library',\n      academyResource: 'Academy',\n      profileGuidelines: 'Guidelines',\n      questionsGuidelines: 'Questions',\n      supportedModules: 'modules',\n      colorPrimary: '#fee77b',\n      colorPrimaryHover: '#ffde45',\n      colorAccent: '#fee77b',\n      colorAccentHover: '#ffde45',\n      gaTrackingId: 'mock-ga-tracking-id',\n      showImpact: true,\n      createResearchRoles: [UserRole.ADMIN, UserRole.RESEARCH_CREATOR],\n      environment: {},\n    };\n\n    let wrapper;\n    act(() => {\n      wrapper = FormProvider(\n        <TenantContext.Provider value={mockTenantContext}>\n          <SettingsPageUserProfile />\n        </TenantContext.Provider>,\n      );\n    });\n\n    await waitFor(() => {\n      expect(wrapper.getAllByTestId('UserInfosSection')).toHaveLength(1);\n      expect(wrapper.queryByTestId('PublicContactSection')).toBeNull();\n      expect(wrapper.getAllByTestId('photo')).toHaveLength(1);\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/UserSettings/SettingsPageUserProfile.tsx",
    "content": "import arrayMutators from 'final-form-arrays';\nimport { toJS } from 'mobx';\nimport { observer } from 'mobx-react';\nimport { Button, Loader } from 'oa-components';\nimport type { ProfileFormData } from 'oa-shared';\nimport { useContext, useMemo } from 'react';\nimport { Form } from 'react-final-form';\nimport { UnsavedChangesDialog } from 'src/common/Form/UnsavedChangesDialog';\nimport { useToast } from 'src/common/Toast';\nimport { profileService } from 'src/services/profileService';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { isContactable } from 'src/utils/helpers';\nimport { Flex } from 'theme-ui';\nimport { TenantContext } from '../common/TenantContext';\nimport { ProfileTypeSection } from './content/sections/ProfileType.section';\nimport { PublicContactSection } from './content/sections/PublicContact.section';\nimport { UserImagesSection } from './content/sections/UserImages.section';\nimport { UserInfosSection } from './content/sections/UserInfos.section';\nimport { VisitorSection } from './content/sections/VisitorSection';\nimport { buttons } from './labels';\n\nexport const SettingsPageUserProfile = observer(() => {\n  const toast = useToast();\n  const tenantContext = useContext(TenantContext);\n  const profileStore = useProfileStore();\n  const { profile, profileTypes } = profileStore;\n\n  if (!profile) {\n    return null;\n  }\n\n  const saveProfile = async (values: ProfileFormData) => {\n    values.coverImages = values.coverImages?.filter((cover) => !!cover) || [];\n\n    if (values.username && values.username !== profile?.username) {\n      const usernamePromise = profileService.updateUsername(values.username);\n      toast.promise(usernamePromise, {\n        loading: 'Updating username...',\n        success: (usernameResult) => {\n          profileStore.update(usernameResult);\n          return 'Username updated!';\n        },\n        error: (error) => `Error: ${error.message}`,\n      });\n    }\n\n    const profilePromise = profileService.update(values);\n\n    toast.promise(profilePromise, {\n      loading: 'Updating profile...',\n      success: (updatedProfile) => {\n        profileStore.update(updatedProfile);\n        profileStore.refresh();\n        return 'Profile updated!';\n      },\n      error: (error) => `Error: ${error.message}`,\n    });\n  };\n\n  const coverImages = profile.coverImages\n    ? profile.coverImages?.slice(0, 4).map((image) => toJS(image))\n    : [];\n\n  const initialValues = useMemo<ProfileFormData>(\n    () =>\n      ({\n        username: profile.username || '',\n        type: profile.type?.name || 'member',\n        displayName: profile.displayName || '',\n        about: profile.about || '',\n        isContactable: isContactable(profile.isContactable),\n        coverImages,\n        photo: profile.photo ? toJS(profile.photo) : undefined,\n        country: profile.country,\n        showVisitorPolicy: !!profile.visitorPolicy,\n        visitorPreferencePolicy: profile.visitorPolicy?.policy || 'open',\n        visitorPreferenceDetails: profile.visitorPolicy?.details,\n        website: profile.website || '',\n        tagIds: profile.tags?.map((x) => x.id) || null,\n      }) satisfies ProfileFormData,\n    [],\n  );\n\n  const formId = 'userProfileForm';\n\n  return (\n    <Form\n      id={formId}\n      onSubmit={async (values) => await saveProfile(values)}\n      initialValues={initialValues}\n      mutators={{ ...arrayMutators }}\n      validateOnBlur\n      render={({\n        dirty,\n        submitting,\n        submitSucceeded,\n        values,\n        handleSubmit,\n        invalid,\n        errors,\n        form,\n      }) => {\n        const isMember = !profileTypes?.find((x) => x.name === values.type)?.isSpace;\n\n        return (\n          <Flex sx={{ flexDirection: 'column', gap: 4 }}>\n            <UnsavedChangesDialog hasChanges={dirty && !submitSucceeded} />\n            {submitting && <Loader sx={{ alignSelf: 'center' }} />}\n            <form id={formId} onSubmit={handleSubmit}>\n              <Flex sx={{ flexDirection: 'column', gap: [4, 6] }}>\n                <ProfileTypeSection profileTypes={profileTypes || []} />\n                <UserInfosSection formValues={values} />\n                <UserImagesSection isMemberProfile={isMember} values={values} form={form} />\n\n                {!isMember && (\n                  <VisitorSection\n                    visitorPolicy={\n                      values.showVisitorPolicy\n                        ? {\n                            policy: values.visitorPreferencePolicy,\n                            details: values.visitorPreferenceDetails,\n                          }\n                        : undefined\n                    }\n                  />\n                )}\n\n                {!tenantContext?.noMessaging && (\n                  <PublicContactSection isContactable={values.isContactable} />\n                )}\n              </Flex>\n            </form>\n\n            <Button\n              large\n              form={formId}\n              data-cy=\"save\"\n              title={invalid ? `Errors: ${Object.keys(errors || {})}` : 'Submit'}\n              onClick={() => window.scrollTo(0, 0)}\n              variant=\"primary\"\n              type=\"submit\"\n              disabled={submitting}\n              sx={{ alignSelf: 'flex-start' }}\n            >\n              {buttons.save}\n            </Button>\n          </Flex>\n        );\n      }}\n    />\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/SupabaseNotifications.tsx",
    "content": "import { observer } from 'mobx-react';\nimport type { DBNotificationsPreferences } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { useToast } from 'src/common/Toast/useToast';\nimport { form } from 'src/pages/UserSettings/labels';\nimport { notificationsPreferencesService } from 'src/services/notificationsPreferencesService';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { isUserContactable } from 'src/utils/helpers';\nimport { SupabaseNotificationsForm } from './SupabaseNotificationsForm';\n\nexport const SupabaseNotifications = observer(() => {\n  const [isLoading, setIsLoading] = useState(true);\n  const [initialValues, setInitialValues] = useState<DBNotificationsPreferences | null>(null);\n  const toast = useToast();\n\n  const { profile } = useProfileStore();\n\n  const refreshPreferences = async () => {\n    const preferences = await notificationsPreferencesService.getPreferences();\n    setInitialValues(preferences);\n    setIsLoading(false);\n  };\n\n  useEffect(() => {\n    refreshPreferences();\n  }, []);\n\n  const onSubmit = async (values: DBNotificationsPreferences) => {\n    const promise = notificationsPreferencesService.setPreferences(values);\n\n    toast.promise(promise, {\n      loading: 'Saving your notification preferences...',\n      success: () => {\n        refreshPreferences();\n        return form.saveNotificationPreferences;\n      },\n      error: (error) => {\n        return `Error: ${error.message}`;\n      },\n    });\n  };\n\n  const onUnsubscribe = async () => {\n    const promise = notificationsPreferencesService.setUnsubscribe(initialValues?.id);\n\n    toast.promise(promise, {\n      loading: 'Unsubscribing...',\n      success: () => {\n        refreshPreferences();\n        return form.saveNotificationPreferences;\n      },\n      error: (error) => {\n        return `Error: ${error.message}`;\n      },\n    });\n  };\n\n  if (!profile) {\n    return null;\n  }\n\n  return (\n    <SupabaseNotificationsForm\n      initialValues={initialValues}\n      isLoading={isLoading}\n      onSubmit={onSubmit}\n      onUnsubscribe={onUnsubscribe}\n      profileIsContactable={isUserContactable(profile)}\n    />\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/SupabaseNotificationsForm.tsx",
    "content": "import type { GridFormFields } from 'oa-components';\nimport {\n  ConfirmModal,\n  FieldCheckbox,\n  GridForm,\n  InformationTooltip,\n  InternalLink,\n  Loader,\n} from 'oa-components';\nimport type { DBNotificationsPreferences } from 'oa-shared';\nimport { useContext, useMemo, useState } from 'react';\nimport { Field, Form } from 'react-final-form';\nimport { Button, Flex, Text } from 'theme-ui';\nimport { TenantContext } from '../common/TenantContext';\n\nconst formId = 'SupabaseNotifications';\n\nconst baseFields: GridFormFields[] = [\n  {\n    component: (\n      <Field component={FieldCheckbox} data-cy={`${formId}-field-comments`} name=\"comments\" />\n    ),\n    description: 'Top-level comments on your contributions or contributions you follow',\n    glyph: 'comment',\n    name: 'New comments',\n  },\n  {\n    component: (\n      <Field component={FieldCheckbox} data-cy={`${formId}-field-replies`} name=\"replies\" />\n    ),\n    description:\n      \"Replies under your comment or a comment thread that you follow. Note that you can always choose to follow or unfollow a single reply thread in the comment's options.\",\n    glyph: 'reply',\n    name: 'New replies',\n  },\n  {\n    component: (\n      <Field\n        component={FieldCheckbox}\n        data-cy={`${formId}-field-research_updates`}\n        name=\"research_updates\"\n      />\n    ),\n    description: 'Updates for the research that you follow.',\n    glyph: 'update',\n    name: 'Research Updates',\n  },\n  {\n    component: (\n      <InformationTooltip\n        glyph=\"information\"\n        size={22}\n        tooltip=\"Afriad we've got to send these to you,<br/>so you can't opt-out. \"\n      />\n    ),\n    description: 'Password resets, email verifications and other service emails',\n    glyph: 'service-email',\n    name: 'Service emails',\n  },\n];\n\ninterface IProps {\n  initialValues: DBNotificationsPreferences | null;\n  isLoading: boolean;\n  onSubmit: (values: DBNotificationsPreferences) => Promise<void>;\n  onUnsubscribe: () => Promise<void>;\n  profileIsContactable?: boolean;\n}\n\nexport const SupabaseNotificationsForm = (props: IProps) => {\n  const { initialValues, isLoading, onSubmit, onUnsubscribe, profileIsContactable } = props;\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n  const tenantContext = useContext(TenantContext);\n  const showMessagingSetting = !tenantContext?.noMessaging;\n\n  const fields = useMemo(() => {\n    const allFields = [...baseFields];\n\n    if (showMessagingSetting) {\n      allFields.push({\n        component: (\n          <InternalLink\n            data-cy=\"messages-link\"\n            to=\"/settings/profile#public-contact\"\n            sx={{ textAlign: 'center', ':hover': { textDecoration: 'underline' } }}\n          >\n            {profileIsContactable ? 'Stop receiving messages' : 'Start receiving messages'}\n          </InternalLink>\n        ),\n        description: 'Through the contact form on your profile page',\n        glyph: 'email',\n        name: 'Receiving messages',\n      });\n    }\n\n    return allFields;\n  }, [showMessagingSetting, profileIsContactable]);\n\n  return (\n    <Form\n      id={formId}\n      onSubmit={onSubmit}\n      initialValues={initialValues || undefined}\n      render={({ submitting, handleSubmit }) => {\n        if (isLoading) {\n          return (\n            <Flex sx={{ minHeight: '700px', alignItems: 'center', justifyContent: 'center' }}>\n              <Loader />\n            </Flex>\n          );\n        }\n\n        return (\n          <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n            <Text as=\"h3\">We should email you about...</Text>\n\n            <GridForm fields={fields} />\n\n            <Flex sx={{ gap: 2 }}>\n              <Button\n                type=\"submit\"\n                form={formId}\n                data-cy=\"save-notifications-preferences\"\n                variant=\"primary\"\n                onClick={handleSubmit}\n                disabled={submitting}\n              >\n                Update preferences\n              </Button>\n              <Button\n                data-cy=\"save-notifications-preferences-unsubscribe\"\n                variant=\"destructive\"\n                onClick={() => setShowDeleteModal(true)}\n                disabled={submitting}\n              >\n                Unsubscribe from all emails\n              </Button>\n              <ConfirmModal\n                isOpen={showDeleteModal}\n                message=\"Unsubscribe from all current email notification types and any others we might add in the future.\"\n                confirmButtonText=\"Confirm\"\n                handleCancel={() => setShowDeleteModal(false)}\n                handleConfirm={() => {\n                  onUnsubscribe();\n                  setShowDeleteModal(false);\n                }}\n                confirmVariant=\"destructive\"\n              />\n            </Flex>\n          </Flex>\n        );\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/SupabaseNotificationsViaEmail.tsx",
    "content": "import type { DBNotificationsPreferences, DBPreferencesWithProfileContact } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { useToast } from 'src/common/Toast';\nimport { form } from 'src/pages/UserSettings/labels';\nimport { notificationsPreferencesViaEmailService } from 'src/services/notificationsPreferencesViaEmailService';\nimport { SupabaseNotificationsForm } from './SupabaseNotificationsForm';\n\ninterface IProps {\n  userCode: string;\n}\n\nexport const SupabaseNotificationsViaEmail = ({ userCode }: IProps) => {\n  const [isLoading, setIsLoading] = useState<boolean>(true);\n  const [initialValues, setInitialValues] = useState<DBPreferencesWithProfileContact | null>(null);\n  const toast = useToast();\n\n  const refreshPreferences = async () => {\n    const preferences = await notificationsPreferencesViaEmailService.getPreferences(userCode);\n\n    setInitialValues(preferences);\n    setIsLoading(false);\n  };\n\n  useEffect(() => {\n    refreshPreferences();\n  }, []);\n\n  const onSubmit = async (values: DBNotificationsPreferences) => {\n    const promise = notificationsPreferencesViaEmailService.setPreferences({\n      ...values,\n      userCode,\n    });\n\n    toast.promise(promise, {\n      loading: 'Saving your notification preferences...',\n      success: () => {\n        refreshPreferences();\n        return form.saveNotificationPreferences;\n      },\n      error: (error) => {\n        return `Error: ${error.message}`;\n      },\n    });\n  };\n\n  const onUnsubscribe = async () => {\n    const promise = notificationsPreferencesViaEmailService.setUnsubscribe(\n      userCode,\n      initialValues?.preferences.id,\n    );\n    toast.promise(promise, {\n      loading: 'Unsubscribing...',\n      success: () => {\n        refreshPreferences();\n        return form.saveNotificationPreferences;\n      },\n      error: (error) => {\n        return `Error: ${error.message}`;\n      },\n    });\n  };\n\n  if (!userCode) {\n    return null;\n  }\n\n  return (\n    <SupabaseNotificationsForm\n      initialValues={initialValues?.preferences || null}\n      isLoading={isLoading}\n      onSubmit={onSubmit}\n      onUnsubscribe={onUnsubscribe}\n      profileIsContactable={initialValues?.is_contactable}\n    />\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/__mocks__/FormProvider.tsx",
    "content": "import { render } from '@testing-library/react';\nimport { ThemeProvider } from '@theme-ui/core';\nimport { theme } from 'oa-themes';\nimport { createMemoryRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\n\nexport const FormProvider = (element: React.ReactNode, routerInitialEntry?: string) => {\n  const router = createMemoryRouter(createRoutesFromElements(<Route index element={element} />), {\n    initialEntries: [routerInitialEntry ? routerInitialEntry : ''],\n  });\n\n  return render(\n    <ProfileStoreProvider>\n      <ThemeProvider theme={theme}>\n        <RouterProvider router={router} />\n      </ThemeProvider>\n    </ProfileStoreProvider>,\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/constants.ts",
    "content": "export const MAX_PIN_LENGTH = 70;\nexport const MEMBER_PROFILE_DESCRIPTION_MAX_LENGTH = 500;\nexport const GROUP_PROFILE_DESCRIPTION_MAX_LENGTH = 2000;\nexport const DEFAULT_PUBLIC_CONTACT_PREFERENCE = true;\n"
  },
  {
    "path": "src/pages/UserSettings/content/elements.tsx",
    "content": "import { Flex } from 'theme-ui';\n\nexport const ProfileSection = (props) => (\n  <Flex sx={{ flexDirection: 'column', gap: [3, 4] }}>{props.children}</Flex>\n);\n"
  },
  {
    "path": "src/pages/UserSettings/content/fields/ImpactQuestion.field.tsx",
    "content": "import { FieldInput, Icon } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { Box, Flex, Label, Text } from 'theme-ui';\n\nimport type { IImpactQuestion } from '../impactQuestions';\n\ninterface Props {\n  formId: string;\n  field: IImpactQuestion;\n}\n\nexport const ImpactQuestionField = ({ field, formId }: Props) => {\n  return (\n    <Box sx={{ marginBottom: 3 }}>\n      <Label htmlFor={`${field.id}.value`} sx={{ marginBottom: 1 }}>\n        {field.description}\n      </Label>\n      <Flex\n        sx={{\n          alignItems: 'center',\n          flexDirection: 'row',\n          justifyContent: 'space-between',\n        }}\n      >\n        <Flex\n          sx={{\n            alignItems: 'center',\n            alignSelf: 'flex-start',\n            flexDirection: 'row',\n            gap: 2,\n          }}\n        >\n          {field.prefix && (\n            <Box>\n              <Text>{field.prefix}</Text>\n            </Box>\n          )}\n\n          <Box>\n            <Field\n              component={FieldInput}\n              data-cy={`${formId}-field-${field.id}-value`}\n              name={`${field.id}.value`}\n              sx={{ background: 'white' }}\n              type=\"number\"\n              onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {\n                if (e.key === 'e' || e.key === 'E' || e.key === '+' || e.key === '-') {\n                  e.preventDefault();\n                }\n              }}\n              onInput={(e: React.FormEvent<HTMLInputElement>) => {\n                const target = e.target as HTMLInputElement;\n\n                target.value = target.value\n                  .replaceAll('e', '')\n                  .replaceAll('E', '')\n                  .replaceAll('+', '')\n                  .replaceAll('-', '');\n              }}\n            />\n          </Box>\n\n          {field.suffix && (\n            <Box>\n              <Text>{field.suffix}</Text>\n            </Box>\n          )}\n\n          <Box>\n            <Text>{field.label}</Text>\n          </Box>\n        </Flex>\n\n        <Flex sx={{ alignSelf: 'flex-end', flexDirection: 'row' }}>\n          <Icon glyph=\"show\" size={24} />\n          <Field\n            component=\"input\"\n            data-cy={`${formId}-field-${field.id}-isVisible`}\n            initialValue={true}\n            name={`${field.id}.isVisible`}\n            type=\"checkbox\"\n          />\n        </Flex>\n      </Flex>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/content/fields/ImpactYear.field.tsx",
    "content": "import { Button } from 'oa-components';\nimport { buttons } from 'src/pages/UserSettings/labels';\n\nimport { impactQuestions } from '../impactQuestions';\nimport { ImpactQuestionField } from './ImpactQuestion.field';\n\ninterface Props {\n  formId: string;\n  handleSubmit: () => void;\n  submitting: boolean;\n}\n\nexport const ImpactYearField = (props: Props) => {\n  const { formId, handleSubmit, submitting } = props;\n\n  return (\n    <>\n      {impactQuestions.map((field, index) => (\n        <ImpactQuestionField field={field} formId={formId} key={index} />\n      ))}\n\n      <Button\n        data-cy={`${formId}-button-save`}\n        disabled={submitting}\n        type=\"submit\"\n        onClick={handleSubmit}\n        form={formId}\n        sx={{ alignSelf: 'start' }}\n      >\n        {buttons.impact.save}\n      </Button>\n    </>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/content/fields/ImpactYearDisplay.field.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Button, Icon } from 'oa-components';\nimport type { IImpactDataField } from 'oa-shared';\nimport { ImpactField } from 'src/pages/User/impact/ImpactField';\nimport { buttons, missingData } from 'src/pages/UserSettings/labels';\nimport { Box, Flex, Text } from 'theme-ui';\n\ninterface Props {\n  fields: IImpactDataField[] | undefined;\n  formId: string;\n  setIsEditMode: (boolean) => void;\n}\n\nexport const ImpactYearDisplayField = observer((props: Props) => {\n  const { fields, formId, setIsEditMode } = props;\n  const { create, edit } = buttons.impact;\n\n  const buttonLabel = fields ? edit : create;\n\n  return (\n    <Flex sx={{ flexDirection: 'column', alignItems: 'flex-start' }}>\n      {fields && fields.length > 0 ? (\n        fields.map((field, index) => {\n          const glyph = field.isVisible ? 'show' : 'hide';\n          return (\n            <Box key={index} sx={{ width: '100%' }}>\n              <Flex\n                sx={{\n                  alignItems: 'center',\n                  flexDirection: 'row',\n                  justifyContent: 'space-between',\n                }}\n              >\n                <ImpactField field={{ ...field, isVisible: true }} />\n                <Icon glyph={glyph} size={24} />\n              </Flex>\n            </Box>\n          );\n        })\n      ) : (\n        <Text>{missingData}</Text>\n      )}\n      <Button\n        type=\"button\"\n        data-cy={`${formId}-button-edit`}\n        onClick={() => setIsEditMode(true)}\n        sx={{ marginTop: 3 }}\n      >\n        {buttonLabel}\n      </Button>\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/content/fields/PatreonIntegration.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { act, fireEvent, render, screen } from '@testing-library/react';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport {\n  CONNECT_BUTTON_TEXT,\n  HEADING,\n  PatreonIntegration,\n  REMOVE_BUTTON_TEXT,\n  SUCCESS_MESSAGE,\n  SUPPORTER_MESSAGE,\n  UPDATE_BUTTON_TEXT,\n} from './PatreonIntegration';\n\nimport { UserRole, type IPatreonUser } from 'oa-shared';\nimport type { Mock } from 'vitest';\nimport { TenantContext, TenantSettingsContext } from 'src/pages/common/TenantContext';\n\n// Mock setup first (hoisted to the top by Vitest)\nvi.mock('src/services/patreonService', () => {\n  return {\n    patreonService: {\n      getCurrentUserPatreon: vi.fn(),\n      disconnectUserPatreon: vi.fn(),\n    },\n  };\n});\n\n// Import the mocked service *after* vi.mock()\nimport { patreonService } from 'src/services/patreonService';\n\nconst mockTenantContext: TenantSettingsContext = {\n  patreonId: 'mock-patreon-client-id',\n  siteName: 'Test Site',\n  siteDescription: '',\n  siteUrl: 'https://test.com',\n  messageSignOff: 'Test',\n  emailFrom: 'test@test.com',\n  siteImage: 'test.png',\n  noMessaging: false,\n  libraryHeading: 'Library',\n  academyResource: 'Academy',\n  profileGuidelines: 'Guidelines',\n  questionsGuidelines: 'Questions',\n  supportedModules: 'modules',\n  colorPrimary: '#fee77b',\n  colorPrimaryHover: '#ffde45',\n  colorAccent: '#fee77b',\n  colorAccentHover: '#ffde45',\n  gaTrackingId: 'mock-ga-tracking-id',\n  showImpact: true,\n  createResearchRoles: [UserRole.ADMIN, UserRole.RESEARCH_CREATOR],\n  environment: {},\n};\n\nconst renderWithTenantContext = (component: React.ReactNode) => {\n  return render(<TenantContext.Provider value={mockTenantContext}>{component}</TenantContext.Provider>);\n};\n\nconst MOCK_PATREON_TIER_TITLE = 'Patreon Tier Title';\nconst mockPatreonSupporter = {\n  isSupporter: true,\n  patreon: {\n    attributes: {\n      thumb_url: 'https://patreon.com',\n    },\n    membership: {\n      tiers: [\n        {\n          id: '123',\n          attributes: {\n            title: MOCK_PATREON_TIER_TITLE,\n            image_url: 'https://patreon.com',\n          },\n        },\n      ],\n    },\n  } as IPatreonUser,\n};\n\nconst WRONG_PATREON_TIER_TITLE = 'Wrong Patreon Tier Title';\nconst mockPatreonNotSupporter = {\n  isSupporter: false,\n  patreon: {\n    attributes: {\n      thumb_url: 'https://patreon.com',\n    },\n    membership: {\n      tiers: [\n        {\n          id: '456',\n          attributes: {\n            title: WRONG_PATREON_TIER_TITLE,\n            image_url: 'https://patreon.com',\n          },\n        },\n      ],\n    },\n  } as IPatreonUser,\n};\n\ndescribe('PatreonIntegration', () => {\n  describe('not connected', () => {\n    beforeEach(async () => {\n      vi.clearAllMocks();\n      // Return null or undefined to simulate not connected\n      (patreonService.getCurrentUserPatreon as Mock).mockResolvedValue(null);\n\n      await act(async () => {\n        renderWithTenantContext(<PatreonIntegration />);\n      });\n    });\n\n    it('renders correctly', () => {\n      expect(screen.getByText(HEADING)).toBeInTheDocument();\n      expect(screen.getByText(CONNECT_BUTTON_TEXT)).toBeInTheDocument();\n    });\n  });\n\n  describe('not supporter', () => {\n    beforeEach(async () => {\n      vi.clearAllMocks();\n      (patreonService.getCurrentUserPatreon as Mock).mockResolvedValue(mockPatreonNotSupporter);\n\n      await act(async () => {\n        renderWithTenantContext(<PatreonIntegration />);\n      });\n    });\n\n    it('displays the connected Patreon account information and buttons', () => {\n      expect(screen.getByText(SUCCESS_MESSAGE)).toBeInTheDocument();\n      expect(screen.getByText(UPDATE_BUTTON_TEXT)).toBeInTheDocument();\n      expect(screen.getByText(REMOVE_BUTTON_TEXT)).toBeInTheDocument();\n    });\n\n    it('does not display supporter message or invalid tier', () => {\n      expect(screen.queryByText(SUPPORTER_MESSAGE)).not.toBeInTheDocument();\n      expect(screen.queryByText(WRONG_PATREON_TIER_TITLE)).not.toBeInTheDocument();\n    });\n  });\n\n  describe('with supporter', () => {\n    beforeEach(async () => {\n      vi.clearAllMocks();\n      (patreonService.getCurrentUserPatreon as Mock).mockReturnValue(mockPatreonSupporter);\n      await act(async () => {\n        renderWithTenantContext(<PatreonIntegration />);\n      });\n    });\n\n    it('displays the connected Patreon account information and buttons', () => {\n      expect(screen.getByText(SUCCESS_MESSAGE)).toBeInTheDocument();\n      expect(screen.getByText(SUPPORTER_MESSAGE)).toBeInTheDocument();\n      expect(screen.getByText('Patreon Tier Title')).toBeInTheDocument();\n      expect(screen.getByText(UPDATE_BUTTON_TEXT)).toBeInTheDocument();\n      expect(screen.getByText(REMOVE_BUTTON_TEXT)).toBeInTheDocument();\n    });\n\n    it('calls removePatreonConnection when \"Remove Connection\" button is clicked', async () => {\n      await act(async () => {\n        fireEvent.click(screen.getByText(REMOVE_BUTTON_TEXT));\n      });\n      expect(patreonService.disconnectUserPatreon).toHaveBeenCalled();\n    });\n  });\n\n  describe('without patreonId', () => {\n    it('does not render when patreonId is not provided', () => {\n      const contextWithoutPatreonId = {\n        ...mockTenantContext,\n        patreonId: '',\n      };\n\n      const { container } = render(\n        <TenantContext.Provider value={contextWithoutPatreonId}>\n          <PatreonIntegration />\n        </TenantContext.Provider>,\n      );\n\n      expect(container.firstChild).toBeNull();\n      expect(screen.queryByText(HEADING)).not.toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "src/pages/UserSettings/content/fields/PatreonIntegration.tsx",
    "content": "import { Button, Icon } from 'oa-components';\nimport type { IPatreonUser } from 'oa-shared';\nimport { useContext, useEffect, useState } from 'react';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { patreonService } from 'src/services/patreonService';\nimport { Flex, Heading, Image, Text } from 'theme-ui';\n\nexport const HEADING = 'Patreon';\nexport const SUCCESS_MESSAGE = 'Successfully linked Patreon account!';\nexport const SUPPORTER_MESSAGE =\n  'Thanks for supporting us! :) Update your data if you changed your Patreon tiers or remove the connection below.';\n\nexport const CONNECT_BUTTON_TEXT = 'Connect';\nexport const UPDATE_BUTTON_TEXT = 'Update';\nexport const REMOVE_BUTTON_TEXT = 'Disconnect';\n\nexport const PatreonIntegration = () => {\n  const tenantContext = useContext(TenantContext);\n  const [patreonUser, setPatreon] = useState<{\n    patreon: IPatreonUser;\n    isSupporter: boolean;\n  }>();\n\n  useEffect(() => {\n    const fetchPatreonData = async () => {\n      const patreonUser = await patreonService.getCurrentUserPatreon();\n\n      if (patreonUser) {\n        setPatreon(patreonUser);\n      }\n    };\n\n    fetchPatreonData();\n  }, []);\n\n  const removePatreonConnection = async () => {\n    const result = await patreonService.disconnectUserPatreon();\n\n    if (result) {\n      setPatreon(undefined);\n    }\n  };\n\n  const patreonRedirect = () => {\n    // Redirect to patreon to get access code.\n    const redirectUri = `${window.location.protocol}//${window.location.host}/patreon`;\n\n    window.location.assign(\n      `https://www.patreon.com/oauth2/authorize?response_type=code&client_id=${tenantContext?.patreonId}&redirect_uri=${redirectUri}`,\n    );\n  };\n\n  if (!tenantContext?.patreonId) {\n    return null;\n  }\n\n  return (\n    <Flex\n      sx={{\n        alignItems: ['flex-start', 'flex-start', 'flex-start'],\n        backgroundColor: 'offWhite',\n        borderRadius: 3,\n        flexDirection: 'column',\n        justifyContent: 'space-between',\n        padding: 4,\n        gap: [2, 4],\n      }}\n    >\n      <Flex sx={{ flexDirection: 'row', gap: [2, 4] }}>\n        <Icon glyph=\"patreon\" size={45} />\n        <Flex sx={{ flexDirection: 'column', flex: 1, gap: [2] }}>\n          <Heading as=\"h2\" variant=\"small\">\n            {HEADING}\n          </Heading>\n          {patreonUser?.patreon ? (\n            <>\n              <Text>{SUCCESS_MESSAGE}</Text>\n              {patreonUser?.isSupporter && patreonUser.patreon.membership && (\n                <Flex sx={{ flexDirection: 'column' }}>\n                  <Text mt={4}>{SUPPORTER_MESSAGE}</Text>\n                  {patreonUser.patreon.membership.tiers.map(({ id, attributes }) => (\n                    <Flex\n                      key={id}\n                      sx={{\n                        alignItems: 'center',\n                        mt: 4,\n                      }}\n                    >\n                      <div\n                        style={{\n                          width: '40px',\n                          height: '40px',\n                          overflow: 'hidden',\n                          marginRight: '10px',\n                        }}\n                      >\n                        <Image\n                          src={attributes.image_url}\n                          sx={{\n                            borderRadius: '50%',\n                            width: 'auto',\n                            height: '40px',\n                            objectFit: 'cover',\n                            objectPosition: 'center',\n                          }}\n                        />\n                      </div>\n                      <Text>{attributes.title}</Text>\n                    </Flex>\n                  ))}\n                </Flex>\n              )}\n            </>\n          ) : (\n            <Text variant=\"quiet\">\n              As a supporter you get a badge on the platform, special insights and voting rights on\n              decisions.\n            </Text>\n          )}\n        </Flex>\n      </Flex>\n\n      <Flex sx={{ gap: 2 }}>\n        <Button type=\"button\" onClick={patreonRedirect} variant=\"primary\">\n          {patreonUser?.patreon ? UPDATE_BUTTON_TEXT : CONNECT_BUTTON_TEXT}\n        </Button>\n        {patreonUser?.patreon && (\n          <Button type=\"button\" onClick={removePatreonConnection} variant=\"outline\">\n            {REMOVE_BUTTON_TEXT}\n          </Button>\n        )}\n      </Flex>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/content/fields/ProfileTypeRadio.field.tsx",
    "content": "import styled from '@emotion/styled';\nimport { MemberBadge } from 'oa-components';\nimport type { ProfileType } from 'oa-shared';\nimport React from 'react';\nimport type { FieldRenderProps } from 'react-final-form';\nimport { Field } from 'react-final-form';\nimport { Flex, Input, Label, Text } from 'theme-ui';\n\ninterface IProps {\n  value: ProfileType;\n  onChange: (value: string) => void;\n  isSelected: boolean;\n  textLabel?: string;\n  subText?: string;\n  name: string;\n  fullWidth?: boolean;\n  required?: boolean;\n  'data-cy'?: string;\n  theme?: any;\n}\n\ntype FieldProps = FieldRenderProps<any, any> & {\n  children?: React.ReactNode;\n  disabled?: boolean;\n  'data-cy'?: string;\n  customOnBlur?: (event) => void;\n};\n\nconst HiddenInputField = ({ input, meta, ...rest }: FieldProps) => (\n  <Input\n    type=\"hidden\"\n    variant={meta.error && meta.touched ? 'error' : 'input'}\n    {...input}\n    {...rest}\n  />\n);\n\n// validation - return undefined if no error (i.e. valid)\nconst isRequired = (value: any) => (value ? undefined : 'Required');\n\nconst HiddenInput = styled(Field)`\n  position: absolute;\n  opacity: 0;\n  width: 0;\n  height: 0;\n`;\n\nexport const ProfileTypeRadioField = (props: IProps) => {\n  const {\n    value,\n    isSelected,\n    textLabel,\n    subText,\n    name,\n    fullWidth,\n    required,\n    'data-cy': dataCy,\n  } = props;\n\n  const classNames: string[] = [];\n  if (isSelected) {\n    classNames.push('selected');\n  }\n  if (fullWidth) {\n    classNames.push('full-width');\n  }\n\n  return (\n    <Label\n      sx={{\n        alignItems: 'center',\n        width: '100%',\n        display: 'flex',\n        flexDirection: 'column',\n        py: 2,\n        gap: 2,\n        borderRadius: 2,\n        border: '2px solid transparent',\n        ':hover': {\n          backgroundColor: 'background',\n          cursor: 'pointer',\n        },\n        '&.selected': {\n          backgroundColor: 'background',\n          borderColor: 'green',\n        },\n      }}\n      htmlFor={value.name}\n      className={classNames.join(' ')}\n      data-cy={dataCy}\n    >\n      <HiddenInput\n        id={value.name}\n        name={name}\n        value={value.name}\n        type=\"radio\"\n        component={HiddenInputField}\n        checked={isSelected}\n        validate={required ? isRequired : undefined}\n        validateFields={[]}\n        onChange={(v) => props.onChange(v.target.value)}\n      />\n      <Flex\n        sx={{\n          width: ['130px', '130px', '100%'],\n          height: ['130px', '130px', '100%'],\n          alignContent: 'center',\n          padding: 2,\n        }}\n      >\n        <MemberBadge size={130} profileType={value} />\n      </Flex>\n      <Flex sx={{ flexDirection: 'column' }}>\n        {textLabel && (\n          <Text\n            sx={{\n              display: 'block',\n              fontSize: 2,\n              fontWeight: ['bold', 'bold', 'inherit'],\n              textAlign: ['center'],\n            }}\n          >\n            {textLabel}\n          </Text>\n        )}\n        {subText && (\n          <Text\n            sx={{\n              textAlign: 'center',\n              fontSize: 1,\n              display: 'block',\n              marginTop: 1,\n              marginBottom: 1,\n              color: 'gray',\n            }}\n          >\n            {subText}\n          </Text>\n        )}\n      </Flex>\n    </Label>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/content/impactQuestions.ts",
    "content": "export interface IImpactQuestion {\n  id: string;\n  icon: string;\n  description: string;\n  label: string;\n  value?: number;\n  suffix?: string;\n  prefix?: string;\n}\n\nexport const impactQuestions: IImpactQuestion[] = [\n  {\n    id: 'plastic',\n    icon: 'plastic',\n    description: ' How many KGs of plastic have you recycled?',\n    label: 'plastic recycled',\n    suffix: 'Kg of',\n  },\n  {\n    id: 'revenue',\n    icon: 'revenue',\n    description: 'What was your annual revenue (in USD)?',\n    label: 'revenue',\n    prefix: 'USD',\n  },\n  {\n    id: 'employees',\n    icon: 'employee',\n    description: 'How many people did you employ (you included)?',\n    label: 'full time employees',\n  },\n  {\n    id: 'volunteers',\n    icon: 'volunteer',\n    description: 'How many volunteers did you work with?',\n    label: 'volunteers',\n  },\n  {\n    id: 'machines',\n    icon: 'machine',\n    description: 'How many machines did you build?',\n    label: 'machines built',\n  },\n];\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/ChangeEmail.form.tsx",
    "content": "import { Accordion, Button, FieldInput } from 'oa-components';\nimport { FRIENDLY_MESSAGES } from 'oa-shared';\nimport { useContext } from 'react';\nimport { Field, Form } from 'react-final-form';\nimport { PasswordField } from 'src/common/Form/PasswordField';\nimport { useToast } from 'src/common/Toast/useToast';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { SessionContext } from 'src/pages/common/SessionContext';\nimport { buttons, fields } from 'src/pages/UserSettings/labels';\nimport { Flex } from 'theme-ui';\nimport { accountService } from '../../services/account.service';\n\ninterface IFormValues {\n  password: string;\n  newEmail: string;\n}\n\nexport const ChangeEmailForm = () => {\n  const claims = useContext(SessionContext);\n  const toast = useToast();\n\n  const formId = 'changeEmail';\n\n  const onSubmit = async (values: IFormValues) => {\n    const { newEmail, password } = values;\n    const promise = accountService.changeEmail(newEmail, password);\n\n    toast.promise(promise, {\n      loading: 'Changing your email...',\n      success: () => FRIENDLY_MESSAGES['auth/email-changed'],\n      error: (error) => `Error: ${error.message}`,\n    });\n  };\n\n  return (\n    <Flex data-cy=\"changeEmailContainer\" sx={{ flexDirection: 'column', gap: 2 }}>\n      <Accordion title=\"Change Email\" subtitle={`${fields.email.title}: ${claims?.email}`}>\n        <Form\n          onSubmit={onSubmit}\n          id={formId}\n          render={({ handleSubmit, submitting, values }) => {\n            const { password, newEmail } = values;\n            const disabled = submitting || !password || !newEmail || newEmail === claims?.email;\n\n            return (\n              <Flex data-cy=\"changeEmailForm\" sx={{ flexDirection: 'column', gap: 2 }}>\n                <FormFieldWrapper text={fields.newEmail.title} htmlFor=\"newEmail\" required>\n                  <Field\n                    autoComplete=\"off\"\n                    component={FieldInput}\n                    data-cy=\"newEmail\"\n                    name=\"newEmail\"\n                    placeholder={fields.newEmail.placeholder}\n                    type=\"email\"\n                    required\n                  />\n                </FormFieldWrapper>\n\n                <FormFieldWrapper text={fields.password.title} htmlFor=\"password\" required>\n                  <PasswordField\n                    autoComplete=\"off\"\n                    component={FieldInput}\n                    data-cy=\"password\"\n                    name=\"password\"\n                    required\n                    placeholder=\"Password\"\n                  />\n                </FormFieldWrapper>\n\n                <Button\n                  data-cy=\"changeEmailSubmit\"\n                  disabled={disabled}\n                  form={formId}\n                  onClick={handleSubmit}\n                  type=\"submit\"\n                  sx={{\n                    alignSelf: 'flex-start',\n                  }}\n                >\n                  {buttons.submitNewEmail}\n                </Button>\n              </Flex>\n            );\n          }}\n        />\n      </Accordion>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/ChangePassword.form.tsx",
    "content": "import { Accordion, Button, FieldInput } from 'oa-components';\nimport { FRIENDLY_MESSAGES } from 'oa-shared';\nimport { Form } from 'react-final-form';\nimport { PasswordField } from 'src/common/Form/PasswordField';\nimport { useToast } from 'src/common/Toast/useToast';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { buttons, fields } from 'src/pages/UserSettings/labels';\nimport { Flex } from 'theme-ui';\nimport { accountService } from '../../services/account.service';\n\ninterface IFormValues {\n  oldPassword: string;\n  newPassword: string;\n  repeatNewPassword: string;\n}\n\nexport const ChangePasswordForm = () => {\n  const toast = useToast();\n  const formId = 'changePassword';\n\n  const onSubmit = async (values: IFormValues) => {\n    const { oldPassword, newPassword } = values;\n\n    const promise = accountService.changePassword(oldPassword, newPassword);\n\n    toast.promise(promise, {\n      loading: 'Changing your password...',\n      success: () => FRIENDLY_MESSAGES['auth/password-changed'],\n      error: (error) => `Error: ${error.message}`,\n    });\n  };\n\n  return (\n    <Flex data-cy=\"changePasswordContainer\" sx={{ flexDirection: 'column', gap: 2 }}>\n      <Accordion\n        title=\"Change Password\"\n        subtitle=\"Here you can change your password to a stronger one.\"\n      >\n        <Form\n          onSubmit={onSubmit}\n          id={formId}\n          render={({ handleSubmit, submitting, values }) => {\n            const { oldPassword, newPassword, repeatNewPassword } = values;\n            const disabled =\n              submitting ||\n              !oldPassword ||\n              !newPassword ||\n              repeatNewPassword !== newPassword ||\n              oldPassword === newPassword;\n\n            return (\n              <Flex data-cy=\"changePasswordForm\" sx={{ flexDirection: 'column', gap: 1 }}>\n                <FormFieldWrapper text={fields.oldPassword.title} htmlFor=\"oldPassword\" required>\n                  <PasswordField\n                    autoComplete=\"off\"\n                    component={FieldInput}\n                    data-cy=\"oldPassword\"\n                    name=\"oldPassword\"\n                    placeholder={fields.oldPassword.placeholder}\n                    required\n                  />\n                </FormFieldWrapper>\n\n                <FormFieldWrapper text={fields.newPassword.title} htmlFor=\"newPassword\" required>\n                  <PasswordField\n                    autoComplete=\"off\"\n                    component={FieldInput}\n                    data-cy=\"newPassword\"\n                    name=\"newPassword\"\n                    placeholder={fields.newPassword.placeholder}\n                    required\n                  />\n                </FormFieldWrapper>\n\n                <FormFieldWrapper\n                  text={fields.repeatNewPassword.title}\n                  htmlFor=\"repeatNewPassword\"\n                  required\n                >\n                  <PasswordField\n                    autoComplete=\"off\"\n                    component={FieldInput}\n                    data-cy=\"repeatNewPassword\"\n                    name=\"repeatNewPassword\"\n                    placeholder={fields.repeatNewPassword.placeholder}\n                    required\n                  />\n                </FormFieldWrapper>\n\n                <Button\n                  data-cy=\"changePasswordSubmit\"\n                  disabled={disabled}\n                  form={formId}\n                  onClick={handleSubmit}\n                  type=\"submit\"\n                  sx={{\n                    alignSelf: 'flex-start',\n                  }}\n                >\n                  {buttons.submitNewPassword}\n                </Button>\n              </Flex>\n            );\n          }}\n        />\n      </Accordion>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/EmailNotifications.section.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Select } from 'oa-components';\nimport type { INotificationSettings } from 'oa-shared';\nimport { EmailNotificationFrequency } from 'oa-shared';\nimport { useMemo } from 'react';\nimport { Field } from 'react-final-form';\nimport { FieldContainer } from 'src/common/Form/FieldContainer';\n\ninterface IProps {\n  notificationSettings?: INotificationSettings;\n}\n\nconst emailFrequencyOptions: {\n  value: EmailNotificationFrequency;\n  label: string;\n}[] = [\n  { value: EmailNotificationFrequency.NEVER, label: 'Never (Unsubscribed)' },\n  { value: EmailNotificationFrequency.DAILY, label: 'Daily' },\n  { value: EmailNotificationFrequency.WEEKLY, label: 'Weekly' },\n  { value: EmailNotificationFrequency.MONTHLY, label: 'Monthly' },\n];\n\nexport const EmailNotificationsSection = observer((props: IProps) => {\n  const defaultValue = useMemo(\n    () =>\n      emailFrequencyOptions.find(\n        ({ value }) =>\n          value ===\n          (props.notificationSettings?.emailFrequency ?? EmailNotificationFrequency.NEVER),\n      ),\n    [props.notificationSettings?.emailFrequency],\n  );\n\n  return (\n    <FieldContainer data-cy=\"NotificationSettingsSelect\">\n      <Field name=\"notification_settings.emailFrequency\">\n        {({ input }) => {\n          return (\n            <Select\n              options={emailFrequencyOptions}\n              defaultValue={defaultValue}\n              onChange={({ value }) => input.onChange(value)}\n            />\n          );\n        }}\n      </Field>\n    </FieldContainer>\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/ImpactYear.section.tsx",
    "content": "import { observer } from 'mobx-react';\nimport type { IImpactDataField, IImpactYear } from 'oa-shared';\nimport { useEffect, useRef, useState } from 'react';\nimport { Form } from 'react-final-form';\nimport { useLocation } from 'react-router';\nimport { useToast } from 'src/common/Toast';\nimport { profileService } from 'src/services/profileService';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Flex, Heading, Text } from 'theme-ui';\nimport {\n  sortImpactYearDisplayFields,\n  transformImpactData,\n  transformImpactInputs,\n} from '../../utils';\nimport { ImpactYearField } from '../fields/ImpactYear.field';\nimport { ImpactYearDisplayField } from '../fields/ImpactYearDisplay.field';\n\ninterface Props {\n  year: IImpactYear;\n}\n\nexport const ImpactYearSection = observer(({ year }: Props) => {\n  const [impact, setImpact] = useState<IImpactDataField[] | undefined>(undefined);\n  const [isEditMode, setIsEditMode] = useState<boolean>(false);\n  const impactDivRef = useRef<HTMLInputElement>(null);\n  const { hash } = useLocation();\n  const { profile, updateImpact } = useProfileStore();\n  const toast = useToast();\n\n  const formId = `impactForm-${year}`;\n  const sx = {\n    backgroundColor: 'background',\n    borderRadius: 2,\n    gap: 1,\n    marginBottom: 2,\n    padding: 2,\n    flexDirection: 'column',\n  } as ThemeUIStyleObject;\n\n  useEffect(() => {\n    const fetchImpact = () => {\n      const impact = profile?.impact;\n      if (impact && impact[year]) {\n        setImpact(impact[year]);\n      }\n    };\n\n    fetchImpact();\n  }, []);\n\n  useEffect(() => {\n    const anchor = `#year_${year}`;\n\n    const openEditMode = () => {\n      if (hash === anchor) {\n        setTimeout(() => {\n          const section = document.querySelector(anchor);\n          // the delay is needed, otherwise the scroll is not happening in Firefox\n          section?.scrollIntoView({ behavior: 'smooth', block: 'start' });\n        }, 500);\n\n        return setIsEditMode(true);\n      }\n      return setIsEditMode(false);\n    };\n\n    openEditMode();\n  }, [hash]);\n\n  const onSubmit = async (values) => {\n    const fields = transformImpactInputs(values);\n    const promise = profileService.updateImpact(year, fields);\n    toast.promise(promise, {\n      loading: 'Updating your impact...',\n      success: (impact) => {\n        updateImpact(impact);\n        setIsEditMode(false);\n        setImpact(sortImpactYearDisplayFields(fields));\n\n        return 'Impact updated!';\n      },\n      error: (error) => `Error: ${error.message}`,\n    });\n  };\n\n  return (\n    <Flex sx={sx} id={`year_${year}`}>\n      <Heading as=\"h3\" variant=\"small\" ref={impactDivRef}>\n        {year}\n      </Heading>\n      <Text as=\"h4\" variant=\"quiet\" sx={{ marginBottom: 2 }}>\n        All fields optional\n      </Text>\n\n      <Form\n        id={formId}\n        initialValues={impact ? transformImpactData(impact) : undefined}\n        onSubmit={onSubmit}\n        render={({ handleSubmit, values, submitting }) => {\n          return isEditMode ? (\n            <ImpactYearField formId={formId} handleSubmit={handleSubmit} submitting={submitting} />\n          ) : (\n            <ImpactYearDisplayField\n              fields={sortImpactYearDisplayFields(transformImpactInputs(values))}\n              formId={formId}\n              setIsEditMode={setIsEditMode}\n            />\n          );\n        }}\n      />\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/ProfileTags.section.tsx",
    "content": "import { useCallback } from 'react';\nimport { Field } from 'react-final-form';\nimport { ProfileTagsSelect } from 'src/common/Tags/ProfileTagsSelect';\nimport { fields } from 'src/pages/UserSettings/labels';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { Flex, Text } from 'theme-ui';\n\nimport { ProfileSection } from '../elements';\n\ninterface IProps {\n  typeName: string | undefined;\n}\n\nexport const ProfileTags = ({ typeName }: IProps) => {\n  const { description, title } = fields.tags;\n\n  const renderTagsSelect = useCallback(\n    ({ input }) => (\n      <ProfileTagsSelect\n        value={input.value}\n        onChange={(tags) => input.onChange(tags)}\n        maxTotal={5}\n        profileType={typeName}\n        isForm\n      />\n    ),\n    [typeName],\n  );\n\n  return (\n    <ProfileSection>\n      <Flex\n        data-testid=\"ProfileTags\"\n        sx={{\n          flexDirection: 'column',\n          justifyContent: 'space-between',\n          gap: 1,\n        }}\n      >\n        <Text>{title}</Text>\n        <Text variant=\"quiet\" sx={{ fontSize: 2 }}>\n          {description}\n        </Text>\n        <Field name=\"tagIds\" component={renderTagsSelect} isEqual={COMPARISONS.tags} />\n      </Flex>\n    </ProfileSection>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/ProfileType.section.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { render, screen } from '@testing-library/react';\nimport { SettingsFormProvider } from 'src/test/components/SettingsFormProvider';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { headings } from '../../labels';\nimport { ProfileTypeSection } from './ProfileType.section';\n\nimport type { ProfileTag } from 'oa-shared';\n\nvi.mock('src/services/profileTagsService', () => ({\n  profileTagsService: {\n    getAllTags: vi.fn().mockResolvedValue([\n      { id: 1, name: 'member', profileType: 'member' },\n      { id: 2, name: 'space', profileType: 'space' },\n    ] as ProfileTag[]),\n  },\n}));\n\ndescribe('Focus', () => {\n  it('render focus section if less than 2 activities', () => {\n    render(\n      <SettingsFormProvider>\n        <ProfileTypeSection\n          profileTypes={[\n            {\n              id: 1,\n              name: 'member',\n              displayName: 'Member',\n              description: 'Member desc',\n              isSpace: false,\n              imageUrl: '',\n              mapPinName: '',\n              order: 1,\n              smallImageUrl: '',\n            },\n          ]}\n        />\n      </SettingsFormProvider>,\n    );\n\n    expect(screen.queryByText(headings.focus)).not.toBeInTheDocument();\n  });\n\n  it('does not render focus section more than 2 activities', () => {\n    render(\n      <SettingsFormProvider>\n        <ProfileTypeSection\n          profileTypes={[\n            {\n              id: 1,\n              name: 'member',\n              displayName: 'Member',\n              description: 'Member desc',\n              isSpace: false,\n              imageUrl: '',\n              mapPinName: '',\n              order: 1,\n              smallImageUrl: '',\n            },\n            {\n              id: 2,\n              name: 'space',\n              displayName: 'Space',\n              description: 'space desc',\n              isSpace: true,\n              imageUrl: '',\n              mapPinName: '',\n              order: 2,\n              smallImageUrl: '',\n            },\n          ]}\n        />\n      </SettingsFormProvider>,\n    );\n\n    expect(screen.queryByText(headings.focus)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/ProfileType.section.tsx",
    "content": "import { ExternalLink } from 'oa-components';\nimport type { ProfileType } from 'oa-shared';\nimport { useContext } from 'react';\nimport { Field } from 'react-final-form';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { buttons, fields, headings } from 'src/pages/UserSettings/labels';\nimport { Box, Flex, Grid, Heading, Paragraph, Text } from 'theme-ui';\nimport { ProfileSection } from '../elements';\nimport { ProfileTypeRadioField } from '../fields/ProfileTypeRadio.field';\n\ntype ProfileTypeSectionProps = {\n  profileTypes: ProfileType[];\n};\nexport const ProfileTypeSection = ({ profileTypes }: ProfileTypeSectionProps) => {\n  const tenantContext = useContext(TenantContext);\n  const { description, error } = fields.activities;\n\n  if (!profileTypes || profileTypes.length < 2) {\n    return null;\n  }\n\n  return (\n    <Field\n      name=\"type\"\n      render={(props) => (\n        <ProfileSection data-cy=\"FocusSection\">\n          <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n            <Heading as=\"h2\">{headings.focus}</Heading>\n            <Paragraph>\n              {description}{' '}\n              <ExternalLink\n                href={tenantContext?.profileGuidelines}\n                sx={{ textDecoration: 'underline', color: 'grey' }}\n                type=\"button\"\n              >\n                {buttons.guidelines}\n              </ExternalLink>\n            </Paragraph>\n          </Flex>\n\n          <Flex sx={{ flexDirection: 'column', gap: 4 }}>\n            {props.meta.error && <Text color=\"red\">{error}</Text>}\n\n            <Grid columns={['repeat(auto-fill, minmax(125px, 1fr))']} gap={2}>\n              {profileTypes\n                .slice()\n                .sort((a, b) => {\n                  if (a.isSpace !== b.isSpace) {\n                    return a.isSpace ? 1 : -1;\n                  }\n                  return a.order - b.order;\n                })\n                .map((profileType, index: number) => (\n                  <Box key={index}>\n                    <ProfileTypeRadioField\n                      data-cy={profileType.name}\n                      value={profileType}\n                      name=\"type\"\n                      isSelected={profileType.name === props.input.value}\n                      onChange={(v) => props.input.onChange(v)}\n                      textLabel={profileType.displayName}\n                    />\n                  </Box>\n                ))}\n            </Grid>\n          </Flex>\n        </ProfileSection>\n      )}\n    />\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/PublicContact.section.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { render, screen } from '@testing-library/react';\nimport { SettingsFormProvider } from 'src/test/components/SettingsFormProvider';\nimport { describe, expect, it } from 'vitest';\n\nimport { PublicContactSection } from './PublicContact.section';\n\ndescribe('PublicContact', () => {\n  it('renders unchecked when isContactable is false', async () => {\n    const isContactable = false;\n\n    render(\n      <SettingsFormProvider>\n        <PublicContactSection isContactable={isContactable} />\n      </SettingsFormProvider>,\n    );\n\n    expect(screen.getByTestId('isContactable')).not.toBeChecked();\n  });\n\n  it('renders checked when isContactable is true', async () => {\n    const isContactable = true;\n\n    render(\n      <SettingsFormProvider>\n        <PublicContactSection isContactable={isContactable} />\n      </SettingsFormProvider>,\n    );\n\n    expect(screen.getByTestId('isContactable')).toBeChecked();\n  });\n});\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/PublicContact.section.tsx",
    "content": "import { Field } from 'react-final-form';\nimport { fields } from 'src/pages/UserSettings/labels';\nimport { Flex, Heading, Switch, Text } from 'theme-ui';\n\ntype PublicContactSectionProps = {\n  isContactable: boolean;\n};\n\nexport const PublicContactSection = ({ isContactable }: PublicContactSectionProps) => {\n  const { description, placeholder, title } = fields.publicContentPreference;\n  const name = 'isContactable';\n\n  return (\n    <Flex\n      data-testid=\"PublicContactSection\"\n      data-cy=\"PublicContactSection\"\n      sx={{\n        flexDirection: 'column',\n        gap: 2,\n      }}\n    >\n      <a id=\"public-contact\"></a>\n      <Heading as=\"h2\">{title}</Heading>\n      <Text variant=\"quiet\" sx={{ fontSize: 2 }}>\n        {description}\n      </Text>\n      <Field name={name}>\n        {({ input }) => {\n          return (\n            <Switch\n              checked={isContactable}\n              data-cy={`${name}-${isContactable}`}\n              data-testid={name}\n              label={placeholder}\n              onChange={() => input.onChange(!isContactable)}\n              sx={{\n                'input:checked ~ &': {\n                  backgroundColor: 'green',\n                },\n              }}\n            />\n          );\n        }}\n      </Field>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/UserImages.section.tsx",
    "content": "import type { FormApi } from 'final-form';\nimport { observer } from 'mobx-react';\nimport { ImageInputV2 } from 'oa-components';\nimport type { ProfileFormData } from 'oa-shared';\nimport { commonStyles } from 'oa-themes';\nimport { useState } from 'react';\nimport { Field } from 'react-final-form';\nimport { fields, headings } from 'src/pages/UserSettings/labels';\nimport { storageService } from 'src/services/storageService';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { Box, Flex, Heading, Spinner, Text } from 'theme-ui';\n\ninterface IProps {\n  values: ProfileFormData;\n  isMemberProfile: boolean;\n  form: FormApi<ProfileFormData, Partial<ProfileFormData>>;\n}\n\nexport const UserImagesSection = observer(({ isMemberProfile, values, form }: IProps) => {\n  const [isUploadingPhoto, setIsUploadingPhoto] = useState(false);\n  const [uploadingCoverIndex, setUploadingCoverIndex] = useState<number | null>(null);\n  const { profile } = useProfileStore();\n  const [coverError, setCoverError] = useState<string | null>(null);\n  const [photoError, setPhotoError] = useState<string | null>(null);\n\n  // Always show 4 inputs: filled images first, then empty slots\n  const filledImages = (values.coverImages || []).filter((img) => img);\n  const emptySlots = Array(4 - filledImages.length).fill(null);\n  const coverImageSlots = [...filledImages, ...emptySlots];\n\n  const handlePhotoSelect = async (file: File | undefined) => {\n    if (!file) {\n      form.change('photo', undefined);\n      return;\n    }\n\n    try {\n      setIsUploadingPhoto(true);\n      const uploadedImage = await storageService.imageUpload(profile!.id, 'profiles', file);\n      form.change('photo', uploadedImage);\n    } catch (error) {\n      console.error('Error uploading photo:', error);\n    } finally {\n      setIsUploadingPhoto(false);\n    }\n  };\n\n  const handleCoverImageSelect = async (file: File | undefined, index: number) => {\n    if (!file) {\n      // Remove image at index\n      const updatedImages = coverImageSlots.filter((_, i) => i !== index).filter((img) => img);\n      form.change('coverImages', updatedImages);\n      return;\n    }\n\n    try {\n      setUploadingCoverIndex(index);\n      const uploadedImage = await storageService.imageUpload(profile!.id, 'profiles' as any, file);\n\n      const updatedSlots = [...coverImageSlots];\n      updatedSlots[index] = uploadedImage;\n\n      // Store only filled images, maintaining order\n      const updatedImages = updatedSlots.filter((img) => img);\n      form.change('coverImages', updatedImages);\n    } catch (error) {\n      console.error('Error uploading cover image:', error);\n    } finally {\n      setUploadingCoverIndex(null);\n    }\n  };\n\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: 3 }}>\n      <Heading as=\"h2\">{isMemberProfile ? fields.userImage.title : headings.images}</Heading>\n\n      <Flex sx={{ flexDirection: 'column', alignContent: 'stretch', gap: 1 }}>\n        {!isMemberProfile && (\n          <Heading variant=\"subHeading\">\n            {fields.userImage.title} <Text color=\"red\">*</Text>\n          </Heading>\n        )}\n        <Text variant=\"paragraph\">{fields.userImage.description}</Text>\n        {photoError && (\n          <Text data-cy=\"photo-error\" sx={{ color: 'error', fontSize: 1, mb: 2, width: '100%' }}>\n            {photoError}\n          </Text>\n        )}\n        <Box\n          data-cy=\"userImage\"\n          data-testid=\"photo\"\n          sx={{\n            width: '120px',\n            height: '120px',\n          }}\n        >\n          {isUploadingPhoto ? (\n            <Flex sx={{ justifyContent: 'center', alignItems: 'center', height: '100%' }}>\n              <Spinner size={32} sx={{ color: commonStyles.colors.darkGrey }} />\n            </Flex>\n          ) : (\n            <Box sx={{ position: 'relative', width: '100%', height: '100%' }}>\n              <ImageInputV2\n                image={values.photo}\n                onFilesChange={handlePhotoSelect}\n                onError={setPhotoError}\n              />\n            </Box>\n          )}\n        </Box>\n      </Flex>\n\n      {!isMemberProfile && (\n        <Flex data-testid=\"coverImages\" sx={{ flexDirection: 'column', gap: 1 }}>\n          <Heading variant=\"subHeading\">\n            {fields.coverImages.title} <Text color=\"red\">*</Text>\n          </Heading>\n          <Text variant=\"paragraph\">{fields.coverImages.description}</Text>\n\n          {coverError && (\n            <Text data-cy=\"cover-error\" sx={{ color: 'error', fontSize: 1, mb: 2, width: '100%' }}>\n              {coverError}\n            </Text>\n          )}\n          <Field name=\"coverImages\">\n            {({ input }) => (\n              <Flex>\n                {coverImageSlots.map((image, index) => (\n                  <Box\n                    sx={{\n                      width: '150px',\n                      height: '100px',\n                      marginRight: '10px',\n                      position: 'relative',\n                    }}\n                    key={`cover-image-${index}`}\n                    data-cy={`coverImages-${index}`}\n                  >\n                    {uploadingCoverIndex === index ? (\n                      <Flex sx={{ justifyContent: 'center', alignItems: 'center', height: '100%' }}>\n                        <Spinner size={20} sx={{ color: commonStyles.colors.darkGrey }} />\n                      </Flex>\n                    ) : (\n                      <ImageInputV2\n                        image={image}\n                        onFilesChange={(file: File | undefined) =>\n                          handleCoverImageSelect(file, index)\n                        }\n                        onError={setCoverError}\n                      />\n                    )}\n                  </Box>\n                ))}\n              </Flex>\n            )}\n          </Field>\n        </Flex>\n      )}\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/UserInfos.section.tsx",
    "content": "import { getCountryDataList, getEmojiFlag } from 'countries-list';\nimport { observer } from 'mobx-react';\nimport { FieldInput, FieldTextarea, Username } from 'oa-components';\nimport type { ProfileFormData } from 'oa-shared';\nimport { UserRole } from 'oa-shared';\nimport { Field } from 'react-final-form';\nimport { SelectField } from 'src/common/Form/Select.field';\nimport { fields, headings } from 'src/pages/UserSettings/labels';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport {\n  composeValidators,\n  noSpecialCharacters,\n  required,\n  validateUrl,\n} from 'src/utils/validators';\nimport { Flex, Heading, Text } from 'theme-ui';\nimport {\n  GROUP_PROFILE_DESCRIPTION_MAX_LENGTH,\n  MEMBER_PROFILE_DESCRIPTION_MAX_LENGTH,\n} from '../../constants';\nimport { ProfileSection } from '../elements';\nimport { ProfileTags } from './ProfileTags.section';\n\nconst countryOptions = getCountryDataList().map((country) => ({\n  label: `${getEmojiFlag(country.iso2)} ${country.native}`,\n  value: country.iso2,\n}));\n\ninterface IProps {\n  formValues: Partial<ProfileFormData>;\n}\n\nexport const UserInfosSection = observer(({ formValues }: IProps) => {\n  const { profile, isUserAuthorized } = useProfileStore();\n\n  const isMemberProfile = !profile?.type?.isSpace;\n  const isAdmin = isUserAuthorized(UserRole.ADMIN);\n  const isUsernameLocked = !!profile?.username && !isAdmin;\n  const { about, country, displayName, userName, website } = fields;\n\n  return (\n    <ProfileSection>\n      <Flex data-testid=\"UserInfosSection\" sx={{ flexDirection: 'column', gap: [3, 5] }}>\n        <Heading as=\"h2\">{headings.infos}</Heading>\n        <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n          <Text>\n            {userName.title} <Text color=\"red\">*</Text>\n          </Text>\n          <Text variant=\"quiet\" sx={{ fontSize: 2 }}>\n            {userName.description}\n          </Text>\n          <Field\n            data-cy=\"username\"\n            name=\"username\"\n            component={FieldInput}\n            placeholder=\"your username\"\n            disabled={isUsernameLocked}\n            validate={composeValidators(required, noSpecialCharacters)}\n            validateFields={[]}\n          />\n        </Flex>\n\n        <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n          <Text>\n            {displayName.title} <Text color=\"red\">*</Text>\n          </Text>\n          <Text variant=\"quiet\" sx={{ fontSize: 2 }}>\n            {displayName.description}\n          </Text>\n          <Field\n            data-cy=\"displayName\"\n            name=\"displayName\"\n            component={FieldInput}\n            placeholder=\"Pick a name to display on your profile\"\n            validate={required}\n            validateFields={[]}\n          />\n        </Flex>\n\n        <ProfileTags typeName={formValues.type || ''} />\n\n        <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n          <Text>\n            {about.title} <Text color=\"red\">*</Text>\n          </Text>\n          <Field\n            data-cy=\"info-about\"\n            name=\"about\"\n            component={FieldTextarea}\n            showCharacterCount\n            maxLength={\n              isMemberProfile\n                ? MEMBER_PROFILE_DESCRIPTION_MAX_LENGTH\n                : GROUP_PROFILE_DESCRIPTION_MAX_LENGTH\n            }\n            placeholder={about.placeholder}\n            validate={required}\n            validateFields={[]}\n          />\n        </Flex>\n\n        <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n          <Text>{country.title}</Text>\n          <Field data-cy=\"country-dropdown\" name=\"country\">\n            {(field) => (\n              <SelectField\n                options={countryOptions}\n                placeholder=\"Select your country...\"\n                {...field}\n              />\n            )}\n          </Field>\n          <Flex sx={{ gap: 1, alignItems: 'center' }}>\n            <Text sx={{ fontSize: 1 }} variant=\"quiet\">\n              Preview:\n            </Text>\n            {formValues.username && (\n              <Username\n                user={{\n                  ...profile,\n                  username: formValues.username,\n                  country: formValues.country,\n                }}\n              />\n            )}\n          </Flex>\n        </Flex>\n\n        <Flex sx={{ flexDirection: 'column', gap: 1 }}>\n          <Text>{website.title}</Text>\n          <Field\n            data-cy=\"website\"\n            name=\"website\"\n            component={FieldInput}\n            placeholder=\"https://\"\n            validate={validateUrl}\n            validateFields={[]}\n          />\n        </Flex>\n      </Flex>\n    </ProfileSection>\n  );\n});\n"
  },
  {
    "path": "src/pages/UserSettings/content/sections/VisitorSection.tsx",
    "content": "import { FieldTextarea, Select, visitorDisplayData } from 'oa-components';\nimport type { Profile, UserVisitorPreferencePolicy } from 'oa-shared';\nimport { userVisitorPreferencePolicies } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { Field } from 'react-final-form';\nimport { FieldContainer } from 'src/common/Form/FieldContainer';\nimport { Flex, Heading, Switch, Text } from 'theme-ui';\nimport { fields, headings } from '../../labels';\n\ninterface Props {\n  visitorPolicy?: Partial<Profile['visitorPolicy']>;\n}\n\nconst visitorPolicyOptions = userVisitorPreferencePolicies.map((policy) => ({\n  value: policy,\n  label: visitorDisplayData.get(policy)?.label || policy,\n}));\n\nfunction findPolicy(policyValue: UserVisitorPreferencePolicy) {\n  return visitorPolicyOptions.find(({ value }) => value === policyValue);\n}\n\nconst { title: preferenceTitle, description: preferenceDescription } = fields.visitorPreference;\nconst { title: policyTitle } = fields.visitorPolicy;\nconst { title: policyDetailsTitle, placeholder: policyDetailsPlaceholder } = fields.visitorDetails;\nconst { visitors } = headings;\n\nexport const VisitorSection = ({ visitorPolicy }: Props) => {\n  const [isOpen, setIsOpen] = useState<boolean>(false);\n\n  useEffect(() => {\n    setIsOpen(!!visitorPolicy);\n  }, [visitorPolicy]);\n\n  return (\n    <Flex\n      data-testid=\"VisitorSection\"\n      data-cy=\"VisitorSection\"\n      sx={{\n        flexDirection: 'column',\n        gap: 2,\n      }}\n    >\n      <Heading as=\"h2\">{visitors}</Heading>\n      <Text variant=\"quiet\" sx={{ fontSize: 2 }}>\n        {preferenceDescription}\n      </Text>\n      <Field name=\"showVisitorPolicy\">\n        {({ input }) => {\n          return (\n            <Switch\n              checked={isOpen}\n              data-testid=\"openToVisitors-switch\"\n              label={preferenceTitle}\n              onChange={() => {\n                input.onChange(isOpen ? null : visitorPolicy || { policy: 'open' });\n              }}\n              sx={{\n                'input:checked ~ &': {\n                  backgroundColor: 'green',\n                },\n              }}\n            />\n          );\n        }}\n      </Field>\n      {visitorPolicy && (\n        <>\n          <Text>{policyTitle} *</Text>\n          <FieldContainer data-cy=\"openToVisitors-policy\">\n            <Field name=\"visitorPreferencePolicy\">\n              {({ input }) => {\n                return (\n                  <Select\n                    options={visitorPolicyOptions}\n                    defaultValue={findPolicy(visitorPolicy.policy || 'open')}\n                    onChange={({ value }) => input.onChange(value)}\n                  />\n                );\n              }}\n            </Field>\n          </FieldContainer>\n          <Text>{policyDetailsTitle}</Text>\n          <Field\n            name=\"visitorPreferenceDetails\"\n            data-cy=\"openToVisitors-details\"\n            component={FieldTextarea}\n            placeholder={policyDetailsPlaceholder}\n            value={visitorPolicy.details}\n          />\n        </>\n      )}\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/UserSettings/labels.ts",
    "content": "import type { ILabels } from 'src/common/Form/types';\n\nexport const buttons = {\n  changeEmail: 'Change email',\n  changePassword: 'Change password',\n  deleteLink: {\n    message: 'Are you sure you want to delete this link?',\n    text: 'Delete',\n  },\n  impact: {\n    create: 'Add data now',\n    edit: 'Edit data',\n    expandOpen: 'Expand and edit',\n    expandClose: 'Close',\n    save: 'Save impact data',\n  },\n  guidelines: 'Check out our guidelines',\n  link: {\n    add: 'Add link',\n    type: 'type',\n  },\n  map: 'Add a map pin',\n  notifications: 'Update notifications',\n  editPin: 'Save map pin',\n  removePin: 'Remove map pin',\n  save: 'Save profile',\n  success: 'Profile saved successfully',\n  submit: 'Submit',\n  submitNewEmail: 'Save new email address',\n  submitNewPassword: 'Save new password',\n};\n\nexport const fields: ILabels = {\n  activities: {\n    description: 'Choose your main activity. Not sure?',\n    error: 'Please select a focus',\n    title: 'What is your main activity?',\n  },\n  about: {\n    title: 'Tell us a bit about yourself',\n    placeholder:\n      \"Describe in details what you do and who you are. Write in English otherwise your profile won't be approved.\",\n  },\n  country: {\n    title: 'Country',\n    description:\n      \"Thought about adding yourself to our map? If you do, we'll automatically set this field.\",\n  },\n  coverImages: {\n    description:\n      \"They're shown at the top your profile and helps us evaluate your account. Make sure the first image shows your space. Best size is 1920x1080.\",\n    title: 'Add profile cover image(s)',\n  },\n  userImage: {\n    description: 'Visible on your profile and comments, best to upload as a square image.',\n    title: 'Add an avatar',\n  },\n  deleteAccount: {\n    description: 'Please reach out to support.',\n    title: 'Would you like to delete your account?',\n  },\n  displayName: {\n    title: 'Display Name',\n    description: 'Shown on your profile page. You can use spaces and everything!',\n  },\n  email: {\n    title: 'Current email address',\n  },\n  emailNotifications: {\n    description: \"We can send you emails with all the notifications you've missed.\",\n    title: 'Email notifications',\n  },\n  impact: {\n    description:\n      \"Let's track our collective positive impact! Add data about your recycling work and show the world the power of a movement of small scale recyclers!\",\n    title: 'Positive impact',\n  },\n  website: {\n    title: 'Website',\n  },\n  location: {\n    error: 'Please select your location',\n    title: 'Your location (flag)',\n  },\n  newEmail: {\n    placeholder: 'New email address',\n    title: 'New email address',\n  },\n  newPassword: {\n    title: 'New password',\n    placeholder: 'New password',\n  },\n  oldPassword: {\n    title: 'Old password',\n    placeholder: 'Old password',\n  },\n  password: {\n    title: 'Password',\n  },\n  publicContentPreference: {\n    title: 'Contact Preference',\n    description:\n      \"Regardless of your email notifications setting, do you want people to be able to contact you? We'll email you their message whenever they do.\",\n    placeholder: 'I want people to be able to contact me',\n  },\n  repeatNewPassword: {\n    title: 'Repeat new password',\n    placeholder: 'Repeat new password',\n  },\n  userName: {\n    title: 'Username',\n    description:\n      'Your unique identifier. Used in your profile URL. Once set, it cannot be changed.',\n  },\n  tags: {\n    description: 'What are your main activities? (choose max five)',\n    title: 'Tags',\n  },\n  visitorDetails: {\n    title: 'Specify your visitor status',\n    placeholder:\n      'Optionally describe details that help people understand your visitor policy.  For example when or how to reach you, or when you expect to change your status.',\n  },\n  visitorPolicy: {\n    title: 'Visitor policy',\n  },\n  visitorPreference: {\n    title: 'Show my visitor policy',\n    description:\n      'This will be visible in your public profile. You can specify the opening hours, conditions or other details in the field below',\n  },\n};\n\nexport const form = {\n  defaultError: 'This field is required',\n  saveSuccess: 'Yay! Impact data saved.',\n  saveNotificationPreferences: 'Whoop. Preferences updated.',\n};\n\nexport const headings = {\n  accountSettings: 'Account settings',\n  changeEmail: 'Change Email',\n  changePassword: 'Change Password',\n  createProfile: 'Create profile',\n  editProfile: 'Edit profile',\n  focus: 'Focus',\n  images: 'Images',\n  infos: 'Infos',\n  map: {\n    description:\n      'Add yourself to the map as an individual who wants to get started. Find local community members and meetup to join forces and collaborate.',\n    yourPinTitle: 'Your map pin',\n    existingPinLabel: 'The map pin you registered has the following description:',\n  },\n  visitors: 'Visitors',\n  workspace: {\n    title: 'Your map pin',\n  },\n};\n\nexport const notificationForm = {\n  loading: 'Loading your notification setting',\n  successfulSave: 'Notification setting saved successfully - whoop',\n};\n\nexport const mapForm = {\n  confirmDeletePin:\n    'If you delete your location now, adding a new map pin in the future might need approving.',\n  descriptionMember: 'Add yourself to the map so that people can reach out and collaborate!',\n  descriptionSpace: \"Map pins undergo moderator's approval which might take several days.\",\n  loading: 'Loading your map pin',\n  locationLabel: 'Your current map pin is here:',\n  needsChanges:\n    'This map pin has been marked as requiring further changes. Specifically the moderator comments are:',\n  noLocationLabel: 'No map pin currently saved',\n  successfulSave: 'Map pin saved successfully - whoop',\n  successfulDelete: 'Location data removed',\n};\n\nexport const missingData = 'Do you have impact data for this year?';\n\nexport const inCompleteProfile =\n  'In order to add yourself to the map, you need to complete your profile';\n"
  },
  {
    "path": "src/pages/UserSettings/services/account.service.ts",
    "content": "const changeEmail = async (email: string, password: string) => {\n  const data = new FormData();\n  data.append('email', email);\n  data.append('password', password);\n\n  const response = await fetch('/api/account/change-email', {\n    method: 'POST',\n    body: data,\n  });\n\n  if (!response.ok) {\n    const errorData = await response.json().catch(() => null);\n    throw new Error(errorData?.error || 'Failed to change email');\n  }\n\n  return response;\n};\n\nconst changePassword = async (oldPassword: string, newPassword: string) => {\n  const data = new FormData();\n  data.append('oldPassword', oldPassword);\n  data.append('newPassword', newPassword);\n\n  const response = await fetch('/api/account/change-password', {\n    method: 'POST',\n    body: data,\n  });\n\n  if (!response.ok) {\n    const errorData = await response.json().catch(() => null);\n    throw new Error(errorData?.error || 'Failed to change password');\n  }\n\n  return response;\n};\n\nexport const accountService = {\n  changeEmail,\n  changePassword,\n};\n"
  },
  {
    "path": "src/pages/UserSettings/types.ts",
    "content": "import type { availableGlyphs } from 'oa-components';\nimport type { ComponentType } from 'react';\n\nexport interface ISettingsTab {\n  header?: React.ReactNode;\n  body: ComponentType;\n  glyph: availableGlyphs;\n  title: string;\n  route: string;\n}\n"
  },
  {
    "path": "src/pages/UserSettings/utils.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { sortImpactYearDisplayFields, transformImpactData, transformImpactInputs } from './utils';\n\ndescribe('transformImpactData', () => {\n  it('returns data structured as field inputs', () => {\n    const description = 'What was your annual revenue (in USD)?';\n\n    const impactFields = [\n      {\n        id: 'revenue',\n        value: 75000,\n        isVisible: false,\n      },\n    ];\n    const expected = {\n      revenue: {\n        id: 'revenue',\n        icon: 'revenue',\n        description,\n        label: 'revenue',\n        prefix: 'USD',\n        value: 75000,\n        isVisible: false,\n      },\n    };\n    expect(transformImpactData(impactFields)).toEqual(expected);\n  });\n});\n\ndescribe('transformImpactInputs', () => {\n  it('returns data structured as impact data', () => {\n    const description = 'How many machines did you build?';\n\n    const inputFields = {\n      machines: {\n        id: 'machines',\n        description,\n        label: 'machines built',\n        value: 20,\n        isVisible: true,\n      },\n    };\n\n    const expected = [\n      {\n        id: 'machines',\n        value: 20,\n        isVisible: true,\n      },\n    ];\n    expect(transformImpactInputs(inputFields)).toEqual(expected);\n  });\n});\n\ndescribe('sortImpactYearDisplayFields', () => {\n  it('sorts impact data in the same order as impact questions', () => {\n    const fields = [\n      {\n        id: 'machines',\n        value: 15,\n        isVisible: true,\n      },\n      {\n        id: 'revenue',\n        value: 2000,\n        isVisible: true,\n      },\n      { id: 'plastic', value: 30000, isVisible: true },\n    ];\n\n    const expected = [\n      { id: 'plastic', value: 30000, isVisible: true },\n      { id: 'revenue', value: 2000, isVisible: true },\n      { id: 'machines', value: 15, isVisible: true },\n    ];\n    expect(sortImpactYearDisplayFields(fields)).toEqual(expected);\n  });\n});\n"
  },
  {
    "path": "src/pages/UserSettings/utils.ts",
    "content": "import type { IImpactDataField } from 'oa-shared';\nimport type { IImpactQuestion } from './content/impactQuestions';\nimport { impactQuestions } from './content/impactQuestions';\n\nexport interface ImpactDataFieldInputs {\n  [key: string]: IImpactQuestion;\n}\n\ninterface IInputs {\n  [key: string]: IImpactDataField;\n}\n\nexport const transformImpactData = (fields: IImpactDataField[]): ImpactDataFieldInputs => {\n  const questionMap = new Map(impactQuestions.map((q) => [q.id, q]));\n  const transformed = {} as ImpactDataFieldInputs;\n\n  for (const field of fields) {\n    const questionField = questionMap.get(field.id);\n    if (questionField) {\n      transformed[field.id] = {\n        ...questionField,\n        ...field,\n      } as IImpactQuestion;\n    }\n  }\n\n  return transformed;\n};\n\nexport const transformImpactInputs = (inputs: IInputs): IImpactDataField[] => {\n  const questionMap = new Map(impactQuestions.map((q) => [q.id, q]));\n  const fields: IImpactDataField[] = [];\n\n  for (const [key, field] of Object.entries(inputs)) {\n    if (field?.value && questionMap.has(key)) {\n      fields.push({\n        id: key,\n        value: field.value,\n        isVisible: field.isVisible ?? true,\n      });\n    }\n  }\n\n  return fields;\n};\n\nexport const sortImpactYearDisplayFields = (\n  fields: IImpactDataField[] | undefined,\n): IImpactDataField[] => {\n  if (!fields) {\n    return [];\n  }\n\n  const fieldMap = new Map(fields.map((field) => [field.id, field]));\n  const sortedFields: IImpactDataField[] = [];\n\n  for (const question of impactQuestions) {\n    const field = fieldMap.get(question.id);\n    if (field) {\n      sortedFields.push(field);\n    }\n  }\n\n  return sortedFields;\n};\n"
  },
  {
    "path": "src/pages/common/Breadcrumbs/Breadcrumbs.tsx",
    "content": "import { Breadcrumbs as BreadcrumbsComponent } from 'oa-components';\nimport { Flex } from 'theme-ui';\n\ntype BreadcrumbStep = { text: string; link?: string };\n\ninterface BreadcrumbsProps {\n  children?: React.ReactNode;\n  steps: BreadcrumbStep[];\n}\n\nexport const Breadcrumbs = (props: BreadcrumbsProps) => {\n  const { steps } = props;\n\n  return (\n    <Flex\n      sx={{\n        alignItems: 'baseline',\n        justifyContent: 'space-between',\n        flexDirection: 'row',\n        flexWrap: 'wrap',\n      }}\n    >\n      <Flex\n        sx={{\n          flex: ['none', 'none', 1],\n          overflowX: 'auto',\n          width: '100%',\n          scrollbarWidth: 'none',\n          '&::-webkit-scrollbar': { display: 'none' },\n        }}\n      >\n        <BreadcrumbsComponent steps={steps} />\n      </Flex>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/Category/CategoriesSelectV2.tsx",
    "content": "import { Select } from 'oa-components';\nimport type { SelectValue } from 'oa-shared';\nimport { FieldContainer } from '../../../common/Form/FieldContainer';\n\nexport type CategoriesSelectProps = {\n  value: SelectValue | null;\n  placeholder: string;\n  isForm: boolean;\n  categories: SelectValue[];\n  onChange: (value: SelectValue) => void;\n  invalid?: boolean;\n};\n\nconst getVariant = (isForm: boolean, invalid: boolean) => {\n  if (!isForm) return undefined;\n  return invalid ? 'formError' : 'form';\n};\n\nexport const CategoriesSelectV2 = ({\n  value,\n  placeholder,\n  isForm,\n  categories,\n  onChange,\n  invalid = false,\n}: CategoriesSelectProps) => {\n  const handleChange = (changedValue) => {\n    onChange(changedValue ?? null);\n  };\n\n  return (\n    <FieldContainer\n      invalid={invalid}\n      data-cy={categories ? 'category-select' : 'category-select-empty'}\n    >\n      <Select\n        variant={getVariant(isForm, invalid)}\n        options={categories}\n        placeholder={placeholder}\n        value={value}\n        onChange={handleChange}\n        isClearable={true}\n      />\n    </FieldContainer>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/CommentsSupabase/CollapsableCommentSection.tsx",
    "content": "import type { ResearchUpdate } from 'oa-shared';\nimport { useEffect, useMemo, useState } from 'react';\nimport { useLocation } from 'react-router';\nimport { Box, Button } from 'theme-ui';\nimport { CommentSectionSupabase } from './CommentSectionSupabase';\n\ntype Props = {\n  authors: number[];\n  open: boolean;\n  total: number;\n  researchUpdate: ResearchUpdate;\n};\n\nconst CollapsableCommentSection = (props: Props) => {\n  const { authors, open, total, researchUpdate } = props;\n\n  const [isOpen, setIsOpen] = useState(() => open || false);\n  const location = useLocation();\n\n  const buttonText = useMemo(() => {\n    if (!isOpen) {\n      switch (total) {\n        case 0:\n          return 'Start a discussion';\n        case 1:\n          return 'View 1 comment';\n        default:\n          return `View ${total} comments`;\n      }\n    }\n\n    return 'Collapse Comments';\n  }, [isOpen]);\n\n  useEffect(() => {\n    const searchParams = new URLSearchParams(location.search);\n    const hash = location.hash;\n\n    // Check if there's an update_N parameter and extract the ID after the underscore\n    const updateParam = Array.from(searchParams.keys()).find((key) => key.startsWith('update_'));\n    const hasMatchingUpdate =\n      updateParam && updateParam.split('_')[1] === researchUpdate.id.toString();\n\n    // Check if there's a #comment:N hash\n    const hasCommentHash = hash.startsWith('#comment:');\n\n    if (hasMatchingUpdate && hasCommentHash) {\n      setIsOpen(true);\n    }\n  }, [location?.search, location?.hash, researchUpdate.id]);\n\n  return (\n    <Box\n      data-cy=\"CollapsableCommentSection\"\n      sx={{\n        backgroundColor: isOpen ? '#e2edf7' : 'inherit',\n        borderTop: '2px solid #111',\n        padding: 2,\n        transition: 'background-color 120ms ease-out',\n      }}\n    >\n      <Button\n        type=\"button\"\n        variant=\"subtle\"\n        sx={{\n          fontSize: '14px',\n          width: '100%',\n          textAlign: 'center',\n          display: 'block',\n          marginBottom: isOpen ? 2 : 0,\n          '&:hover': { bg: '#ececec' },\n        }}\n        onClick={() => setIsOpen((prev) => !prev)}\n        backgroundColor={isOpen ? '#c2daf0' : '#e2edf7'}\n        className={isOpen ? 'viewComments' : ''}\n        data-cy=\"HideDiscussionContainer:button\"\n      >\n        {buttonText}\n      </Button>\n      {isOpen && (\n        <CommentSectionSupabase\n          sourceId={researchUpdate.id}\n          sourceType=\"research_updates\"\n          authors={authors}\n        />\n      )}\n    </Box>\n  );\n};\n\nexport default CollapsableCommentSection;\n"
  },
  {
    "path": "src/pages/common/CommentsSupabase/CommentItemSupabase.tsx",
    "content": "import { observer } from 'mobx-react';\nimport {\n  ActionSet,\n  Button,\n  ButtonShowReplies,\n  CommentDisplay,\n  ConfirmModal,\n  EditComment,\n  FollowButton,\n  FollowIcon,\n  Modal,\n} from 'oa-components';\nimport type { Comment, DiscussionContentType } from 'oa-shared';\nimport { UserRole } from 'oa-shared';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { useSubscription } from 'src/stores/Subscription/useSubscription';\nimport { useUsefulVote } from 'src/stores/UsefulVote/useUsefulVote';\nimport { Card, Flex } from 'theme-ui';\nimport { CommentReply } from './CommentReplySupabase';\nimport { CreateCommentSupabase } from './CreateCommentSupabase';\nimport { useCopyCommentLink } from './useCopyCommentLink';\n\nexport interface ICommentItemProps {\n  comment: Comment;\n  onEdit: (id: number, comment: string) => Promise<Response>;\n  onDelete: (id: number) => void;\n  onReply: (reply: string) => void;\n  onEditReply: (id: number, reply: string) => Promise<Response>;\n  onDeleteReply: (id: number) => void;\n  updateUsefulCount?: (id: number, newVoteCount: number) => void;\n  sourceType: DiscussionContentType;\n}\n\nexport const CommentItemSupabase = observer((props: ICommentItemProps) => {\n  const {\n    comment,\n    onEdit,\n    onDelete,\n    onReply,\n    onEditReply,\n    onDeleteReply,\n    updateUsefulCount,\n    sourceType,\n  } = props;\n  const commentRef = useRef<HTMLDivElement>(null);\n  const [showEditModal, setShowEditModal] = useState(false);\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n  const [showReplies, setShowReplies] = useState(\n    () => !!comment.replies?.some((x) => x.highlighted),\n  );\n  const { profile } = useProfileStore();\n  const {\n    hasVoted,\n    usefulCount,\n    toggle: toggleVote,\n  } = useUsefulVote('comments', comment.id, comment.voteCount ?? 0);\n  const { isSubscribed: isFollowingReplies, toggle: toggleFollowReplies } = useSubscription(\n    'comments',\n    comment.id,\n  );\n\n  const isEditable = useMemo(() => {\n    return (\n      profile?.username === comment.createdBy?.username || profile?.roles?.includes(UserRole.ADMIN)\n    );\n  }, [profile]);\n\n  const item = 'CommentItem';\n\n  // Update parent component with new vote count when it changes\n  useEffect(() => {\n    updateUsefulCount?.(comment.id, usefulCount);\n  }, [usefulCount, comment.id, updateUsefulCount]);\n\n  useEffect(() => {\n    if (comment.highlighted) {\n      commentRef.current?.scrollIntoView({\n        behavior: 'smooth',\n        block: 'center',\n      });\n    }\n  }, [comment.highlighted]);\n\n  const copyCommentLink = useCopyCommentLink(comment);\n\n  return (\n    <Flex\n      id={`comment:${comment.id}`}\n      data-cy={isEditable ? `OwnCommentItem` : 'CommentItem'}\n      sx={{ flexDirection: 'column' }}\n    >\n      <Card\n        sx={{ flexDirection: 'column', padding: 3, overflow: 'inherit' }}\n        ref={commentRef as any}\n        variant=\"borderless\"\n      >\n        <CommentDisplay\n          isEditable={isEditable}\n          itemType={item}\n          comment={comment}\n          actions={\n            <>\n              {!!profile && isFollowingReplies && (\n                <Flex sx={{ display: ['none', 'inline'] }}>\n                  <FollowIcon tooltip=\"Following replies\" />\n                </Flex>\n              )}\n              <ActionSet itemType=\"CommentItem\">\n                <FollowButton\n                  isLoggedIn={!!profile}\n                  isFollowing={isFollowingReplies}\n                  onFollowClick={toggleFollowReplies}\n                  labelFollow=\"Follow replies\"\n                  labelUnfollow=\"Unfollow replies\"\n                  variant=\"subtle\"\n                  sx={{ fontSize: 1 }}\n                />\n                {isEditable && (\n                  <Button\n                    type=\"button\"\n                    data-cy=\"CommentItem: edit button\"\n                    variant=\"subtle\"\n                    icon=\"edit\"\n                    onClick={() => setShowEditModal(true)}\n                    sx={{ fontSize: 1 }}\n                  >\n                    Edit\n                  </Button>\n                )}\n                <Button\n                  type=\"button\"\n                  data-cy=\"CommentItem: copy link button\"\n                  variant=\"subtle\"\n                  icon=\"copy-link\"\n                  onClick={copyCommentLink}\n                  sx={{ fontSize: 1 }}\n                >\n                  Copy Link\n                </Button>\n                {isEditable && (\n                  <Button\n                    type=\"button\"\n                    data-cy=\"CommentItem: delete button\"\n                    variant=\"subtle\"\n                    icon=\"delete\"\n                    onClick={() => setShowDeleteModal(true)}\n                    sx={{ fontSize: 1 }}\n                  >\n                    Delete\n                  </Button>\n                )}\n              </ActionSet>\n            </>\n          }\n          usefulButtonConfig={{\n            onUsefulClick: async () => await toggleVote(),\n            hasUserVotedUseful: hasVoted,\n            votedUsefulCount: usefulCount,\n            isLoggedIn: !!profile,\n          }}\n        />\n\n        <Flex\n          sx={{\n            alignItems: 'stretch',\n            flexDirection: 'column',\n            flex: 1,\n            gap: 4,\n            marginTop: 3,\n          }}\n        >\n          {showReplies && (\n            <>\n              {comment.replies?.map((x) => (\n                <CommentReply\n                  key={x.id}\n                  comment={x}\n                  onEdit={async (id: number, comment: string) => {\n                    return await onEditReply(id, comment);\n                  }}\n                  onDelete={(id: number) => onDeleteReply(id)}\n                />\n              ))}\n\n              <CreateCommentSupabase\n                onSubmit={(comment) => onReply(comment)}\n                sourceType={sourceType}\n                isReply\n              />\n            </>\n          )}\n          <ButtonShowReplies\n            isShowReplies={showReplies}\n            replies={(comment.replies || []) as any}\n            setIsShowReplies={() => setShowReplies(!showReplies)}\n          />\n        </Flex>\n      </Card>\n\n      <Modal width={600} isOpen={showEditModal} onDismiss={() => setShowEditModal(false)}>\n        <EditComment\n          comment={comment.comment}\n          handleSubmit={async (commentText) => {\n            return await onEdit(comment.id, commentText);\n          }}\n          setShowEditModal={setShowEditModal}\n          handleCancel={() => setShowEditModal(false)}\n          isReply={false}\n        />\n      </Modal>\n\n      <ConfirmModal\n        isOpen={showDeleteModal}\n        message=\"Are you sure you want to delete this comment?\"\n        confirmButtonText=\"Delete\"\n        handleCancel={() => setShowDeleteModal(false)}\n        handleConfirm={async () => {\n          onDelete(comment.id);\n          setShowDeleteModal(false);\n        }}\n        confirmVariant=\"destructive\"\n      />\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/common/CommentsSupabase/CommentReplySupabase.tsx",
    "content": "import { observer } from 'mobx-react';\nimport {\n  ActionSet,\n  Button,\n  CommentDisplay,\n  ConfirmModal,\n  EditComment,\n  Icon,\n  Modal,\n} from 'oa-components';\nimport type { Reply } from 'oa-shared';\nimport { UserRole } from 'oa-shared';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { useUsefulVote } from 'src/stores/UsefulVote/useUsefulVote';\nimport { Box, Flex, Text } from 'theme-ui';\nimport { useCopyCommentLink } from './useCopyCommentLink';\n\nconst DELETED_COMMENT = 'The original comment got deleted';\n\nexport interface ICommentItemProps {\n  comment: Reply;\n  onEdit: (id: number, comment: string) => Promise<Response>;\n  onDelete: (id: number) => void;\n}\n\nexport const CommentReply = observer(({ comment, onEdit, onDelete }: ICommentItemProps) => {\n  const commentRef = useRef<HTMLDivElement>(null);\n  const [showEditModal, setShowEditModal] = useState(false);\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n  const {\n    hasVoted,\n    usefulCount,\n    toggle: toggleVote,\n  } = useUsefulVote('comments', comment.id, comment.voteCount ?? 0);\n\n  const { profile: activeUser } = useProfileStore();\n\n  const isEditable = useMemo(() => {\n    return (\n      activeUser?.username === comment.createdBy?.username ||\n      activeUser?.roles?.includes(UserRole.ADMIN)\n    );\n  }, [activeUser, comment]);\n\n  useEffect(() => {\n    if (comment.highlighted) {\n      commentRef.current?.scrollIntoView({\n        behavior: 'smooth',\n        block: 'center',\n      });\n    }\n  }, [comment.highlighted]);\n\n  const item = 'ReplyItem';\n  const loggedInUser = activeUser;\n\n  const copyCommentLink = useCopyCommentLink(comment);\n\n  return (\n    <Flex>\n      <Box\n        sx={{\n          paddingTop: 1,\n          paddingRight: [0, 2],\n        }}\n      >\n        <Icon glyph=\"reply-outline\" />\n      </Box>\n      <Flex\n        id={`comment:${comment.id}`}\n        data-cy={isEditable ? `Own${item}` : item}\n        sx={{ flexDirection: 'column', width: '100%' }}\n      >\n        <Flex sx={{ gap: 2 }} ref={commentRef as any}>\n          {comment.deleted ? (\n            <Box\n              sx={{\n                marginBottom: 2,\n                border: `${comment.highlighted ? '2px dashed black' : 'none'}`,\n              }}\n              data-cy=\"deletedComment\"\n            >\n              <Text sx={{ color: 'grey' }}>[{DELETED_COMMENT}]</Text>\n            </Box>\n          ) : (\n            <CommentDisplay\n              isEditable={isEditable}\n              itemType={item}\n              comment={comment}\n              actions={\n                <ActionSet itemType=\"ReplyItem\">\n                  {isEditable && (\n                    <Button\n                      type=\"button\"\n                      data-cy=\"ReplyItem: edit button\"\n                      variant=\"subtle\"\n                      icon=\"edit\"\n                      onClick={() => setShowEditModal(true)}\n                      sx={{ fontSize: 1 }}\n                    >\n                      Edit\n                    </Button>\n                  )}\n                  <Button\n                    type=\"button\"\n                    data-cy=\"ReplyItem: copy link button\"\n                    variant=\"subtle\"\n                    icon=\"copy-link\"\n                    onClick={copyCommentLink}\n                    sx={{ fontSize: 1 }}\n                  >\n                    Copy Link\n                  </Button>\n                  {isEditable && (\n                    <Button\n                      type=\"button\"\n                      data-cy=\"ReplyItem: delete button\"\n                      variant=\"subtle\"\n                      icon=\"delete\"\n                      onClick={() => setShowDeleteModal(true)}\n                      sx={{ fontSize: 1 }}\n                    >\n                      Delete\n                    </Button>\n                  )}\n                </ActionSet>\n              }\n              usefulButtonConfig={{\n                onUsefulClick: async () => await toggleVote(),\n                hasUserVotedUseful: hasVoted,\n                votedUsefulCount: usefulCount,\n                isLoggedIn: !!loggedInUser,\n              }}\n            />\n          )}\n        </Flex>\n\n        <Modal width={600} isOpen={showEditModal} onDismiss={() => setShowEditModal(false)}>\n          <EditComment\n            comment={comment.comment}\n            handleSubmit={async (commentText) => {\n              return await onEdit(comment.id, commentText);\n            }}\n            setShowEditModal={setShowEditModal}\n            handleCancel={() => setShowEditModal(false)}\n            isReply={true}\n          />\n        </Modal>\n\n        <ConfirmModal\n          isOpen={showDeleteModal}\n          message=\"Are you sure you want to delete this comment?\"\n          confirmButtonText=\"Delete\"\n          handleCancel={() => setShowDeleteModal(false)}\n          handleConfirm={async () => {\n            onDelete(comment.id);\n            setShowDeleteModal(false);\n          }}\n          confirmVariant=\"destructive\"\n        />\n      </Flex>\n    </Flex>\n  );\n});\n"
  },
  {
    "path": "src/pages/common/CommentsSupabase/CommentSectionSupabase.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { AuthorsContext, CommentsTitle, FollowButton } from 'oa-components';\nimport type { DiscussionContentType, Reply } from 'oa-shared';\nimport { Comment } from 'oa-shared';\nimport type { Dispatch, SetStateAction } from 'react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport { useLocation } from 'react-router';\nimport { commentService } from 'src/services/commentService';\nimport { subscribersService } from 'src/services/subscribersService';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { useSubscription } from 'src/stores/Subscription/useSubscription';\nimport { Box, Button, Flex } from 'theme-ui';\nimport { CommentItemSupabase } from './CommentItemSupabase';\nimport { CommentSort } from './CommentSort';\nimport { CommentSortOption, CommentSortOptions } from './CommentSortOptions';\nimport { CreateCommentSupabase } from './CreateCommentSupabase';\n\ninterface IProps {\n  authors: Array<number>;\n  sourceId: number;\n  sourceType: DiscussionContentType;\n  setSubscribersCount?: Dispatch<SetStateAction<number>>;\n}\nconst commentPageSize = 10;\n\nexport const CommentSectionSupabase = observer((props: IProps) => {\n  const { authors, sourceId, sourceType } = props;\n\n  const [comments, setComments] = useState<Comment[]>([]);\n  const [commentLimit, setCommentLimit] = useState<number>(commentPageSize);\n  const [sortBy, setSortBy] = useState<CommentSortOption>(CommentSortOption.Oldest);\n  const { isSubscribed, toggle: toggleFollowReplies } = useSubscription(sourceType, sourceId);\n  const { profile } = useProfileStore();\n  const location = useLocation();\n\n  const displayedComments = useMemo(() => {\n    const sortFn = CommentSortOptions.getSortFn(sortBy);\n    const sorted = [...comments].sort(sortFn);\n    return sorted.slice(0, commentLimit);\n  }, [comments, commentLimit, sortBy]);\n\n  const remainingCommentsCount = useMemo(() => {\n    return Math.max(0, comments.length - commentLimit);\n  }, [comments.length, commentLimit]);\n\n  useEffect(() => {\n    const fetchComments = async () => {\n      try {\n        const comments = await commentService.getComments(sourceType, sourceId);\n        const highlightedCommentId = location.hash?.startsWith('#comment:')\n          ? location.hash.replace('#comment:', '')\n          : null;\n\n        if (highlightedCommentId) {\n          const highlightedComment = comments.find((x) => x.id === +highlightedCommentId);\n          if (highlightedComment) {\n            highlightedComment.highlighted = true;\n          } else {\n            // find in replies and set highlighted\n            const highlightedReply = comments\n              .flatMap((x) => x.replies)\n              .find((x) => x?.id === +highlightedCommentId);\n\n            if (highlightedReply) {\n              highlightedReply.highlighted = true;\n            }\n          }\n\n          // ensure highlighted comment is visible\n          const index = comments.findIndex(\n            (x) => x.highlighted || x.replies?.some((y) => y.highlighted),\n          );\n\n          if (index > 5) {\n            setCommentLimit(index + 1);\n          }\n        }\n\n        setComments(comments || []);\n      } catch (err) {\n        console.error(err);\n      }\n    };\n\n    fetchComments();\n  }, [sourceId, location?.hash]);\n\n  useEffect(() => {\n    if (location.hash && location.hash === '#discussion') {\n      const el = document.getElementById('discussion');\n      if (el) {\n        el.scrollIntoView({ behavior: 'smooth' });\n      }\n    }\n  }, [location.hash]);\n\n  const postComment = async (comment: string) => {\n    try {\n      const result = await commentService.postComment(sourceId, comment, sourceType);\n\n      if (result.status === 201) {\n        const newComment = new Comment(await result.json());\n        subscribersService.add(newComment.sourceType, Number(newComment.sourceId));\n\n        setComments((comments) => [...comments, newComment]);\n      }\n      return result;\n    } catch (err) {\n      console.error(err);\n      return err;\n    }\n  };\n\n  const editComment = async (id: number, comment: string) => {\n    try {\n      const result = await commentService.editComment(sourceId, id, comment);\n      const now = new Date();\n\n      if (result.status === 204) {\n        setComments((comments) =>\n          comments.map((x) => {\n            if (x.id === id) {\n              x.comment = comment;\n              x.modifiedAt = now;\n            }\n            return x;\n          }),\n        );\n      }\n      return result;\n    } catch (err) {\n      console.error(err);\n      return err;\n    }\n  };\n\n  const deleteComment = async (id: number) => {\n    try {\n      const result = await commentService.deleteComment(sourceId, id);\n\n      if (result.status === 204) {\n        setComments((comments) =>\n          comments.map((x) => {\n            if (x.id === id) {\n              x.deleted = true;\n            }\n            return x;\n          }),\n        );\n      }\n      return result;\n    } catch (err) {\n      console.error(err);\n    }\n  };\n\n  const postReply = async (id: number, reply: string) => {\n    try {\n      const result = await commentService.postComment(sourceId, reply, sourceType, id);\n\n      if (result.status === 201) {\n        const newReply = new Comment(await result.json()) as Reply;\n\n        setComments((comments) =>\n          comments.map((comment) => {\n            if (comment.id === id) {\n              comment.replies = [...(comment.replies || []), newReply];\n            }\n            return comment;\n          }),\n        );\n      }\n      return result;\n    } catch (err) {\n      console.error(err);\n    }\n  };\n\n  const editReply = async (id: number, replyText: string, parentId: number) => {\n    try {\n      const result = await commentService.editComment(sourceId, id, replyText);\n      const now = new Date();\n\n      if (result.status === 204) {\n        setComments((comments) =>\n          comments.map((comment) => {\n            if (comment.id === parentId) {\n              comment.replies = comment.replies?.map((reply) => {\n                if (reply.id === id) {\n                  reply.comment = replyText;\n                  reply.modifiedAt = now;\n                }\n                return reply;\n              });\n            }\n            return comment;\n          }),\n        );\n      }\n      return result;\n    } catch (err) {\n      console.error(err);\n      return err;\n    }\n  };\n\n  const deleteReply = async (id: number, parentId: number) => {\n    try {\n      const result = await commentService.deleteComment(sourceId, id);\n\n      if (result.status === 204) {\n        setComments((comments) =>\n          comments.map((comment) => {\n            if (comment.id === parentId) {\n              comment.replies = comment.replies?.map((reply) => {\n                if (reply.id === id) {\n                  reply.deleted = true;\n                }\n                return reply;\n              });\n            }\n            return comment;\n          }),\n        );\n      }\n      return result;\n    } catch (err) {\n      console.error(err);\n      return err;\n    }\n  };\n\n  const updateVoteCount = useCallback((id: number, newVoteCount: number) => {\n    setComments((comments) =>\n      comments.map((comment) => {\n        if (comment.id === id) {\n          comment.voteCount = newVoteCount;\n        }\n        return comment;\n      }),\n    );\n  }, []);\n\n  return (\n    <AuthorsContext.Provider value={{ authors }}>\n      <Flex sx={{ flexDirection: 'column', gap: 2 }} id=\"discussion\">\n        <Flex\n          sx={{\n            flexDirection: 'row',\n            flexWrap: 'wrap',\n            alignItems: 'center',\n            gap: 2,\n            containerType: 'inline-size',\n          }}\n        >\n          <Flex\n            sx={{\n              flexDirection: 'row',\n              flexWrap: 'wrap',\n              alignItems: 'center',\n              gap: 1,\n              justifyContent: 'space-between',\n              flex: '1 1 auto',\n            }}\n          >\n            <CommentsTitle comments={comments} />\n\n            <FollowButton\n              isFollowing={isSubscribed}\n              isLoggedIn={!!profile}\n              labelFollow=\"Follow Comments\"\n              labelUnfollow=\"Following Comments\"\n              onFollowClick={toggleFollowReplies}\n            />\n          </Flex>\n          {comments.length >= 5 && <CommentSort sortBy={sortBy} onSortChange={setSortBy} />}\n        </Flex>\n        {displayedComments.map((comment) => (\n          <Box key={comment.id}>\n            <CommentItemSupabase\n              comment={comment}\n              onEdit={editComment}\n              onDelete={deleteComment}\n              onReply={(reply) => postReply(comment.id, reply)}\n              onEditReply={(id, reply) => editReply(id, reply, comment.id)}\n              onDeleteReply={(id) => deleteReply(id, comment.id)}\n              updateUsefulCount={updateVoteCount}\n              sourceType={sourceType}\n            />\n          </Box>\n        ))}\n\n        {remainingCommentsCount > 0 && (\n          <Flex>\n            <Button\n              type=\"button\"\n              sx={{ margin: '0 auto' }}\n              variant=\"outline\"\n              data-cy=\"show-more-comments\"\n              onClick={() => setCommentLimit((prev) => prev + commentPageSize)}\n            >\n              {`show ${remainingCommentsCount} more comment${remainingCommentsCount === 1 ? '' : 's'}`}\n            </Button>\n          </Flex>\n        )}\n\n        <CreateCommentSupabase onSubmit={postComment} sourceType={sourceType} />\n      </Flex>\n    </AuthorsContext.Provider>\n  );\n});\n"
  },
  {
    "path": "src/pages/common/CommentsSupabase/CommentSort.tsx",
    "content": "import { Select } from 'oa-components';\nimport { FieldContainer } from 'src/common/Form/FieldContainer';\nimport { Flex } from 'theme-ui';\nimport type { CommentSortOption } from './CommentSortOptions';\nimport { CommentSortOptions } from './CommentSortOptions';\n\ninterface IProps {\n  sortBy: CommentSortOption;\n  onSortChange: (sortBy: CommentSortOption) => void;\n}\n\nexport const CommentSort = ({ sortBy, onSortChange }: IProps) => {\n  return (\n    <Flex\n      sx={{\n        minWidth: '160px',\n        flex: '0 0 auto',\n        '@container (max-width: 600px)': {\n          flex: '1 1 100%',\n          minWidth: '100%',\n        },\n      }}\n    >\n      <FieldContainer>\n        <div data-cy=\"comment-sort-select\">\n          <Select\n            options={CommentSortOptions.getOptions()}\n            value={{\n              label: CommentSortOptions.get(sortBy),\n              value: sortBy,\n            }}\n            onChange={(option) => onSortChange(option.value)}\n            useAlternateBackground\n          />\n        </div>\n      </FieldContainer>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/CommentsSupabase/CommentSortOptions.ts",
    "content": "import type { Comment } from 'oa-shared';\n\nexport enum CommentSortOption {\n  Newest = 'Newest',\n  Oldest = 'Oldest',\n  MostUseful = 'MostUseful',\n}\n\ntype SortConfig = {\n  label: string;\n  sortFn: (a: Comment, b: Comment) => number;\n};\n\nconst Options = new Map<CommentSortOption, SortConfig>([\n  [\n    CommentSortOption.Oldest,\n    {\n      label: 'Oldest',\n      sortFn: (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),\n    },\n  ],\n  [\n    CommentSortOption.Newest,\n    {\n      label: 'Newest',\n      sortFn: (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),\n    },\n  ],\n  [\n    CommentSortOption.MostUseful,\n    {\n      label: 'Most Useful',\n      sortFn: (a, b) => {\n        const voteCountDiff = (b.voteCount || 0) - (a.voteCount || 0);\n        // If vote counts are tied, sort by newest first\n        if (voteCountDiff === 0) {\n          return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();\n        }\n        return voteCountDiff;\n      },\n    },\n  ],\n]);\n\nconst getOptions = () => {\n  return Array.from(Options, ([value, config]) => ({\n    label: config.label,\n    value: value,\n  }));\n};\n\nconst getSortFn = (key: CommentSortOption) => {\n  const config = Options.get(key);\n  return config?.sortFn ?? Options.get(CommentSortOption.Oldest)!.sortFn;\n};\n\nexport const CommentSortOptions = {\n  get: (key: CommentSortOption) => Options.get(key)?.label ?? '',\n  getSortFn,\n  getOptions,\n};\n"
  },
  {
    "path": "src/pages/common/CommentsSupabase/CreateCommentSupabase.css",
    "content": "/* https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ */\n.grow-wrap {\n  /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */\n  display: grid;\n}\n.grow-wrap::after {\n  /* Note the weird space! Needed to preventy jumpy behavior */\n  content: attr(data-replicated-value) \" \";\n\n  /* This is how textarea text behaves */\n  white-space: pre-wrap;\n\n  /* Hidden from view, clicks, and screen readers */\n  visibility: hidden;\n}\n.grow-wrap > textarea {\n  /* You could leave this, but after a user resizes, then it ruins the auto sizing */\n  resize: none;\n\n  /* Firefox shows scrollbar on growth, you can hide like this. */\n  overflow: hidden;\n}\n.grow-wrap > textarea,\n.grow-wrap::after {\n  /* Identical styling required!! */\n  font-family: Inter, sans-serif;\n  background: none;\n  resize: none;\n  padding: 15px;\n  word-wrap: anywhere;\n  border-color: transparent;\n  font-size: 16px;\n\n  /* Place on top of each other */\n  grid-area: 1 / 1 / 2 / 2;\n}\n.grow-wrap > textarea:focus {\n  border-color: transparent;\n}\n\n.grow-wrap.value-set > textarea,\n.grow-wrap.value-set::after {\n  /* Identical styling required!! */\n  padding-bottom: 27px !important;\n}\n\n/* Mobile: match 40px button height & prevent iOS Safari zoom (font-size must be >= 16px) */\n@media (max-width: 768px) {\n  .grow-wrap > textarea,\n  .grow-wrap::after {\n    font-size: 16px;\n    padding: 8px;\n    line-height: 1.4;\n  }\n}\n"
  },
  {
    "path": "src/pages/common/CommentsSupabase/CreateCommentSupabase.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { Button, CommentAvatar, MemberBadge, ReturnPathLink } from 'oa-components';\nimport type { DiscussionContentType } from 'oa-shared';\nimport type { ChangeEvent } from 'react';\nimport { useMemo, useState } from 'react';\nimport { UserAction } from 'src/common/UserAction';\nimport { MAX_COMMENT_LENGTH } from 'src/constants';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Box, Flex, Text, Textarea } from 'theme-ui';\n\nimport './CreateCommentSupabase.css';\n\ninterface IProps {\n  onSubmit: (value: string) => void;\n  sourceType: DiscussionContentType;\n  isLoading?: boolean;\n  isReply?: boolean;\n  placeholder?: string;\n  sx?: ThemeUIStyleObject | undefined;\n}\n\nexport const CreateCommentSupabase = observer((props: IProps) => {\n  const { onSubmit, isLoading, isReply, sx } = props;\n  const placeholder = props.placeholder || 'Leave your questions or feedback...';\n  const buttonLabel = isReply ? 'Leave a reply' : 'Leave a comment';\n\n  const [comment, setComment] = useState<string>('');\n  const [isFocused, setIsFocused] = useState<boolean>(false);\n  const { profile, profileTypes } = useProfileStore();\n\n  const profileType = useMemo(() => {\n    if (profile?.type) {\n      return profile.type;\n    }\n\n    return profileTypes?.find((x) => !x.isSpace);\n  }, [profile, profileTypes]);\n\n  const commentIsActive = comment.length > 0 || isFocused;\n\n  const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => {\n    (event.target.parentNode! as HTMLDivElement).dataset.replicatedValue = event.target.value;\n    setComment(event.target.value);\n  };\n\n  return (\n    <Flex\n      sx={{\n        alignItems: 'stretch',\n        background: 'softblue',\n        borderRadius: 2,\n        flexDirection: 'column',\n        ...sx,\n      }}\n    >\n      <Flex sx={{ flexDirection: 'column' }}>\n        <Flex data-target=\"create-comment-container\" sx={{ gap: 2, padding: isReply ? 2 : 0 }}>\n          <Box\n            sx={{\n              lineHeight: 0,\n              display: ['none', 'block'],\n              flexShrink: 0,\n            }}\n          >\n            {profile?.photo?.publicUrl ? (\n              <CommentAvatar displayName={profile?.displayName} photo={profile?.photo?.publicUrl} />\n            ) : (\n              <MemberBadge profileType={profileType} useLowDetailVersion />\n            )}\n          </Box>\n          <Box\n            sx={{\n              display: 'block',\n              background: 'white',\n              flex: 1,\n              marginLeft: [0, 3],\n              borderRadius: 1,\n              position: 'relative',\n              width: 'min-content',\n              '&:before': {\n                display: ['none', 'block'],\n                content: '\"\"',\n                position: 'absolute',\n                borderWidth: '1em 1em',\n                borderStyle: 'solid',\n                borderColor: 'transparent white transparent transparent',\n                margin: '.5em -2em',\n              },\n            }}\n          >\n            <UserAction\n              incompleteProfile={<IncompleteProfilePrompt isReply={isReply} />}\n              loggedIn={\n                <Flex sx={{ flexDirection: 'column' }}>\n                  <Box className={`grow-wrap ${commentIsActive ? 'value-set' : ''}`}>\n                    <Textarea\n                      value={comment}\n                      maxLength={MAX_COMMENT_LENGTH}\n                      onChange={(event) => onChange(event)}\n                      aria-label=\"Comment\"\n                      data-cy={isReply ? 'reply-form' : 'comments-form'}\n                      placeholder={placeholder}\n                      rows={1}\n                      sx={{ padding: 2 }}\n                      onFocus={() => setIsFocused(true)}\n                      onBlur={() => setIsFocused(false)}\n                    />\n                  </Box>\n                  <Text\n                    sx={{\n                      fontSize: 1,\n                      display: commentIsActive ? 'flex' : 'none',\n                      alignSelf: 'flex-end',\n                      padding: 2,\n                    }}\n                  >\n                    {comment.length}/{MAX_COMMENT_LENGTH}\n                  </Text>\n                </Flex>\n              }\n              loggedOut={<LoginPrompt isReply={isReply} />}\n            />\n          </Box>\n\n          <Flex\n            sx={{\n              alignSelf: 'flex-end',\n              height: ['40px', '52px'],\n              width: ['40px', 'auto'],\n            }}\n          >\n            <Button\n              data-cy={isReply ? 'reply-submit' : 'comment-submit'}\n              disabled={!comment.trim() || isLoading}\n              variant=\"primary\"\n              icon={isLoading ? undefined : 'contact'}\n              onClick={() => {\n                if (!isLoading) {\n                  onSubmit(comment);\n                  setComment('');\n                }\n              }}\n              sx={{\n                height: ['40px', '100%'],\n                width: ['40px', 'auto'],\n                padding: [0, 1],\n                'div:first-of-type': {\n                  display: ['flex', 'none'],\n                },\n              }}\n            >\n              <Text sx={{ display: ['none', 'block'] }}>{buttonLabel}</Text>\n            </Button>\n          </Flex>\n        </Flex>\n      </Flex>\n    </Flex>\n  );\n});\n\nconst LoginPrompt = ({ isReply }: { isReply?: boolean }) => {\n  return (\n    <Box sx={{ padding: [3, 4] }}>\n      <Text data-cy=\"comments-login-prompt\">\n        {isReply ? 'You could reply here.' : 'Hi there!'}{' '}\n        <ReturnPathLink\n          to=\"/sign-in\"\n          style={{\n            textDecoration: 'underline',\n            color: 'inherit',\n          }}\n        >\n          {isReply ? 'But first you need to login' : 'Log in to leave a comment'}\n        </ReturnPathLink>\n      </Text>\n    </Box>\n  );\n};\n\nconst IncompleteProfilePrompt = ({ isReply }: { isReply?: boolean }) => {\n  return (\n    <Box sx={{ padding: [3, 4] }}>\n      <Text data-cy=\"comments-incomplete-profile-prompt\">\n        {isReply ? 'Before replying' : 'Hi there!'}{' '}\n        <ReturnPathLink\n          to=\"/settings\"\n          style={{\n            textDecoration: 'underline',\n            color: 'inherit',\n          }}\n        >\n          {isReply ? 'complete your profile' : 'Complete your profile to leave a comment'}\n        </ReturnPathLink>\n      </Text>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/CommentsSupabase/useCopyCommentLink.ts",
    "content": "import type { Comment, Reply } from 'oa-shared';\nimport { useCallback } from 'react';\nimport { useLocation } from 'react-router';\n\nexport const useCopyCommentLink = (comment: Comment | Reply) => {\n  const location = useLocation();\n\n  return useCallback(async () => {\n    try {\n      const baseUrl = `${window.location.origin}${location.pathname}`;\n      let url = `${baseUrl}#comment:${comment.id}`;\n\n      if (comment.sourceType === 'research_updates') {\n        const searchParams = new URLSearchParams(location.search);\n        const updateParam = Array.from(searchParams.keys()).find((key) =>\n          key.startsWith('update_'),\n        );\n        const updateId = updateParam ? updateParam.split('_')[1] : comment.sourceId;\n        url = `${baseUrl}?update_${updateId}#comment:${comment.id}`;\n      }\n\n      await navigator.clipboard.writeText(url);\n    } catch (error) {\n      console.error('Failed to copy comment link:', error);\n    }\n  }, [comment.id, comment.sourceId, comment.sourceType, location.pathname, location.search]);\n};\n"
  },
  {
    "path": "src/pages/common/Drafts/DraftButton.test.tsx",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { render } from '@testing-library/react';\nimport { describe, expect, it } from 'vitest';\n\nimport { drafts } from '../labels';\nimport DraftButton from './DraftButton';\n\ndescribe('Draft Button', () => {\n  it('displays back label', () => {\n    const { getByText } = render(\n      <DraftButton showDrafts={true} draftCount={10} handleShowDrafts={() => {}} />,\n    );\n\n    expect(getByText(drafts.backToList)).toBeInTheDocument();\n  });\n\n  it('displays count label', () => {\n    const count = 10;\n    const { getByText } = render(\n      <DraftButton showDrafts={false} draftCount={10} handleShowDrafts={() => {}} />,\n    );\n\n    expect(getByText(drafts.myDrafts + ` (${count})`)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "src/pages/common/Drafts/DraftButton.tsx",
    "content": "import { Button } from 'oa-components';\nimport { drafts } from '../labels';\n\ntype DraftButtonProps = {\n  showDrafts: boolean;\n  draftCount?: number;\n  handleShowDrafts: () => void;\n};\n\nconst DraftButton = ({ showDrafts, draftCount, handleShowDrafts }: DraftButtonProps) => {\n  if (!draftCount) {\n    return null;\n  }\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"secondary\"\n      icon={showDrafts ? 'arrow-back' : undefined}\n      data-cy=\"my-drafts\"\n      onClick={() => handleShowDrafts()}\n    >\n      {showDrafts ? (\n        <>{drafts.backToList}</>\n      ) : (\n        <>\n          {drafts.myDrafts} {draftCount ? `(${draftCount})` : ''}\n        </>\n      )}\n    </Button>\n  );\n};\n\nexport default DraftButton;\n"
  },
  {
    "path": "src/pages/common/Drafts/DraftTag.tsx",
    "content": "import { Flex, Text } from 'theme-ui';\n\nexport const DraftTag = () => {\n  return (\n    <Flex\n      sx={{\n        marginBottom: 'auto',\n        minWidth: '100px',\n        borderRadius: 1,\n        height: '44px',\n        background: 'lightgrey',\n      }}\n    >\n      <Text\n        sx={{\n          display: 'inline-block',\n          verticalAlign: 'middle',\n          color: 'black',\n          fontSize: [2, 2, 3],\n          padding: 2,\n          margin: 'auto',\n        }}\n        data-cy=\"draft-tag\"\n      >\n        Draft\n      </Text>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/Drafts/useDraftsSupabase.tsx",
    "content": "import type { FetchState } from 'oa-shared';\nimport { useContext, useEffect, useState } from 'react';\nimport { SessionContext } from '../SessionContext';\n\ntype Props<T> = {\n  getDraftCount: () => Promise<number>;\n  getDrafts: () => Promise<T[]>;\n};\n\nconst useDrafts = <T,>({ getDraftCount, getDrafts }: Props<T>) => {\n  const session = useContext(SessionContext);\n\n  const [draftCount, setDraftCount] = useState<number>(0);\n  const [drafts, setDrafts] = useState<T[]>([]);\n  const [showDrafts, setShowDrafts] = useState<boolean>(false);\n  const [fetchingDrafts, setFetchingDrafts] = useState<FetchState>('idle');\n\n  useEffect(() => {\n    const fetchDraftCount = async () => {\n      if (session?.sub) {\n        const count = await getDraftCount();\n\n        setDraftCount(count);\n      }\n    };\n    fetchDraftCount();\n  }, [session?.sub]);\n\n  const handleShowDrafts = async () => {\n    setShowDrafts((showDrafts) => !showDrafts);\n\n    if (fetchingDrafts !== 'idle') {\n      return;\n    }\n\n    setFetchingDrafts('fetching');\n\n    const items = await getDrafts();\n    setDrafts(items);\n\n    setFetchingDrafts('completed');\n  };\n\n  return {\n    handleShowDrafts,\n    isFetchingDrafts: fetchingDrafts === 'fetching',\n    showDrafts,\n    drafts,\n    draftCount,\n  };\n};\n\nexport default useDrafts;\n"
  },
  {
    "path": "src/pages/common/FormFields/Category.field.tsx",
    "content": "import type { ContentType, SelectValue } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { Field } from 'react-final-form';\nimport { CategoriesSelectV2 } from 'src/pages/common/Category/CategoriesSelectV2';\nimport { fields } from 'src/pages/Question/labels';\nimport { categoryService } from 'src/services/categoryService';\nimport { FormFieldWrapper } from './FormFieldWrapper';\n\ninterface IProps {\n  type: ContentType;\n}\n\nexport const CategoryField = ({ type }: IProps) => {\n  const [categories, setCategories] = useState<SelectValue[]>([]);\n  const name = 'category';\n\n  useEffect(() => {\n    const initCategories = async () => {\n      const categories = await categoryService.getCategories(type);\n      if (!categories) {\n        return;\n      }\n\n      const selectOptions = categories.map((category) => ({\n        value: category.id.toString(),\n        label: category.name,\n      }));\n      setCategories(selectOptions);\n    };\n\n    initCategories();\n  }, []);\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={fields.category.title}>\n      <Field\n        name={name}\n        id={name}\n        isEqual={(a, b) => {\n          if (!a && !b) return true; // both null/undefined = equal\n          return !!a && a?.value === b?.value;\n        }}\n        render={({ input, ...rest }) => (\n          <CategoriesSelectV2\n            {...rest}\n            categories={categories || []}\n            isForm={true}\n            onChange={input.onChange}\n            value={input.value}\n            placeholder={fields.category.placeholder as string}\n          />\n        )}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/FormFields/FilesFields.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { FieldInput } from 'oa-components';\nimport { type IFilesForm, type MediaFile, type ProjectFormData, UserRole } from 'oa-shared';\nimport { commonStyles } from 'oa-themes';\nimport { useState } from 'react';\nimport { Field, useForm, useFormState } from 'react-final-form';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { FileDisplay } from 'src/common/Form/FileInput/FileDisplay';\nimport { FileInputField } from 'src/common/Form/FileInput.field';\nimport { MAX_LINK_LENGTH } from 'src/pages/constants';\nimport { storageService } from 'src/services/storageService';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { COMPARISONS } from 'src/utils/comparisons';\nimport { Flex, Label, Spinner, Text } from 'theme-ui';\nimport { fileLabels } from './labels';\n\ninterface FilesFieldsProps {\n  contentType: 'projects' | 'research' | 'questions' | 'news';\n  contentId?: number | null;\n}\n\nexport const FilesFields = (props: FilesFieldsProps) => {\n  const { contentType, contentId = null } = props;\n  const state = useFormState<IFilesForm>();\n  const form = useForm<ProjectFormData>();\n  const hasBothError = !!(state.values?.files?.length && state.values.fileLink);\n  const [isUploading, setIsUploading] = useState(false);\n  const [uploadError, setUploadError] = useState<string | null>(null);\n\n  return (\n    <ClientOnly fallback={<></>}>\n      {() => (\n        <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n          {hasBothError && <WarningMessages show={hasBothError} />}\n          <Label htmlFor=\"files\">{fileLabels.files.title}</Label>\n          {!!state.values.files?.length && (\n            <AlreadyAddedFiles\n              files={state.values.files || []}\n              deleteFile={(id) => {\n                form.change(\n                  'files',\n                  state.values.files?.filter((x) => x.id !== id),\n                );\n              }}\n            />\n          )}\n          <UploadNewFiles\n            contentType={contentType}\n            contentId={contentId}\n            isUploading={isUploading}\n            setIsUploading={setIsUploading}\n            uploadError={uploadError}\n            setUploadError={setUploadError}\n          />\n        </Flex>\n      )}\n    </ClientOnly>\n  );\n};\n\nconst WarningMessages = ({ show }) => {\n  const { error } = fileLabels.files;\n\n  return (\n    <Flex sx={{ mb: 2 }}>\n      {show && (\n        <Text\n          id=\"invalid-file-warning\"\n          data-cy=\"invalid-file-warning\"\n          data-testid=\"invalid-file-warning\"\n          sx={{\n            color: 'error',\n          }}\n        >\n          {error}\n        </Text>\n      )}\n    </Flex>\n  );\n};\n\nconst AlreadyAddedFiles = ({\n  files,\n  deleteFile,\n}: {\n  files: MediaFile[];\n  deleteFile: (id: string) => void;\n}) => {\n  return (\n    <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n      {files.map((file) => (\n        <FileDisplay key={file.id} file={file} onRemove={() => deleteFile(file.id)} />\n      ))}\n    </Flex>\n  );\n};\n\ninterface UploadNewFilesProps {\n  contentType: 'projects' | 'research' | 'questions' | 'news';\n  contentId: number | null;\n  isUploading: boolean;\n  setIsUploading: (value: boolean) => void;\n  uploadError: string | null;\n  setUploadError: (value: string | null) => void;\n}\n\nconst UploadNewFiles = observer(\n  ({\n    contentType,\n    contentId,\n    isUploading,\n    setIsUploading,\n    uploadError,\n    setUploadError,\n  }: UploadNewFilesProps) => {\n    const identity = 'file-download-link';\n    const state = useFormState<IFilesForm>();\n    const form = useForm<ProjectFormData>();\n    const { isUserAuthorized } = useProfileStore();\n\n    const handleFilesChange = async (selectedFiles: (Blob | File)[]) => {\n      if (!selectedFiles || selectedFiles.length === 0) {\n        return;\n      }\n\n      setIsUploading(true);\n      setUploadError(null);\n\n      try {\n        const uploadedFiles: MediaFile[] = [];\n\n        for (const file of selectedFiles) {\n          if (file instanceof File) {\n            const uploadedFile = await storageService.fileUpload(contentId, contentType, file);\n            uploadedFiles.push(uploadedFile);\n          }\n        }\n\n        // Add uploaded files to existing files in form state\n        const currentFiles = state.values.files || [];\n        form.change('files', [...currentFiles, ...uploadedFiles]);\n      } catch (error) {\n        console.error('Error uploading files:', error);\n        setUploadError('Failed to upload one or more files. Please try again.');\n      } finally {\n        setIsUploading(false);\n      }\n    };\n\n    return (\n      <>\n        {uploadError && <Text sx={{ color: 'error', fontSize: 1 }}>{uploadError}</Text>}\n        <Flex sx={{ flexDirection: 'column' }}>\n          {isUploading ? (\n            <Flex sx={{ alignItems: 'center', mb: 2 }}>\n              <Spinner sx={{ color: commonStyles.colors.darkGrey }} />\n              <Text sx={{ ml: 2 }}>Uploading files...</Text>\n            </Flex>\n          ) : (\n            <Field\n              hasText={false}\n              name=\"fileUploadTrigger\"\n              data-cy=\"file-input-field\"\n              component={FileInputField}\n              admin={false}\n              onFilesChange={handleFilesChange}\n            />\n          )}\n          <Text sx={{ fontSize: 1, color: 'grey', mt: 1 }}>\n            {isUserAuthorized(UserRole.ADMIN)\n              ? fileLabels.files.descriptionAdmin\n              : fileLabels.files.description}\n          </Text>\n        </Flex>\n        <Flex sx={{ flexDirection: 'column' }}>\n          <Label htmlFor={identity} sx={{ fontSize: 2, mb: 1 }}>\n            {fileLabels.fileLink.title}\n          </Label>\n          <Field\n            id=\"fileLink\"\n            name=\"fileLink\"\n            data-cy=\"fileLink\"\n            component={FieldInput}\n            isEqual={COMPARISONS.textInput}\n            maxLength={MAX_LINK_LENGTH}\n            placeholder={fileLabels.fileLink.placeholder}\n            validateFields={[]}\n          />\n        </Flex>\n      </>\n    );\n  },\n);\n"
  },
  {
    "path": "src/pages/common/FormFields/FormFieldWrapper.test.tsx",
    "content": "import { render, screen } from '@testing-library/react';\nimport { LibraryFormProvider } from 'src/pages/Library/Content/Common/LibraryFormProvider';\nimport { describe, it } from 'vitest';\n\nimport { FormFieldWrapper } from './FormFieldWrapper';\n\ndescribe('FormFieldWrapper', () => {\n  it('renders the props', async () => {\n    const text = 'Title Presented';\n    const htmlFor = 'html_tag';\n    const childrenText = 'Children rendered';\n\n    render(\n      <LibraryFormProvider>\n        <FormFieldWrapper text={text} htmlFor={htmlFor}>\n          <p>{childrenText}</p>\n        </FormFieldWrapper>\n      </LibraryFormProvider>,\n    );\n\n    await screen.findByText(text);\n    await screen.findByText(childrenText);\n  });\n\n  it('adds an asterisk with required', async () => {\n    const text = 'Title Presented';\n\n    render(\n      <LibraryFormProvider>\n        <FormFieldWrapper text={text} required>\n          <p></p>\n        </FormFieldWrapper>\n      </LibraryFormProvider>,\n    );\n\n    await screen.findByText('*', { exact: false });\n  });\n});\n"
  },
  {
    "path": "src/pages/common/FormFields/FormFieldWrapper.tsx",
    "content": "import { Flex, Label, Text } from 'theme-ui';\n\nconst _labelStyle = {\n  fontSize: 2,\n  marginBottom: 1,\n  display: 'block',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n  description?: string;\n  htmlFor?: string;\n  required?: boolean;\n  text: string;\n}\n\nexport const FormFieldWrapper = (props: IProps) => {\n  const { children, description, htmlFor, required, text } = props;\n\n  const heading = required ? `${text} *` : text;\n\n  return (\n    <Flex sx={{ flexDirection: 'column' }}>\n      <Label sx={_labelStyle} htmlFor={htmlFor}>\n        {heading}\n      </Label>\n\n      {description && (\n        <Text variant=\"quiet\" sx={{ fontSize: 2 }}>\n          {description}\n        </Text>\n      )}\n\n      <Flex sx={{ flexDirection: 'column' }}>{children}</Flex>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/FormFields/ImageField.tsx",
    "content": "import { ImageInputV2 } from 'oa-components';\nimport type { ProjectFormData } from 'oa-shared';\nimport { commonStyles } from 'oa-themes';\nimport { useState } from 'react';\nimport { Field, useForm } from 'react-final-form';\nimport { FieldContainer } from 'src/common/Form/FieldContainer';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { storageService } from 'src/services/storageService';\nimport { required } from 'src/utils/validators';\nimport { Box, Spinner, Text } from 'theme-ui';\n\ntype ImageFieldProps = {\n  title: string;\n  contentType: 'projects' | 'research' | 'questions' | 'news';\n  contentId?: number | null;\n};\n\nexport const ImageField = (props: ImageFieldProps) => {\n  const { title, contentType, contentId = null } = props;\n  const form = useForm<ProjectFormData>();\n  const [isUploading, setIsUploading] = useState(false);\n  const [uploadError, setUploadError] = useState<string | null>(null);\n\n  const handleImageSelect = async (file: File | undefined) => {\n    // If user is clearing the image\n    if (!file) {\n      form.change('coverImage', null);\n      setUploadError(null);\n      return;\n    }\n\n    setIsUploading(true);\n    setUploadError(null);\n\n    try {\n      // Upload the image first\n      const uploadedImage = await storageService.imageUpload(contentId, contentType, file);\n\n      // Only then set the form state with the uploaded metadata\n      form.change('coverImage', uploadedImage);\n    } catch (error) {\n      setUploadError(\n        error instanceof Error ? error.message : 'Failed to upload image. Please try again.',\n      );\n      form.change('coverImage', null);\n    } finally {\n      setIsUploading(false);\n    }\n  };\n\n  return (\n    <Field name=\"coverImage\" validate={required}>\n      {({ input, meta }) => (\n        <FormFieldWrapper htmlFor=\"image\" text={title} required>\n          {uploadError && <Text sx={{ color: 'error', fontSize: 1, mb: 2 }}>{uploadError}</Text>}\n          {meta.touched && meta.error && (\n            <Text sx={{ color: 'error', fontSize: 1, mb: 2 }}>{meta.error}</Text>\n          )}\n\n          <Box\n            sx={{\n              height: '200px',\n              width: '370px',\n              maxWidth: '100%',\n              ...(meta.touched && meta.error\n                ? { '.image-input__wrapper': { borderColor: 'error' } }\n                : {}),\n            }}\n            data-cy={isUploading ? 'image-uploading' : 'image-input'}\n          >\n            <FieldContainer\n              style={{\n                height: '100%',\n                width: '100%',\n                display: 'flex',\n                alignItems: 'center',\n                justifyContent: 'center',\n              }}\n            >\n              {isUploading ? (\n                <>\n                  <Spinner sx={{ color: commonStyles.colors.darkGrey }} />\n                  <Text sx={{ ml: 2 }}>Uploading image...</Text>\n                </>\n              ) : (\n                <ImageInputV2\n                  onFilesChange={handleImageSelect}\n                  onError={setUploadError}\n                  image={input.value || undefined}\n                />\n              )}\n            </FieldContainer>\n          </Box>\n        </FormFieldWrapper>\n      )}\n    </Field>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/FormFields/ImageInputFieldWrapper.tsx",
    "content": "import styled from '@emotion/styled';\n\nexport const ImageInputFieldWrapper = styled.div`\n  display: flex;\n  width: 150px;\n  height: 100px;\n  align-items: center;\n  justify-content: center;\n`;\n"
  },
  {
    "path": "src/pages/common/FormFields/ProfileBadgeField.tsx",
    "content": "import { Select } from 'oa-components';\nimport type { SelectValue } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { Field } from 'react-final-form';\nimport { FieldContainer } from 'src/common/Form/FieldContainer';\nimport { ProfileBadgeService } from 'src/services/profileBadgeService';\nimport { FormFieldWrapper } from './FormFieldWrapper';\n\ninterface IProps {\n  placeholder: string;\n  title: string;\n}\n\nexport const ProfileBadgeField = ({ placeholder, title }: IProps) => {\n  const [profileBadges, setProfileBadges] = useState<SelectValue[]>([]);\n  const name = 'profileBadge';\n\n  useEffect(() => {\n    const initCategories = async () => {\n      const badges = await ProfileBadgeService.getProfileBadges();\n      if (!badges) {\n        return;\n      }\n\n      const selectBadges = badges.map((badge) => ({\n        value: badge.id.toString(),\n        label: badge.displayName,\n      }));\n      setProfileBadges(selectBadges);\n    };\n\n    initCategories();\n  }, []);\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title}>\n      <Field\n        name={name}\n        id={name}\n        isEqual={(a, b) => !!a && a?.value === b?.value}\n        render={({ input, ...rest }) => (\n          <FieldContainer data-cy=\"profileBadge-select\">\n            <Select\n              {...rest}\n              variant=\"form\"\n              options={profileBadges || []}\n              value={input.value}\n              onChange={(changedValue) => {\n                input.onChange(changedValue ?? null);\n              }}\n              isClearable={true}\n              placeholder={placeholder}\n            />\n          </FieldContainer>\n        )}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/FormFields/StepImagesField.tsx",
    "content": "import { ImageInputV2 } from 'oa-components';\nimport type { MediaWithPublicUrl } from 'oa-shared';\nimport { commonStyles } from 'oa-themes';\nimport { useState } from 'react';\nimport { useForm, useFormState } from 'react-final-form';\nimport { steps } from 'src/pages/Library/labels';\nimport { storageService } from 'src/services/storageService';\nimport { Flex, Spinner, Text } from 'theme-ui';\nimport { FormFieldWrapper } from './FormFieldWrapper';\nimport { ImageInputFieldWrapper } from './ImageInputFieldWrapper';\n\nconst MAX_IMAGES = 10;\n\ninterface StepImagesFieldProps {\n  stepIndex: number;\n  contentType: 'projects' | 'research' | 'questions' | 'news';\n  contentId?: number | null;\n  images: MediaWithPublicUrl[];\n  fieldName: string;\n  onImageUploaded?: () => void;\n}\n\nexport const StepImagesField = ({\n  stepIndex,\n  contentType,\n  contentId,\n  images,\n  fieldName,\n  onImageUploaded,\n}: StepImagesFieldProps) => {\n  const state = useFormState();\n  const form = useForm();\n  const [isUploading, setIsUploading] = useState(false);\n  const [uploadError, setUploadError] = useState<string | null>(null);\n\n  const imagesFieldName = `steps[${stepIndex}].images`;\n\n  const handleImageSelect = async (file: File | undefined) => {\n    if (!file) {\n      return;\n    }\n\n    setIsUploading(true);\n    setUploadError(null);\n\n    try {\n      const uploadedImage = await storageService.imageUpload(contentId ?? null, contentType, file);\n\n      const currentImages = state.values.steps?.[stepIndex]?.images || [];\n      // Add new image and deduplicate by id\n      const allImages = [...currentImages, uploadedImage];\n      const uniqueImagesMap = new Map(allImages.map((img) => [img.id, img]));\n      const uniqueImages = Array.from(uniqueImagesMap.values());\n      form.change(imagesFieldName, uniqueImages);\n\n      onImageUploaded?.();\n    } catch (error) {\n      setUploadError(\n        error instanceof Error ? error.message : 'Failed to upload image. Please try again.',\n      );\n    } finally {\n      setIsUploading(false);\n    }\n  };\n\n  const removeImage = (imageIndex: number) => {\n    const updatedImages = (images || []).filter((_, i) => i !== imageIndex);\n    form.change(imagesFieldName, updatedImages);\n  };\n\n  return (\n    <FormFieldWrapper htmlFor={fieldName} text={steps.images.title}>\n      {uploadError && (\n        <Text sx={{ color: 'error', fontSize: 1, mb: 2, width: '100%' }}>{uploadError}</Text>\n      )}\n\n      <Flex sx={{ gap: 2, flexWrap: 'wrap' }}>\n        {images?.map((image, i) => (\n          <ImageInputFieldWrapper key={`image-${i}`} data-cy={`image-${i}`}>\n            <ImageInputV2\n              image={image}\n              onFilesChange={(file) => {\n                if (!file) {\n                  removeImage(i);\n                }\n              }}\n              onError={setUploadError}\n            />\n          </ImageInputFieldWrapper>\n        ))}\n\n        {images.length < MAX_IMAGES && (\n          <ImageInputFieldWrapper data-cy=\"new-image-upload\">\n            {isUploading ? (\n              <Spinner size={20} sx={{ color: commonStyles.colors.darkGrey }} />\n            ) : (\n              <ImageInputV2\n                onFilesChange={(file) => handleImageSelect(file)}\n                onError={setUploadError}\n              />\n            )}\n          </ImageInputFieldWrapper>\n        )}\n      </Flex>\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/FormFields/Tags.field.tsx",
    "content": "import { Field } from 'react-final-form';\nimport { TagsSelectField } from 'src/common/Form/TagsSelectField';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { COMPARISONS } from 'src/utils/comparisons';\n\ninterface IProps {\n  title: string;\n}\n\nexport const TagsField = ({ title }: IProps) => {\n  const name = 'tags';\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title}>\n      <Field\n        data-cy={`field-${name}`}\n        name={name}\n        id={name}\n        component={TagsSelectField}\n        isEqual={COMPARISONS.tagsSupabase}\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/FormFields/Title.field.tsx",
    "content": "import { FieldValidator } from 'final-form';\nimport { FieldInput } from 'oa-components';\nimport { Field } from 'react-final-form';\nimport { FormFieldWrapper } from 'src/pages/common/FormFields';\nimport { NEWS_MAX_TITLE_LENGTH, NEWS_MIN_TITLE_LENGTH } from 'src/pages/News/constants';\n\ninterface IProps {\n  placeholder?: string;\n  validate: FieldValidator<string>;\n  title: string;\n}\n\nexport const TitleField = ({ placeholder, title, validate }: IProps) => {\n  const name = 'title';\n\n  return (\n    <FormFieldWrapper htmlFor={name} text={title} required>\n      <Field\n        data-cy={`field-${name}`}\n        name={name}\n        id={name}\n        validate={validate}\n        component={FieldInput}\n        placeholder={placeholder}\n        minLength={NEWS_MIN_TITLE_LENGTH}\n        maxLength={NEWS_MAX_TITLE_LENGTH}\n        showCharacterCount\n        onBlur\n      />\n    </FormFieldWrapper>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/FormFields/index.ts",
    "content": "export * from './Category.field';\nexport * from './FormFieldWrapper';\nexport * from './StepImagesField';\nexport * from './Tags.field';\nexport * from './Title.field';\n"
  },
  {
    "path": "src/pages/common/FormFields/labels.ts",
    "content": "export const fileLabels = {\n  fileLink: {\n    title: 'Or add a download link',\n    placeholder: 'Link to Google Drive, Dropbox, Grabcad etc',\n  },\n  files: {\n    title: 'Attach your file(s) for this update',\n    description: 'Maximum file size 50MB',\n    descriptionAdmin: 'Maximum file size 300MB',\n    error: 'Please provide either a file link or upload a file, not both.',\n  },\n};\n"
  },
  {
    "path": "src/pages/common/GlobalSiteFooter/GlobalSiteFooter.tsx",
    "content": "import { SiteFooter } from 'oa-components';\nimport { useContext, useMemo } from 'react';\nimport { useLocation } from 'react-router';\n\nimport { TenantContext } from '../TenantContext';\n\nconst GlobalSiteFooter = () => {\n  const tenantContext = useContext(TenantContext);\n  const location = useLocation();\n\n  const showFooter = useMemo(() => {\n    const path = location?.pathname;\n\n    return !path.startsWith('/map') && !path.startsWith('/academy') && path !== '/';\n  }, [location?.pathname]);\n\n  return showFooter ? (\n    <SiteFooter siteName={tenantContext?.siteName || 'Community Platform'} />\n  ) : null;\n};\n\nexport default GlobalSiteFooter;\n"
  },
  {
    "path": "src/pages/common/Header/Header.tsx",
    "content": "import { withTheme } from '@emotion/react';\nimport { animated, useSpring } from '@react-spring/web';\nimport { observer } from 'mobx-react';\nimport { Button } from 'oa-components';\nimport type { NotificationDisplay } from 'oa-shared';\nimport { UserRole } from 'oa-shared';\nimport { useEffect, useState } from 'react';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { AuthWrapper } from 'src/common/AuthWrapper';\nimport Logo from 'src/pages/common/Header/Menu/Logo/Logo';\nimport MenuDesktop from 'src/pages/common/Header/Menu/MenuDesktop';\nimport MenuMobilePanel from 'src/pages/common/Header/Menu/MenuMobile/MenuMobilePanel';\nimport Profile from 'src/pages/common/Header/Menu/Profile/Profile';\nimport { notificationSupabaseService } from 'src/services/notificationsSupabaseService';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { Flex, Text, useThemeUI } from 'theme-ui';\nimport { NotificationsContext } from '../NotificationsContext';\nimport { NotificationsSupabase } from './Menu/Notifications/NotificationsSupabase';\nimport { MobileMenuContext } from './MobileMenuContext';\n\nconst MobileNotificationsWrapper = ({ children }) => {\n  const { theme } = useThemeUI();\n\n  return (\n    <Flex\n      sx={{\n        alignItems: 'center',\n        gap: 2,\n        position: 'relative',\n        [`@media only screen and (max-width: ${theme.breakpoints![1]})`]: {\n          display: 'flex',\n          marginLeft: '1em',\n          marginRight: 'auto',\n        },\n        [`@media only screen and (min-width: ${theme.breakpoints![1]})`]: {\n          display: 'none',\n        },\n      }}\n    >\n      {children}\n    </Flex>\n  );\n};\n\nconst MobileMenuWrapper = ({ children, ...props }) => (\n  <Flex {...props} sx={{ position: 'relative', display: ['flex', 'flex', 'none'] }}>\n    {children}\n  </Flex>\n);\n\nconst AnimationContainer = (props: any) => {\n  const springStyle = useSpring({\n    from: { top: '-100%' },\n    to: { top: '0' },\n    config: { duration: 250 },\n  });\n\n  return (\n    <animated.div style={{ position: 'relative', ...springStyle }}>{props.children}</animated.div>\n  );\n};\n\nconst Header = observer(() => {\n  const { theme } = useThemeUI();\n  const { profile } = useProfileStore();\n  const isLoggedIn = !!profile;\n\n  const [isVisible, setIsVisible] = useState(false);\n\n  // New notifications states\n  const [notificationsSupabase, setNotificationsSupabase] = useState<NotificationDisplay[] | null>(\n    null,\n  );\n  const [isUpdatingNotifications, setIsUpdatingNotifications] = useState<boolean>(true);\n\n  const updateNotifications = async () => {\n    setIsUpdatingNotifications(true);\n    const notifications = await notificationSupabaseService.getNotifications();\n    setNotificationsSupabase(notifications);\n    setIsUpdatingNotifications(false);\n  };\n\n  useEffect(() => {\n    updateNotifications();\n  }, []);\n\n  return (\n    <NotificationsContext.Provider\n      value={{\n        notifications: notificationsSupabase,\n        isUpdatingNotifications,\n        updateNotifications,\n      }}\n    >\n      <MobileMenuContext.Provider\n        value={{\n          isVisible,\n          setIsVisible,\n        }}\n      >\n        <Flex\n          data-cy=\"header\"\n          sx={{\n            backgroundColor: 'white',\n            px: [4, 4, 0],\n            zIndex: (theme as any).zIndex.header,\n            position: 'relative',\n            justifyContent: 'space-between',\n            alignItems: 'center',\n            minHeight: [null, null, 80],\n          }}\n        >\n          <Flex>\n            <Logo />\n            {isLoggedIn && (\n              <AuthWrapper roleRequired={UserRole.BETA_TESTER} borderLess>\n                <Flex className=\"user-beta-icon\" sx={{ alignItems: 'center', marginLeft: 4 }}>\n                  <Text\n                    sx={{\n                      color: 'white',\n                      fontWeight: 'bold',\n                      fontSize: '1.4rem',\n                      borderRadius: '4px',\n                      padding: '2px 6px',\n                      backgroundColor: 'lightgrey',\n                    }}\n                  >\n                    BETA\n                  </Text>\n                </Flex>\n              </AuthWrapper>\n            )}\n          </Flex>\n          {isLoggedIn && (\n            <MobileNotificationsWrapper>\n              <NotificationsSupabase device=\"mobile\" />\n            </MobileNotificationsWrapper>\n          )}\n          <Flex\n            className=\"menu-desktop\"\n            sx={{\n              alignItems: 'center',\n              paddingX: 2,\n              position: 'relative',\n              display: ['none', 'none', 'flex'],\n              gap: 2,\n            }}\n          >\n            <MenuDesktop />\n            {isLoggedIn && <NotificationsSupabase device=\"desktop\" />}\n            <Profile isMobile={false} />\n          </Flex>\n          <ClientOnly fallback={<></>}>\n            {() => (\n              <MobileMenuWrapper className=\"menu-mobile\">\n                <Flex sx={{ paddingLeft: 5 }}>\n                  <Button\n                    type=\"button\"\n                    showIconOnly={true}\n                    icon={isVisible ? 'close' : 'menu'}\n                    onClick={() => setIsVisible(!isVisible)}\n                    large={true}\n                    sx={{\n                      marginRight: -3,\n                      backgroundColor: 'white',\n                      borderWidth: '0px',\n                      '&:hover': {\n                        backgroundColor: 'white',\n                      },\n                      '&:active': {\n                        backgroundColor: 'white',\n                      },\n                    }}\n                  />\n                </Flex>\n              </MobileMenuWrapper>\n            )}\n          </ClientOnly>\n        </Flex>\n        {isVisible && (\n          <AnimationContainer key=\"mobilePanelContainer\">\n            <MobileMenuWrapper>\n              <MenuMobilePanel />\n            </MobileMenuWrapper>\n          </AnimationContainer>\n        )}\n      </MobileMenuContext.Provider>\n    </NotificationsContext.Provider>\n  );\n});\n\nexport default withTheme(Header);\n"
  },
  {
    "path": "src/pages/common/Header/Menu/Logo/Logo.tsx",
    "content": "import { useContext } from 'react';\nimport { Link } from 'react-router';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { Box, Image, Text } from 'theme-ui';\n\nconst Logo = () => {\n  const tenantContext = useContext(TenantContext);\n  const name = tenantContext?.siteName;\n  const logo = tenantContext?.siteImage;\n\n  return (\n    <Box\n      sx={{\n        position: 'relative',\n      }}\n    >\n      <Link to=\"/\">\n        <Image\n          loading=\"lazy\"\n          src={logo}\n          sx={{\n            // 831px and below\n            width: 60,\n            height: 60,\n            marginBottom: '-20px',\n            '@media screen and (min-width: 832px)': {\n              width: 80,\n              height: 80,\n              marginBottom: '-30px',\n              marginLeft: '20px',\n            },\n            '@media screen and (min-width: 1200px)': {\n              width: 100,\n              height: 100,\n              marginBottom: '-40px',\n              marginLeft: '20px',\n            },\n          }}\n          alt={`${name} logo`}\n          title={`${name} logo`}\n        />\n        <Text className=\"sr-only\" ml={2} sx={{ display: ['none', 'none', 'block'] }} color=\"black\">\n          {name}\n        </Text>\n      </Link>\n    </Box>\n  );\n};\n\nexport default Logo;\n"
  },
  {
    "path": "src/pages/common/Header/Menu/MenuDesktop.tsx",
    "content": "import styled from '@emotion/styled';\nimport { useContext } from 'react';\nimport { NavLink, useLocation } from 'react-router';\nimport MenuCurrent from 'src/assets/images/menu-current.svg';\nimport { getSupportedModules } from 'src/modules';\nimport { getAvailablePageList } from 'src/pages/PageList';\nimport { Flex } from 'theme-ui';\n\nimport { TenantContext } from '../../TenantContext';\n\nconst MenuLink = styled(NavLink)`\n  padding: 0px ${(props) => props.theme.space[4]}px;\n  color: ${'black'};\n  position: relative;\n  > div {\n    z-index: ${(props) => props.theme.zIndex.default};\n    position: relative;\n    &:hover {\n      opacity: 0.7;\n    }\n  }\n  &.active {\n    &:after {\n      content: '';\n      width: 70px;\n      height: 20px;\n      display: block;\n      position: absolute;\n      bottom: -6px;\n      background-color: var(--color-primary);\n      mask-size: contain;\n      mask-image: url(\\\"${MenuCurrent}\\\");\n      mask-repeat: no-repeat;\n      z-index: ${(props) => props.theme.zIndex.level};\n      left: 50%;\n      transform: translateX(-50%);\n      pointer-events: none;\n    }\n  }\n`;\n\nexport const MenuDesktop = () => {\n  const tenantContext = useContext(TenantContext);\n  const location = useLocation();\n\n  return (\n    <Flex sx={{ alignItems: 'center', width: '100%' }}>\n      {getAvailablePageList(getSupportedModules(tenantContext?.supportedModules || '')).map(\n        (page) => (\n          <Flex key={page.path}>\n            <MenuLink\n              to={page.path}\n              data-cy=\"page-link\"\n              onClick={(e) => {\n                if (location.pathname === page.path) {\n                  e.preventDefault();\n                }\n              }}\n            >\n              <Flex>{page.title}</Flex>\n            </MenuLink>\n          </Flex>\n        ),\n      )}\n    </Flex>\n  );\n};\n\nexport default MenuDesktop;\n"
  },
  {
    "path": "src/pages/common/Header/Menu/MenuMobile/MenuMobileExternalLink.tsx",
    "content": "import { ExternalLink as Link } from 'oa-components';\nimport { useContext } from 'react';\nimport { Box } from 'theme-ui';\n\nimport { MobileMenuContext } from '../../MobileMenuContext';\n\ninterface IProps {\n  content: string;\n  href: string;\n}\n\nconst MenuMobileExternalLink = ({ content, href }: IProps) => {\n  const mobileMenuContext = useContext(MobileMenuContext);\n  const id = content.toLowerCase().replace(' ', '-');\n\n  return (\n    <>\n      <Box data-cy=\"mobile-menu-item\" sx={{ paddingTop: 3, paddingBottom: 3 }}>\n        <Link\n          onClick={() => mobileMenuContext.setIsVisible(false)}\n          id={id}\n          href={href}\n          sx={{ color: 'silver', fontSize: 2 }}\n        >\n          {content}\n        </Link>\n      </Box>\n    </>\n  );\n};\n\nexport default MenuMobileExternalLink;\n"
  },
  {
    "path": "src/pages/common/Header/Menu/MenuMobile/MenuMobileLink.tsx",
    "content": "import styled from '@emotion/styled';\nimport React, { useContext } from 'react';\nimport { NavLink, useLocation } from 'react-router';\nimport MenuCurrent from 'src/assets/images/menu-current.svg';\nimport { Box } from 'theme-ui';\n\nimport { MobileMenuContext } from '../../MobileMenuContext';\n\ninterface IProps {\n  path: string;\n  content: string;\n  style?: React.CSSProperties;\n  onClick?: () => void;\n}\n\nconst PanelItem = styled(Box)`\n  padding: ${(props) => props.theme.space[3]}px 0px;\n`;\n\nconst MenuLink = styled(NavLink)`\n  color: ${(props) => props.theme.colors.black};\n  font-size: ${(props) => props.theme.fontSizes[2]}px;\n  position: relative;\n  > span {\n    z-index: 1;\n    position: relative;\n    &:hover {\n      opacity: 0.7;\n    }\n  }\n  &.current {\n    &:after {\n      content: '';\n      width: 70px;\n      height: 20px;\n      display: block;\n      position: absolute;\n      bottom: -5px;\n      background-color: var(--color-primary);\n      mask-size: contain;\n      mask-image\": url(\"${MenuCurrent}\");\n      mask-repeat: no-repeat;\n      z-index: 0;\n      left: 50%;\n      transform: translateX(-50%);\n    }\n  }\n`;\n\nconst MenuMobileLink = (props: IProps) => {\n  const mobileMenuContext = useContext(MobileMenuContext);\n  const location = useLocation();\n\n  return (\n    <PanelItem data-cy=\"mobile-menu-item\">\n      <MenuLink\n        to={props.path}\n        onClick={(e) => {\n          if (location.pathname === props.path) {\n            e.preventDefault();\n          }\n          mobileMenuContext.setIsVisible(false);\n          if (props.onClick) {\n            props.onClick();\n          }\n        }}\n        style={props.style}\n        className={({ isActive }) => (isActive ? 'current' : '')}\n      >\n        <span>{props.content}</span>\n      </MenuLink>\n    </PanelItem>\n  );\n};\n\nexport default MenuMobileLink;\n"
  },
  {
    "path": "src/pages/common/Header/Menu/MenuMobile/MenuMobilePanel.tsx",
    "content": "import { useContext } from 'react';\nimport { getSupportedModules } from 'src/modules';\nimport MenuMobileLink from 'src/pages/common/Header/Menu/MenuMobile/MenuMobileLink';\nimport Profile from 'src/pages/common/Header/Menu/Profile/Profile';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { getAvailablePageList } from 'src/pages/PageList';\nimport { Box, Flex } from 'theme-ui';\n\nconst MenuMobilePanel = () => {\n  const tenantContext = useContext(TenantContext);\n\n  return (\n    <Box\n      sx={{\n        width: '100%',\n        height: '100%',\n        position: 'absolute',\n        zIndex: 3000,\n      }}\n    >\n      <Flex\n        as=\"nav\"\n        sx={{\n          backgroundColor: 'white',\n          flexDirection: 'column',\n          textAlign: 'center',\n          minWidth: '200px',\n          boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',\n        }}\n      >\n        {getAvailablePageList(getSupportedModules(tenantContext?.supportedModules || '')).map(\n          (page) => (\n            <MenuMobileLink path={page.path} content={page.title} key={page.path} />\n          ),\n        )}\n        <Profile isMobile={true} />\n      </Flex>\n    </Box>\n  );\n};\n\nexport default MenuMobilePanel;\n"
  },
  {
    "path": "src/pages/common/Header/Menu/Notifications/NotificationsSupabase.tsx",
    "content": "import { Icon, NotificationListSupabase, NotificationsModal } from 'oa-components';\nimport { useContext, useState } from 'react';\nimport { NotificationsContext } from 'src/pages/common/NotificationsContext';\nimport { Box } from 'theme-ui';\n\ninterface IProps {\n  device: 'desktop' | 'mobile';\n}\n\nexport const NotificationsSupabase = ({ device }: IProps) => {\n  const [isOpen, setIsOpen] = useState<boolean>(false);\n  const { notifications, isUpdatingNotifications, updateNotifications } =\n    useContext(NotificationsContext);\n\n  if (!notifications === undefined) {\n    return null;\n  }\n\n  const markAllRead = async () => {\n    await fetch(`/api/notifications/all/read`, { method: 'POST' });\n    updateNotifications && (await updateNotifications());\n  };\n\n  const markRead = async (id: number) => {\n    await fetch(`/api/notifications/${id}/read`, { method: 'POST' });\n    updateNotifications && (await updateNotifications());\n  };\n\n  const hasNewNotifications =\n    notifications && notifications?.filter(({ isRead }) => isRead === false).length > 0;\n\n  const onClick = () => setIsOpen(!isOpen);\n\n  const iconProps = {\n    onClick,\n    size: 40,\n    sx: {\n      ':hover': {\n        background: 'background',\n        borderRadius: 99,\n      },\n    },\n  };\n\n  return (\n    <Box data-cy={`NotificationsSupabase-${device}`}>\n      {!hasNewNotifications && (\n        <Icon data-cy=\"notifications-no-new-messages\" glyph=\"megaphone-inactive\" {...iconProps} />\n      )}\n      {hasNewNotifications && (\n        <Icon data-cy=\"notifications-new-messages\" glyph=\"megaphone-active\" {...iconProps} />\n      )}\n      <NotificationsModal isOpen={isOpen}>\n        <NotificationListSupabase\n          isUpdatingNotifications={isUpdatingNotifications}\n          markAllRead={markAllRead}\n          markRead={markRead}\n          modalDismiss={onClick}\n          notifications={notifications || []}\n        />\n      </NotificationsModal>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/Header/Menu/Profile/Profile.tsx",
    "content": "import { observer } from 'mobx-react';\nimport { MemberBadge } from 'oa-components';\nimport { useContext, useState } from 'react';\nimport { useClickOutside } from 'src/common/hooks/useClickOutside';\nimport MenuMobileLink from 'src/pages/common/Header/Menu/MenuMobile/MenuMobileLink';\nimport { ProfileModal } from 'src/pages/common/Header/Menu/ProfileModal/ProfileModal';\nimport { MobileMenuContext } from 'src/pages/common/Header/MobileMenuContext';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { Avatar, Box, Flex } from 'theme-ui';\nimport ProfileButtons from './ProfileButtons';\nimport { UpgradeBadgeLink } from './UpgradeBadgeLink';\nimport './profile.css';\n\ninterface IProps {\n  isMobile: boolean;\n}\n\nconst Profile = observer((props: IProps) => {\n  const [showProfileModal, setShowProfileModal] = useState<boolean>(false);\n  const { profile: profile, upgradeBadgeForCurrentUser } = useProfileStore();\n  const modalRef = useClickOutside(() => setShowProfileModal(false));\n  const mobileMenuContext = useContext(MobileMenuContext);\n\n  if (!profile) {\n    return <ProfileButtons isMobile={props.isMobile} />;\n  }\n\n  if (props.isMobile) {\n    return (\n      <Box\n        sx={{\n          borderBottom: 'none',\n          borderColor: 'lightgrey',\n          borderTop: '1px solid',\n          mt: 1,\n        }}\n      >\n        <MenuMobileLink\n          path={profile.username ? '/u/' + profile.username : '/settings/profile'}\n          content=\"Profile\"\n        />\n        {upgradeBadgeForCurrentUser && (\n          <Box data-cy=\"mobile-menu-item\" sx={{ py: 3 }}>\n            <UpgradeBadgeLink\n              upgradeBadge={upgradeBadgeForCurrentUser}\n              onClick={() => mobileMenuContext.setIsVisible(false)}\n              data-cy=\"mobile-menu-upgrade-badge\"\n              sx={{\n                justifyContent: 'center',\n                fontSize: 2,\n                '&:hover': {\n                  opacity: 0.7,\n                },\n              }}\n            />\n          </Box>\n        )}\n        <MenuMobileLink path=\"/settings\" content=\"Settings\" />\n        <MenuMobileLink path=\"/logout\" content=\"Log out\" />\n      </Box>\n    );\n  }\n\n  return (\n    <Box data-cy=\"user-menu\" sx={{ width: '93px' }}>\n      <Flex onClick={() => setShowProfileModal((x) => !x)} sx={{ ml: 1, height: '100%' }}>\n        {profile.photo ? (\n          <Avatar\n            data-cy=\"header-avatar\"\n            loading=\"lazy\"\n            src={profile.photo.publicUrl}\n            sx={{\n              objectFit: 'cover',\n              width: '40px',\n              height: '40px',\n            }}\n          />\n        ) : (\n          <MemberBadge profileType={profile.type || undefined} sx={{ cursor: 'pointer' }} />\n        )}\n      </Flex>\n      <Flex>\n        {showProfileModal && (\n          <div ref={modalRef}>\n            <ProfileModal />\n          </div>\n        )}\n      </Flex>\n    </Box>\n  );\n});\n\nexport default Profile;\n"
  },
  {
    "path": "src/pages/common/Header/Menu/Profile/ProfileButtonItem.tsx",
    "content": "import { Button, ReturnPathLink } from 'oa-components';\nimport React, { useContext } from 'react';\n\nimport { MobileMenuContext } from '../../MobileMenuContext';\n\ninterface IProps {\n  link: string;\n  text: string;\n  variant: string;\n  style?: React.CSSProperties;\n  isMobile?: boolean;\n  sx?: any;\n}\n\nexport const ProfileButtonItem = (props: IProps) => {\n  const mobileMenuContext = useContext(MobileMenuContext);\n\n  return (\n    <ReturnPathLink to={props.link} style={{ minWidth: 'auto' }}>\n      <Button\n        type=\"button\"\n        onClick={() => props.isMobile && mobileMenuContext.setIsVisible(false)}\n        variant={props.variant}\n        {...(props.isMobile ? { large: true } : {})}\n        data-cy={props.text.toLowerCase()}\n        sx={{\n          ...props.sx,\n          display: props.isMobile ? ['flex', 'flex', 'none'] : ['none', 'none', 'flex'],\n        }}\n      >\n        {props.text}\n      </Button>\n    </ReturnPathLink>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/Header/Menu/Profile/ProfileButtons.tsx",
    "content": "import { ClientOnly } from 'remix-utils/client-only';\nimport { Box, Flex } from 'theme-ui';\nimport { ProfileButtonItem } from './ProfileButtonItem';\nimport './profile.css';\n\ninterface IProps {\n  isMobile?: boolean;\n}\n\nconst ProfileButtons = (props: IProps) => {\n  const _commonMobileBtnStyle = {\n    fontSize: 1,\n    justifyContent: 'center',\n    textAlign: 'center',\n    width: '100%',\n  };\n\n  if (props.isMobile) {\n    return (\n      <Flex\n        className=\"util__fade-in\"\n        sx={{\n          width: '100%',\n          justifyContent: 'center',\n        }}\n      >\n        <Box\n          sx={{\n            pt: 1,\n            pb: 2,\n            display: 'block',\n          }}\n        >\n          <ClientOnly fallback={<></>}>\n            {() => (\n              <>\n                <ProfileButtonItem\n                  link=\"/sign-in\"\n                  text=\"Login\"\n                  variant=\"secondary\"\n                  sx={{\n                    ..._commonMobileBtnStyle,\n                    fontWeight: 'bold',\n                    marginRight: 2,\n                    marginBottom: 2,\n                  }}\n                  isMobile={true}\n                />\n                <ProfileButtonItem\n                  link=\"/sign-up\"\n                  text=\"Join\"\n                  variant=\"outline\"\n                  isMobile={true}\n                  sx={{\n                    ..._commonMobileBtnStyle,\n                  }}\n                />\n              </>\n            )}\n          </ClientOnly>\n        </Box>\n      </Flex>\n    );\n  }\n\n  return (\n    <ClientOnly fallback={<></>}>\n      {() => (\n        <>\n          <ProfileButtonItem\n            link=\"/sign-in\"\n            text=\"Login\"\n            variant=\"secondary\"\n            sx={{\n              fontWeight: 'bold',\n              marginRight: 2,\n              fontSize: 2,\n            }}\n          />\n          <ProfileButtonItem link=\"/sign-up\" text=\"Join\" variant=\"outline\" sx={{ fontSize: 2 }} />\n        </>\n      )}\n    </ClientOnly>\n  );\n};\n\nexport default ProfileButtons;\n"
  },
  {
    "path": "src/pages/common/Header/Menu/Profile/UpgradeBadgeLink.tsx",
    "content": "import type { UpgradeBadge } from 'oa-shared';\nimport { trackEvent } from 'src/common/Analytics';\nimport type { ThemeUIStyleObject } from 'theme-ui';\nimport { Box, Image } from 'theme-ui';\n\ninterface Props {\n  upgradeBadge: UpgradeBadge;\n  onClick?: () => void;\n  sx?: ThemeUIStyleObject;\n  'data-cy'?: string;\n}\n\nexport const UpgradeBadgeLink = ({\n  upgradeBadge,\n  onClick,\n  sx,\n  'data-cy': dataCy = 'upgrade-badge-link',\n}: Props) => {\n  return (\n    <Box\n      as=\"a\"\n      // @ts-expect-error - Box doesn't properly type 'as' prop with anchor attributes\n      href={upgradeBadge.actionUrl}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      data-cy={dataCy}\n      onClick={() => {\n        onClick?.();\n        trackEvent({\n          category: 'profiles',\n          action: 'upgradeBadgeClicked',\n          label: upgradeBadge.actionLabel,\n        });\n      }}\n      sx={{\n        display: 'flex',\n        alignItems: 'center',\n        gap: 1,\n        color: 'black',\n        ...sx,\n      }}\n    >\n      {upgradeBadge.actionLabel}\n      {upgradeBadge.badge?.imageUrl && (\n        <Image\n          src={upgradeBadge.badge.imageUrl}\n          sx={{ height: 20, width: 20 }}\n          alt={upgradeBadge.badge.displayName || 'badge'}\n        />\n      )}\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/Header/Menu/Profile/profile.css",
    "content": ".util__fade-in {\n  animation: fadeIn 480ms;\n}\n\n@keyframes fadeIn {\n  0% {\n    opacity: 0;\n  }\n  100% {\n    opacity: 1;\n  }\n}\n"
  },
  {
    "path": "src/pages/common/Header/Menu/ProfileModal/ProfileModal.tsx",
    "content": "import styled from '@emotion/styled';\nimport { observer } from 'mobx-react';\nimport { ReturnPathLink } from 'oa-components';\nimport { NavLink } from 'react-router';\nimport { AuthWrapper } from 'src/common/AuthWrapper';\nimport { UpgradeBadgeLink } from 'src/pages/common/Header/Menu/Profile/UpgradeBadgeLink';\nimport { useProfileStore } from 'src/stores/Profile/profile.store';\nimport { Box, Flex, useThemeUI } from 'theme-ui';\n\nconst ModalContainer = styled(Box)`\n  max-width: 100%;\n  max-height: 100%;\n  position: absolute;\n  right: 10px;\n  top: 60px;\n  z-index: ${(props) => props.theme.zIndex.modalProfile};\n  height: 100%;\n`;\n\nconst ModalLink = styled(NavLink)`\n  z-index: ${(props) => props.theme.zIndex.modalProfile};\n  display: flex;\n  flex-direction: column;\n  color: ${(props) => props.theme.colors.black};\n  padding: 10px 30px 10px 30px;\n  text-align: left;\n  width: 100%;\n  max-width: 100%;\n  max-height: 100%;\n\n  &:hover,\n  &:focus,\n  &:active,\n  &.current {\n    background-color: ${(props) => props.theme.colors.background};\n  }\n`;\n\nexport const ProfileModal = observer(() => {\n  const { profile: activeUser, upgradeBadgeForCurrentUser } = useProfileStore();\n  const { theme } = useThemeUI();\n\n  const upgradeBadge = upgradeBadgeForCurrentUser;\n  const shouldShowUpgrade = !!upgradeBadge;\n\n  return (\n    <ModalContainer data-cy=\"user-menu-list\">\n      <Flex\n        sx={{\n          zIndex: (theme as any).zIndex.modalProfile,\n          position: 'relative',\n          background: 'white',\n          border: '2px solid black',\n          borderRadius: 1,\n          overflow: 'hidden',\n          flexDirection: 'column',\n        }}\n      >\n        <ModalLink\n          to={activeUser?.username ? '/u/' + activeUser.username : '/settings/profile'}\n          data-cy=\"menu-Profile\"\n          className={({ isActive }) => (isActive ? 'current' : '')}\n        >\n          Profile\n        </ModalLink>\n        {shouldShowUpgrade && (\n          <UpgradeBadgeLink\n            upgradeBadge={upgradeBadge}\n            data-cy=\"menu-upgrade-badge\"\n            sx={{\n              padding: '10px 30px 10px 30px',\n              textAlign: 'left',\n              '&:hover': {\n                backgroundColor: 'background',\n              },\n            }}\n          />\n        )}\n        <AuthWrapper>\n          <ModalLink\n            to=\"/settings\"\n            data-cy=\"menu-Settings\"\n            className={({ isActive }) => (isActive ? 'current' : '')}\n          >\n            Settings\n          </ModalLink>\n        </AuthWrapper>\n        <Box\n          sx={{\n            padding: '10px 30px 10px 30px',\n            '&:hover': { background: 'background' },\n          }}\n        >\n          <ReturnPathLink data-cy=\"menu-Logout\" to=\"/logout\" style={{ color: 'black' }}>\n            Log out\n          </ReturnPathLink>\n        </Box>\n      </Flex>\n    </ModalContainer>\n  );\n});\n"
  },
  {
    "path": "src/pages/common/Header/MobileMenuContext.tsx",
    "content": "import { createContext } from 'react';\n\ninterface MobileMenuContextType {\n  isVisible: boolean;\n  setIsVisible: React.Dispatch<React.SetStateAction<boolean>>;\n}\n\nexport const MobileMenuContext = createContext<MobileMenuContextType>({\n  isVisible: false,\n  setIsVisible: () => {},\n});\n"
  },
  {
    "path": "src/pages/common/Layout/ListHeader.tsx",
    "content": "import { Flex, Heading, Text } from 'theme-ui';\n\ninterface IProps {\n  itemCount: number;\n  actionComponents: React.ReactNode;\n  headingTitle: string;\n  categoryComponent: React.ReactNode;\n  filteringComponents: React.ReactNode;\n  showDrafts: boolean;\n  mobileFilteringComponents?: React.ReactNode;\n  searchString?: string;\n}\n\nexport const ListHeader = (props: IProps) => {\n  const {\n    itemCount,\n    actionComponents,\n    headingTitle,\n    showDrafts,\n    categoryComponent,\n    filteringComponents,\n    mobileFilteringComponents,\n    searchString,\n  } = props;\n\n  const itemLabel = itemCount === 1 ? 'item' : 'items';\n  return (\n    <>\n      <Flex\n        sx={{\n          paddingTop: [6, 6, 12],\n          flexDirection: 'column',\n          gap: [4, 4, 8],\n        }}\n      >\n        <Heading\n          as=\"h1\"\n          sx={{\n            marginX: 'auto',\n            textAlign: 'center',\n            fontWeight: 'bold',\n            fontSize: 5,\n          }}\n        >\n          {headingTitle}\n        </Heading>\n        <Flex sx={{ justifyContent: 'center' }}>{categoryComponent}</Flex>\n      </Flex>\n      <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n        <Flex\n          sx={{\n            justifyContent: 'space-between',\n            flexDirection: ['column', 'row', 'row'],\n            gap: [2, 2, 2],\n            maxWidth: '100%',\n          }}\n        >\n          <Flex\n            sx={{\n              flexDirection: ['column', 'column', 'row'],\n              gap: [2, 2, 2],\n              width: ['100%', '100%', 'auto'],\n              alignItems: ['flex-start', 'flex-start', 'center'],\n              display: ['none', 'none', 'flex'],\n            }}\n          >\n            {!showDrafts && filteringComponents}\n          </Flex>\n          <Flex\n            sx={{\n              width: ['100%', '100%', 'auto'],\n              flexShrink: 0,\n              justifyContent: 'space-between',\n            }}\n          >\n            {mobileFilteringComponents ?? null}\n            <Flex sx={{ gap: 2 }}>{actionComponents}</Flex>\n          </Flex>\n        </Flex>\n        <Flex sx={{ gap: 4, lineHeight: '1.5rem' }}>\n          {!searchString ? (\n            <Flex\n              sx={{\n                flexDirection: 'row',\n                justifyContent: ['space-between', 'space-between', 'flex-start'],\n                alignItems: 'center',\n                width: ['100%', '100%', 'auto'],\n              }}\n            >\n              <Text>{`${itemCount} ${itemLabel}`}</Text>\n            </Flex>\n          ) : (\n            <Flex\n              sx={{\n                flexDirection: 'row',\n                justifyContent: ['space-between', 'space-between', 'flex-start'],\n                alignItems: 'center',\n                width: ['100%', '100%', 'auto'],\n              }}\n            >\n              <Text>{searchString && `${itemCount} ${itemLabel} for \"${searchString}\"`}</Text>\n            </Flex>\n          )}\n        </Flex>\n      </Flex>\n    </>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/Layout/Main.tsx",
    "content": "import type { FlexProps } from 'theme-ui';\nimport { Flex } from 'theme-ui';\n\ninterface ILayoutProps {\n  ignoreMaxWidth?: boolean;\n}\n\ntype IProps = FlexProps & ILayoutProps;\n\nconst Main = (props: IProps) => {\n  // avoid passing custom props\n  const { ignoreMaxWidth, ...rest } = props;\n\n  return (\n    <Flex {...rest} sx={{ flexDirection: 'column' }}>\n      <Flex\n        className=\"main-container\"\n        sx={{\n          flexDirection: 'column',\n          width: '100%',\n          height: '100%',\n          ...(!ignoreMaxWidth && {\n            // Base css for all the pages, except Map & Academy\n            position: 'relative',\n            maxWidth: 'container',\n            px: [2, 2, 4],\n            mx: 'auto',\n            my: 0,\n          }),\n          ...(ignoreMaxWidth && {\n            margin: '0',\n            padding: '0',\n          }),\n        }}\n      >\n        {props.children}\n      </Flex>\n    </Flex>\n  );\n};\n\nexport default Main;\n"
  },
  {
    "path": "src/pages/common/Layout/MobileSortModal.tsx",
    "content": "import { Icon, Modal } from 'oa-components';\nimport { Box, Button, Flex, Text } from 'theme-ui';\n\nexport interface FilterSection {\n  title: string;\n  options: { label: string; value: string }[];\n  selectedValue: string;\n  onSelect: (value: string) => void;\n}\n\ninterface Props {\n  isOpen: boolean;\n  onDismiss: () => void;\n  title: string;\n  sections: FilterSection[];\n  onApply: () => void;\n  onReset: () => void;\n}\n\nexport const MobileSortModal = (props: Props) => {\n  const { isOpen, onDismiss, title, sections, onApply, onReset } = props;\n\n  return (\n    <Modal\n      onDismiss={onDismiss}\n      isOpen={isOpen}\n      sx={{\n        width: '90vw',\n        maxHeight: '80vh',\n        p: 0,\n      }}\n    >\n      <Flex\n        sx={{\n          justifyContent: 'space-between',\n          alignItems: 'center',\n          px: 3,\n          py: 2,\n          borderBottom: '2px solid',\n          borderColor: 'muted',\n        }}\n      >\n        <Box sx={{ width: 32 }} />\n        <Text sx={{ fontSize: 4 }}>{title}</Text>\n        <Button\n          variant=\"outline\"\n          sx={{\n            border: 'none',\n            padding: 0,\n            minWidth: 32,\n            display: 'flex',\n            justifyContent: 'flex-end',\n            alignItems: 'center',\n            '&:hover': { backgroundColor: 'transparent' },\n          }}\n          onClick={onDismiss}\n        >\n          <Icon glyph=\"close-modal\" size={20} />\n        </Button>\n      </Flex>\n\n      <Box\n        sx={{\n          flex: 1,\n          overflowY: 'auto',\n          mt: 2,\n          mb: 2,\n        }}\n      >\n        {sections.map((section, index) => (\n          <Box key={section.title}>\n            <Flex\n              sx={{\n                flexDirection: 'column',\n                px: 3,\n              }}\n            >\n              <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>\n                <li>\n                  <Flex\n                    sx={{\n                      paddingTop: '10px',\n                      paddingBottom: '10px',\n                      alignItems: 'center',\n                      fontSize: 3,\n                      justifyContent: 'space-between',\n                    }}\n                  >\n                    <Text>{section.title}</Text>\n                  </Flex>\n                </li>\n                {section.options.map((option) => (\n                  <li key={option.value}>\n                    <Flex\n                      onClick={() => section.onSelect(option.value)}\n                      sx={{\n                        cursor: 'pointer',\n                        justifyContent: 'space-between',\n                        alignItems: 'center',\n                        paddingTop: '10px',\n                        paddingBottom: '10px',\n                        fontSize: 2,\n                      }}\n                    >\n                      {option.label}\n                      <Flex\n                        sx={{\n                          width: 22,\n                          height: 22,\n                          flexShrink: 0,\n                          alignItems: 'center',\n                          justifyContent: 'center',\n                        }}\n                      >\n                        {section.selectedValue === option.value ? (\n                          <Icon glyph=\"check\" color=\"green\" size={22} />\n                        ) : null}\n                      </Flex>\n                    </Flex>\n                  </li>\n                ))}\n              </ul>\n            </Flex>\n            {index < sections.length - 1 && (\n              <Box\n                sx={{\n                  mt: 2,\n                  mb: 2,\n                  borderTop: '2px solid',\n                  borderColor: 'softgrey',\n                }}\n              />\n            )}\n          </Box>\n        ))}\n      </Box>\n\n      <Flex\n        sx={{\n          gap: 2,\n          borderTop: '2px solid',\n          borderColor: 'muted',\n          paddingTop: '10px',\n          paddingBottom: '10px',\n          px: 3,\n        }}\n      >\n        <Button\n          variant=\"primary\"\n          onClick={onApply}\n          sx={{\n            flex: 1,\n            display: 'flex',\n            justifyContent: 'center',\n            alignItems: 'center',\n            cursor: 'pointer',\n          }}\n        >\n          Apply\n        </Button>\n        <Button\n          variant=\"outline\"\n          sx={{\n            border: 'none',\n            flex: 1,\n            display: 'flex',\n            justifyContent: 'center',\n            alignItems: 'center',\n          }}\n          onClick={onReset}\n        >\n          Reset\n        </Button>\n      </Flex>\n    </Modal>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/NotificationsContext.ts",
    "content": "import type { NotificationDisplay } from 'oa-shared';\nimport { createContext } from 'react';\n\ntype INotificationsContext = {\n  notifications: NotificationDisplay[] | null;\n  isUpdatingNotifications: boolean;\n  updateNotifications?: () => void;\n};\n\nexport const NotificationsContext = createContext<INotificationsContext>({\n  notifications: null,\n  isUpdatingNotifications: false,\n});\n"
  },
  {
    "path": "src/pages/common/SessionContext.ts",
    "content": "import type { JwtPayload } from '@supabase/supabase-js';\nimport { createContext } from 'react';\n\nexport const SessionContext = createContext<JwtPayload | null>(null);\n"
  },
  {
    "path": "src/pages/common/StickyButton.tsx",
    "content": "import { Button, ExternalLink } from 'oa-components';\nimport { useEffect, useState } from 'react';\nimport { useLocation } from 'react-router';\nimport { Box, Text } from 'theme-ui';\n\nexport const StickyButton = () => {\n  const location = useLocation();\n  const [page, setPage] = useState<string>('');\n\n  useEffect(() => {\n    setPage(window.location.href);\n  }, [location]);\n\n  const href = `/feedback/#page=${page}`;\n\n  return (\n    <Box\n      sx={{\n        position: 'fixed',\n        bottom: [2, 5],\n        right: [2, 5],\n        display: 'block',\n        zIndex: 3000,\n      }}\n    >\n      <ExternalLink href={href} data-cy=\"feedback\">\n        <Button type=\"button\" sx={{ display: ['none', 'inherit'] }} variant=\"primary\" icon=\"update\">\n          <Text>Report a Problem</Text>\n        </Button>\n\n        <Button\n          type=\"button\"\n          sx={{ display: ['inherit', 'none'] }}\n          variant=\"primary\"\n          icon=\"update\"\n          small\n        >\n          <Text>Problem?</Text>\n        </Button>\n      </ExternalLink>\n    </Box>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/TenantContext.ts",
    "content": "import { TenantSettings } from 'oa-shared';\nimport { createContext } from 'react';\nimport type { ConfigurationOption } from 'src/config/constants';\nimport { _supportedConfigurationOptions } from 'src/config/constants';\n\nexport type TenantSettingsContext =\n  | (TenantSettings & {\n      environment: Partial<EnvVariables>;\n    })\n  | null;\n\nexport const getEnvVariables = (): Partial<EnvVariables> => {\n  const envVariables: Partial<EnvVariables> = {};\n  _supportedConfigurationOptions.forEach((option) => {\n    const value = import.meta.env[option];\n    if (value !== undefined) {\n      envVariables[option] = value;\n    }\n  });\n  return envVariables;\n};\n\nexport const TenantContext = createContext<TenantSettingsContext | null>(null);\n\ntype EnvVariables = {\n  [K in ConfigurationOption]: string;\n};\n"
  },
  {
    "path": "src/pages/common/UserNameSelect/UserNameSelect.tsx",
    "content": "import { Select } from 'oa-components';\nimport { useEffect, useState } from 'react';\nimport { FieldContainer } from 'src/common/Form/FieldContainer';\nimport { profilesService } from 'src/services/profilesService';\nimport { useDebouncedCallback } from 'use-debounce';\n\nexport interface IOption {\n  value: string;\n  label: string;\n}\n\ninterface IProps {\n  input: {\n    value: string[];\n    onChange: (v: string[]) => void;\n  };\n  placeholder?: string;\n  isForm?: boolean;\n}\n\nexport const UserNameSelect = (props: IProps) => {\n  const { input, placeholder } = props;\n  const [inputValue, setInputValue] = useState('');\n  const [options, setOptions] = useState<IOption[]>([]);\n\n  const loadOptions = async (inputVal: string) => {\n    if (!inputVal) {\n      setOptions([]);\n      return;\n    }\n\n    const profiles = await profilesService.search(inputVal);\n    const options = profiles\n      .filter((x) => x.username)\n      .map((x) => ({\n        label: x.username!,\n        value: x.username!,\n      }));\n\n    setOptions(options);\n  };\n\n  const debounceLoad = useDebouncedCallback((q: string) => loadOptions(q), 500);\n\n  useEffect(() => {\n    debounceLoad(inputValue);\n  }, [inputValue]);\n\n  const value = input.value?.length\n    ? input.value.map((user) => ({\n        value: user,\n        label: user,\n      }))\n    : [];\n\n  return (\n    <FieldContainer data-cy=\"UserNameSelect\">\n      <Select\n        variant=\"form\"\n        options={options}\n        placeholder={placeholder}\n        value={value}\n        onChange={(v: IOption[]) => input.onChange(v.map((user) => user.value))}\n        onInputChange={setInputValue}\n        isClearable={true}\n        isMulti={true}\n        noOptionsMessage={(i) => (i.inputValue ? 'Not found' : 'Search users')}\n      />\n    </FieldContainer>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/UserNameTag/UserNameTag.tsx",
    "content": "import type { PublishedAction } from 'oa-components';\nimport { DisplayDate, Username } from 'oa-components';\nimport type { Author } from 'oa-shared';\nimport { Flex, Text } from 'theme-ui';\n\ninterface IProps {\n  author: Author;\n  createdAt?: string | number | Date;\n  publishedAction?: PublishedAction;\n  modifiedAt?: string | number | Date | null;\n  publishedAt?: string | number | Date | null;\n}\n\nexport const UserNameTag = (props: IProps) => {\n  const { author, createdAt, publishedAction = 'Published', modifiedAt, publishedAt } = props;\n\n  return (\n    <Flex sx={{ flexDirection: 'column' }}>\n      <Flex sx={{ alignItems: 'center', gap: 1 }}>\n        <Username user={author} sx={{ position: 'relative' }} />\n        {createdAt && (\n          <>\n            <Text variant=\"auxiliary\">|</Text>\n            <Text variant=\"auxiliary\">\n              <DisplayDate\n                publishedAction={publishedAction}\n                createdAt={createdAt}\n                publishedAt={publishedAt}\n                modifiedAt={modifiedAt}\n              />\n            </Text>\n          </>\n        )}\n      </Flex>\n    </Flex>\n  );\n};\n"
  },
  {
    "path": "src/pages/common/banner.service.ts",
    "content": "import type { Banner } from 'oa-shared';\n\nconst getBanner = async () => {\n  try {\n    const response = await fetch('/api/banner');\n    return (await response.json()) as Banner;\n  } catch (error) {\n    console.error({ error });\n    return null;\n  }\n};\n\nexport const bannerService = {\n  getBanner,\n};\n"
  },
  {
    "path": "src/pages/common/labels.ts",
    "content": "export const drafts = {\n  myDrafts: 'My Drafts',\n  backToList: 'Back to list',\n};\n"
  },
  {
    "path": "src/pages/constants.ts",
    "content": "export const MAX_LINK_LENGTH = 2000;\n"
  },
  {
    "path": "src/pages/policy/PrivacyPolicy.tsx",
    "content": "import { ExternalLink } from 'oa-components';\n\nconst PrivacyPolicy = () => (\n  <>\n    <h1>Privacy Policy</h1>\n    <p>\n      Stichting One Army built the OneArmy Community Platform as an Open Source web app. This\n      SERVICE is provided by Stichting One Army at no cost and is intended for use as is.\n    </p>\n    <p>\n      This page is used to inform visitors regarding our policies with the collection, use, and\n      disclosure of Personal Information if anyone decided to use our Service.\n    </p>\n    <p>\n      If you choose to use our Service, then you agree to the collection and use of information in\n      relation to this policy. The Personal Information that we collect is used for providing and\n      improving the Service. We will not use or share your information with anyone except as\n      described in this Privacy Policy.\n    </p>\n    <p>\n      The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions,\n      which is accessible at OneArmy Community Platform unless otherwise defined in this Privacy\n      Policy.\n    </p>\n    <p>\n      <strong>Information Collection and Use</strong>\n    </p>\n    <p>\n      For a better experience, while using our Service, we may require you to provide us with\n      certain personally identifiable information, including but not limited to Email, username,\n      optionally workspace profile, social media contacts and links, and photos. The information\n      that we request will be retained by us and used as described in this privacy policy.\n    </p>\n    <p>The app does use third party services that may collect information used to identify you.</p>\n    <div>\n      <p>Link to privacy policy of third party service providers used by the app</p>\n      <ul>\n        <li>\n          <ExternalLink href=\"https://policies.google.com/privacy\">Google Analytics</ExternalLink>\n        </li>\n      </ul>\n    </div>\n    <p>\n      <strong>Log Data</strong>\n    </p>\n    <p>\n      We want to inform you that whenever you use our Service, in a case of an error in the app we\n      collect data and information (through third party products) on your phone called Log Data.\n      This Log Data may include information such as your device Internet Protocol (“IP”) address,\n      device name, operating system version, the configuration of the app when utilizing our\n      Service, the time and date of your use of the Service, and other statistics.\n    </p>\n    <p>\n      <strong>Cookies</strong>\n    </p>\n    <p>\n      Cookies are files with a small amount of data that are commonly used as anonymous unique\n      identifiers. These are sent to your browser from the websites that you visit and are stored on\n      your device's internal memory.\n    </p>\n    <p>\n      This Service does not use these “cookies” explicitly. However, the app may use third party\n      code and libraries that use “cookies” to collect information and improve their services. You\n      have the option to either accept or refuse these cookies and know when a cookie is being sent\n      to your device. If you choose to refuse our cookies, you may not be able to use some portions\n      of this Service.\n    </p>\n    <p>\n      <strong>Service Providers</strong>\n    </p>\n    <p>We may employ third-party companies and individuals due to the following reasons:</p>\n    <ul>\n      <li>To facilitate our Service;</li>\n      <li>To provide the Service on our behalf;</li>\n      <li>To perform Service-related services; or</li>\n      <li>To assist us in analyzing how our Service is used.</li>\n    </ul>\n    <p>\n      We want to inform users of this Service that these third parties have access to your Personal\n      Information. The reason is to perform the tasks assigned to them on our behalf. However, they\n      are obligated not to disclose or use the information for any other purpose.\n    </p>\n    <p>\n      <strong>Security</strong>\n    </p>\n    <p>\n      We value your trust in providing us your Personal Information, thus we are striving to use\n      commercially acceptable means of protecting it. But remember that no method of transmission\n      over the internet, or method of electronic storage is 100% secure and reliable, and we cannot\n      guarantee its absolute security.\n    </p>\n    <p>\n      <strong>Links to Other Sites</strong>\n    </p>\n    <p>\n      This Service may contain links to other sites. If you click on a third-party link, you will be\n      directed to that site. Note that these external sites are not operated by us. Therefore, we\n      strongly advise you to review the Privacy Policy of these websites. We have no control over\n      and assume no responsibility for the content, privacy policies, or practices of any\n      third-party sites or services.\n    </p>\n    <p>\n      <strong>Children’s Privacy</strong>\n    </p>\n    <p>\n      These Services do not address anyone under the age of 13. We do not knowingly collect\n      personally identifiable information from children under 13. In the case we discover that a\n      child under 13 has provided us with personal information, we immediately delete this from our\n      servers. If you are a parent or guardian and you are aware that your child has provided us\n      with personal information, please contact us so that we will be able to do necessary actions.\n    </p>\n    <p>\n      <strong>Changes to This Privacy Policy</strong>\n    </p>\n    <p>\n      We may update our Privacy Policy from time to time. Thus, you are advised to review this page\n      periodically for any changes. We will notify you of any changes by posting the new Privacy\n      Policy on this page. These changes are effective immediately after they are posted on this\n      page.\n    </p>\n    <p>\n      <strong>Contact Us</strong>\n    </p>\n    <p>\n      If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact\n      us at platform@onearmy.world.\n    </p>\n  </>\n);\n\nexport default PrivacyPolicy;\n"
  },
  {
    "path": "src/pages/policy/TermsPolicy.tsx",
    "content": "import { ExternalLink } from 'oa-components';\n\nconst TermsPolicy = () => {\n  return (\n    <>\n      <h1>Terms &amp; Conditions</h1>\n      <h2>Updated 20th January 2020</h2>\n      <p>\n        By using the platform, these terms will automatically platformly to you – you should make\n        sure therefore that you read them carefully before using the platform. The platform code is\n        licensed as MIT, and full details of the code license can be found on{' '}\n        <ExternalLink href=\"https://github.com/ONEARMY/community-platform/blob/master/LICENSE\">\n          GitHub\n        </ExternalLink>\n      </p>\n      <p>\n        You are not allowed to otherwise copy, modify or use trademarks found within the platform in\n        any way. All the trade marks, copyright, database rights and other intellectual property\n        rights related to this platform instance belong to Precious Plastic.\n      </p>\n      <p>\n        Stichting OneArmy is committed to ensuring that the platform is as useful and efficient as\n        possible. For that reason, we reserve the right to make changes to the platform or to charge\n        for its services, at any time and for any reason. We will never charge you for the platform\n        or its services without making it very clear to you exactly what you’re paying for.\n      </p>\n      <p>\n        You should be aware that there are certain things that Stichting OneArmy will not take\n        responsibility for. Certain functions of the platform will require the platform have an\n        active internet connection. The connection can be Wi-Fi, or provided by your mobile network\n        provider, but Stichting OneArmy cannot take responsibility for the platform not working at\n        full functionality if you don’t have access to Wi-Fi, and you don’t have any of your data\n        allowance left.\n      </p>\n      <p>\n        If you’re using the platform outside of an area with Wi-Fi, you should remember that your\n        terms of the agreement with your mobile network provider will still platformly. As a result,\n        you may be charged by your mobile provider for the cost of data for the duration of the\n        connection while accessing the platform, or other third party charges. In using the\n        platform, you’re accepting responsibility for any such charges, including roaming data\n        charges if you use the platform outside of your home territory (i.e. region or country)\n        without turning off data roaming. If you are not the bill payer for the device on which\n        you’re using the platform, please be aware that we assume that you have received permission\n        from the bill payer for using the platform.\n      </p>\n      <p>\n        Along the same lines, Stichting OneArmy cannot always take responsibility for the way you\n        use the platform i.e. You need to make sure that your device stays charged – if it runs out\n        of battery and you can’t turn it on to avail the Service, Stichting OneArmy cannot accept\n        responsibility.\n      </p>\n      <p>\n        With respect to Stichting OneArmy’s responsibility for your use of the platform, when you’re\n        using the platform, it’s important to bear in mind that although we endeavour to ensure that\n        it is updated and correct at all times, we do rely on third parties to provide information\n        to us so that we can make it available to you. Stichting OneArmy accepts no liability for\n        any loss, direct or indirect, you experience as a result of relying wholly on this\n        functionality of the platform.\n      </p>\n      <p>\n        At some point, we may wish to update the platform. The requirements for system (and for any\n        additional systems we decide to extend the availability of the platform to) may change, and\n        you’ll need to download the updates if you want to keep using the platform. Stichting\n        OneArmy does not promise that it will always update the platform so that it is relevant to\n        you and/or works with the version that you have previously used. However, you promise to\n        always accept updates to the platformlication when offered to you, We may also wish to stop\n        providing the platform, and may terminate use of it at any time without giving notice of\n        termination to you. Unless we tell you otherwise, upon any termination, (a) the rights and\n        licenses granted to you in these terms will end; (b) you must stop using the platform.\n      </p>\n      <p>\n        <strong>Changes to This Terms and Conditions</strong>\n      </p>\n      <p>\n        We may update our Terms and Conditions from time to time. Thus, you are advised to review\n        this page periodically for any changes. We will notify you of any changes by posting the new\n        Terms and Conditions on this page. These changes are effective immediately after they are\n        posted on this page.\n      </p>\n      <p>\n        <strong>Contact Us</strong>\n      </p>\n      <p>\n        If you have any questions or suggestions about our Terms and Conditions, do not hesitate to\n        contact us at platform@onearmy.world.\n      </p>\n      <p>\n        This Terms and Conditions page was generated by\n        <ExternalLink href=\"https://app-privacy-policy-generator.firebaseapp.com/\">\n          platform Privacy Policy Generator\n        </ExternalLink>\n      </p>\n    </>\n  );\n};\n\nexport default TermsPolicy;\n"
  },
  {
    "path": "src/repository/supabase.server.ts",
    "content": "import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr';\n\n// Create a single supabase client for interacting with your database\nexport function createSupabaseServerClient(request: Request) {\n  const headers = new Headers();\n  const supabase = {\n    headers,\n    client: createServerClient(process.env.SUPABASE_API_URL!, process.env.SUPABASE_KEY!, {\n      cookies: {\n        getAll() {\n          return parseCookieHeader(request.headers.get('Cookie') ?? '').map(({ name, value }) => ({\n            name,\n            value: value ?? '',\n          }));\n        },\n        setAll(cookiesToSet) {\n          cookiesToSet.forEach(({ name, value, options }) =>\n            headers.append('Set-Cookie', serializeCookieHeader(name, value, options)),\n          );\n          headers.append('x-tenant-id', process.env.TENANT_ID!);\n        },\n      },\n      global: {\n        headers: {\n          'x-tenant-id': process.env.TENANT_ID!,\n        },\n      },\n    }),\n  };\n\n  return supabase;\n}\n"
  },
  {
    "path": "src/repository/supabaseAdmin.server.ts",
    "content": "import { createClient } from '@supabase/supabase-js';\n\nexport function createSupabaseAdminServerClient() {\n  return createClient(process.env.SUPABASE_API_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, {\n    auth: {\n      autoRefreshToken: false,\n      persistSession: false,\n    },\n    global: {\n      headers: {\n        'x-tenant-id': process.env.TENANT_ID!,\n      },\n    },\n  });\n}\n"
  },
  {
    "path": "src/root.tsx",
    "content": "import { Global, withEmotionCache } from '@emotion/react';\nimport { ThemeProvider } from '@theme-ui/core';\nimport { GlobalStyles } from 'oa-components';\nimport { theme } from 'oa-themes';\nimport { useContext, useEffect, useRef } from 'react';\nimport type { LinksFunction, LoaderFunctionArgs, MetaFunction } from 'react-router';\nimport { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from 'react-router';\nimport { createSupabaseServerClient } from './repository/supabase.server';\nimport { TenantSettingsService } from './services/tenantSettingsService.server';\nimport { ClientStyleContext, ServerStyleContext } from './styles/context';\nimport { generateTags } from './utils/seo.utils';\n\ninterface DocumentProps {\n  children: React.ReactNode;\n}\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client } = createSupabaseServerClient(request);\n  const settings = await new TenantSettingsService(client).get();\n\n  return {\n    colorPrimary: settings.colorPrimary,\n    colorPrimaryHover: settings.colorPrimaryHover,\n    colorAccent: settings.colorAccent,\n    colorAccentHover: settings.colorAccentHover,\n    siteName: settings.siteName,\n    siteDescription: settings.siteDescription,\n  };\n}\n\nconst Document = withEmotionCache(({ children }: DocumentProps, emotionCache) => {\n  const loaderData = useLoaderData<typeof loader>();\n  const serverStyleData = useContext(ServerStyleContext);\n  const clientStyleData = useContext(ClientStyleContext);\n  const reinjectStylesRef = useRef(true);\n\n  const cssVars = `\n    --color-primary: ${loaderData.colorPrimary};\n    --color-primary-hover: ${loaderData.colorPrimaryHover};\n    --color-accent: ${loaderData.colorAccent};\n    --color-accent-hover: ${loaderData.colorAccentHover};\n  `;\n\n  // const isProd = import.meta.env.VITE_BRANCH === 'production';\n\n  // Only executed on client\n  // When a top level ErrorBoundary or CatchBoundary are rendered,\n  // the document head gets removed, so we have to create the style tags\n  useEffect(() => {\n    if (!reinjectStylesRef.current) {\n      return;\n    }\n    // re-link sheet container\n    emotionCache.sheet.container = document.head;\n\n    // re-inject tags\n    const tags = emotionCache.sheet.tags;\n    emotionCache.sheet.flush();\n    tags.forEach((tag) => {\n      (emotionCache.sheet as any)._insertTag(tag);\n    });\n\n    // reset cache to re-apply global styles\n    clientStyleData!.reset();\n    // ensure we only do this once per mount\n    reinjectStylesRef.current = false;\n  }, [clientStyleData, emotionCache.sheet]);\n\n  return (\n    <html lang=\"en\">\n      <head>\n        <meta charSet=\"utf-8\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\" />\n        <link rel=\"preconnect\" href=\"https://storage.googleapis.com\" crossOrigin=\"anonymous\" />\n        <Meta />\n        <Links />\n        <style dangerouslySetInnerHTML={{ __html: `:root { ${cssVars} }` }} />\n\n        {serverStyleData?.map(({ key, ids, css }) => (\n          <style\n            key={key}\n            data-emotion={`${key} ${ids.join(' ')}`}\n            dangerouslySetInnerHTML={{ __html: css }}\n          />\n        ))}\n      </head>\n      <body>\n        {children}\n        <ScrollRestoration />\n        <Scripts />\n      </body>\n    </html>\n  );\n});\n\nexport const links: LinksFunction = () => {\n  return [\n    {\n      rel: 'icon',\n      href: '/api/favicon',\n      type: 'image/x-icon',\n    },\n    {\n      rel: 'manifest',\n      href: '/manifest.webmanifest',\n    },\n  ];\n};\n\nexport const meta: MetaFunction<typeof loader> = ({ loaderData }) => {\n  const tags = generateTags(\n    loaderData?.siteName || '',\n    loaderData?.siteDescription || undefined,\n    '/social-image.jpg',\n    { siteName: loaderData?.siteName },\n  );\n\n  tags.push({\n    name: 'theme-color',\n    content: loaderData?.colorPrimary || '#000',\n  });\n\n  // iOS PWA meta tags\n  tags.push({ name: 'apple-mobile-web-app-capable', content: 'yes' });\n  tags.push({\n    name: 'apple-mobile-web-app-status-bar-style',\n    content: 'default',\n  });\n  tags.push({\n    name: 'apple-mobile-web-app-title',\n    content: loaderData?.siteName || '',\n  });\n\n  if (import.meta.env.VITE_BRANCH !== 'production') {\n    tags.push({\n      name: 'robots',\n      content: 'noindex',\n    });\n  }\n\n  return tags;\n};\n\nexport default function Root() {\n  return (\n    <Document>\n      <ThemeProvider theme={theme}>\n        <Outlet />\n        <Global styles={GlobalStyles} />\n      </ThemeProvider>\n    </Document>\n  );\n}\n"
  },
  {
    "path": "src/routes/[manifest.webmanifest].tsx",
    "content": "import { WebAppManifest } from 'oa-shared';\nimport { type LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  try {\n    const { client } = createSupabaseServerClient(request);\n\n    const settings = await new TenantSettingsService(client).get();\n\n    const manifest = {\n      name: settings.siteName,\n      short_name: settings.siteName,\n      description: settings.siteDescription,\n      theme_color: settings.colorPrimary,\n      background_color: '#f4f6f7',\n      display: 'standalone',\n      scope: '/',\n      start_url: '/',\n      icons: settings.pwaIcons\n        ? [\n            {\n              src: settings.pwaIcons[16],\n              sizes: '16x16',\n              type: 'image/png',\n              purpose: 'any',\n            },\n            {\n              src: settings.pwaIcons[32],\n              sizes: '32x32',\n              type: 'image/png',\n              purpose: 'any',\n            },\n            {\n              src: settings.pwaIcons[192],\n              sizes: '192x192',\n              type: 'image/png',\n              purpose: 'any',\n            },\n            {\n              src: settings.pwaIcons[256],\n              sizes: '256x256',\n              type: 'image/png',\n              purpose: 'any',\n            },\n            {\n              src: settings.pwaIcons[512],\n              sizes: '512x512',\n              type: 'image/png',\n              purpose: 'any',\n            },\n          ]\n        : undefined,\n    } satisfies WebAppManifest;\n\n    return new Response(JSON.stringify(manifest), {\n      status: 200,\n      headers: {\n        'Content-Type': 'application/manifest+json',\n      },\n    });\n  } catch (error) {\n    console.error(error);\n  }\n  return Response.json(null, { status: 500 });\n}\n"
  },
  {
    "path": "src/routes/[robots.txt].tsx",
    "content": "import { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\n\nexport const loader = async ({ request }: { request: Request }) => {\n  const { headers } = request;\n\n  const host = headers.get('host');\n  let robotText = '';\n\n  if (host?.includes('fly.dev')) {\n    // disable for preview sites\n    robotText = `User-agent: *\n      Disallow: /`;\n  } else {\n    const { client } = createSupabaseServerClient(request);\n    const settings = await new TenantSettingsService(client).get();\n\n    const allModules = ['library', 'map', 'research', 'academy', 'questions', 'news'];\n    const availableModules = settings.supportedModules?.split(',');\n\n    robotText = 'User-agent: *';\n\n    allModules.forEach((x) => {\n      let pagePath = `/${x}/`;\n\n      const permission = availableModules?.includes(x) ? 'Allow' : 'Disallow';\n\n      robotText += `\\n${permission}: ${pagePath}`;\n    });\n  }\n\n  return new Response(robotText, {\n    status: 200,\n    headers: {\n      'Content-Type': 'text/plain',\n    },\n  });\n};\n"
  },
  {
    "path": "src/routes/_.$.tsx",
    "content": "import { NotFoundPage } from 'src/pages/NotFound/NotFound';\n\nexport async function loader() {\n  return null;\n}\n\nexport default function Index() {\n  return <NotFoundPage />;\n}\n"
  },
  {
    "path": "src/routes/_.academy.$.tsx",
    "content": "import { css, Global } from '@emotion/react';\nimport { data, LoaderFunctionArgs } from 'react-router';\nimport Academy from 'src/pages/Academy/Academy';\nimport Main from 'src/pages/common/Layout/Main';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  return generateTags(`Academy - ${loaderData?.siteName}`);\n});\n\nexport default function Index() {\n  return (\n    <Main style={{ flex: 1, overflow: 'hidden' }} ignoreMaxWidth={true}>\n      <Academy />\n      <Global\n        styles={css`\n          html {\n            overflow-y: hidden !important;\n          }\n        `}\n      />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.email-confirmation.tsx",
    "content": "import { Button, HeroBanner } from 'oa-components';\nimport { useEffect, useState } from 'react';\nimport { Form } from 'react-final-form';\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useActionData, useLoaderData, useNavigate } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { Card, Flex, Heading, Text } from 'theme-ui';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const url = new URL(request.url);\n  const error = url.searchParams.get('error_description');\n  const token = url.searchParams.get('token');\n\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (claims.data?.claims) {\n    return redirect('/settings/profile', { headers });\n  }\n\n  if (token || error) {\n    return data({ token, error }, { headers });\n  }\n\n  return redirect('/', { headers });\n};\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  const url = new URL(request.url);\n\n  // Get the token from URL params (it should still be there)\n  const token = url.searchParams.get('token');\n\n  if (!token) {\n    return data({ error: 'Your reset link is invalid' }, { status: 400, headers });\n  }\n\n  const tokenVerification = await client.auth.verifyOtp({\n    token_hash: token,\n    type: 'signup',\n  });\n\n  if (!tokenVerification.data.user) {\n    return data({ error: 'Your link has expired or is invalid' }, { status: 400, headers });\n  }\n\n  return data({ success: true }, { headers });\n};\n\nexport default function Index() {\n  const navigate = useNavigate();\n  const loaderData = useLoaderData<typeof loader>();\n  const actionData = useActionData();\n  const [isConfirmed, setIsConfirmed] = useState(false);\n\n  useEffect(() => {\n    if (actionData?.success) {\n      setIsConfirmed(true);\n    }\n  }, [actionData?.success]);\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <Form\n        onSubmit={() => {}}\n        render={({ submitting }) => {\n          return (\n            <form data-cy=\"email-confirmation-form\" method=\"post\">\n              <Flex\n                sx={{\n                  bg: 'inherit',\n                  px: 2,\n                  width: '100%',\n                  maxWidth: '620px',\n                  mx: 'auto',\n                  mt: [5, 10],\n                  mb: 3,\n                }}\n              >\n                <Flex sx={{ flexDirection: 'column', width: '100%' }}>\n                  <HeroBanner type={isConfirmed ? 'celebration' : 'email'} />\n                  <Card sx={{ borderRadius: 3 }}>\n                    <Flex\n                      sx={{\n                        flexWrap: 'wrap',\n                        flexDirection: 'column',\n                        padding: 4,\n                        gap: 4,\n                        width: '100%',\n                      }}\n                    >\n                      <Flex sx={{ gap: 2, flexDirection: 'column' }}>\n                        <Heading>{isConfirmed ? 'Email verified!' : 'Email confirmation'}</Heading>\n                      </Flex>\n\n                      {loaderData.error ? (\n                        <Text color=\"red\">{loaderData?.error}</Text>\n                      ) : (\n                        <>\n                          {actionData?.error && <Text color=\"red\">{actionData?.error}</Text>}\n\n                          <Flex\n                            sx={{\n                              flexDirection: 'column',\n                              alignItems: 'flex-start',\n                              gap: '2rem',\n                            }}\n                          >\n                            {isConfirmed ? (\n                              <>\n                                <Text\n                                  sx={{\n                                    textAlign: 'center',\n                                    color: 'grey',\n                                    gap: '2rem',\n                                  }}\n                                >\n                                  Great! You can now continue to setup your profile!\n                                </Text>\n                                <Button\n                                  large\n                                  sx={{\n                                    borderRadius: 3,\n                                    width: '100%',\n                                    justifyContent: 'center',\n                                  }}\n                                  variant=\"primary\"\n                                  disabled={submitting}\n                                  type=\"button\"\n                                  onClick={() => navigate('/settings/profile')}\n                                >\n                                  Setup my profile\n                                </Button>\n                              </>\n                            ) : (\n                              <Button\n                                large\n                                data-cy=\"submit\"\n                                sx={{\n                                  borderRadius: 3,\n                                  width: '100%',\n                                  justifyContent: 'center',\n                                }}\n                                variant=\"primary\"\n                                disabled={submitting}\n                                type=\"submit\"\n                              >\n                                Confirm Email\n                              </Button>\n                            )}\n                          </Flex>\n                        </>\n                      )}\n                    </Flex>\n                  </Card>\n                </Flex>\n              </Flex>\n            </form>\n          );\n        }}\n      />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.email-preferences.tsx",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useLoaderData } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\nimport { SupabaseNotificationsViaEmail } from 'src/pages/UserSettings/SupabaseNotificationsViaEmail';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { Alert, Card, Flex } from 'theme-ui';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  const url = new URL(request.url);\n\n  const claims = await client.auth.getClaims();\n\n  if (claims.data?.claims) {\n    return redirect('/settings/notifications', { headers });\n  }\n\n  const code = url.searchParams.get('code');\n\n  if (!code) {\n    const error = `Oh no! Doesn't look you gave us the info needed to workout who you are.`;\n    return data({ code, error }, { headers });\n  }\n\n  return data({ code, error: null }, { headers });\n};\n\nexport default function Index() {\n  const data = useLoaderData<typeof loader>();\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <Flex sx={{ justifyContent: 'center', width: '100%', padding: [2, 4, 6] }}>\n        {data.error && (\n          <Alert variant=\"failure\">\n            {data.error}\n            <br />\n            Click a link in a notification email again, otherwise please report the problem.\n          </Alert>\n        )}\n        {data.code && (\n          <Card sx={{ borderRadius: 3, padding: 4, maxWidth: '650px' }}>\n            <SupabaseNotificationsViaEmail userCode={data.code} />\n          </Card>\n        )}\n      </Flex>\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.feedback.tsx",
    "content": "import { redirect } from 'react-router';\n\nexport function loader() {\n  return redirect(`https://onearmy.retool.com/form/0b8bbbc7-77d0-43ef-90fc-9d6dd9fda734`);\n}\n"
  },
  {
    "path": "src/routes/_.forbidden.tsx",
    "content": "import { Button, ExternalLink } from 'oa-components';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { useLoaderData } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { Card, Flex, Heading, Text } from 'theme-ui';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client } = createSupabaseServerClient(request);\n\n  const settings = await new TenantSettingsService(client).get();\n\n  const url = new URL(request.url);\n  const pageMatch = url.search.match(/[?&]page=([^&]*)/);\n  const page = pageMatch ? decodeURIComponent(pageMatch[1]) : null;\n\n  return { page, settings, url };\n}\n\nexport default function Index() {\n  const { page, settings, url } = useLoaderData<typeof loader>() || {};\n\n  const actionLabel = page ? 'I want to use it' : 'Report the problem';\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <Flex\n        sx={{\n          background: 'inherit',\n          paddingX: 2,\n          width: '100%',\n          maxWidth: '620px',\n          marginX: 'auto',\n          marginTop: [5, 10],\n        }}\n      >\n        <Flex sx={{ flexDirection: 'column', width: '100%' }}>\n          <Flex\n            sx={{\n              flexDirection: 'column',\n            }}\n          >\n            <Card sx={{ borderRadius: 3 }}>\n              <Flex\n                sx={{\n                  padding: 4,\n                  paddingTop: 6,\n                  gap: 2,\n                  flexDirection: 'column',\n                }}\n              >\n                <Flex\n                  sx={{\n                    gap: 1,\n                    flexDirection: 'column',\n                    alignItems: 'center',\n                    textAlign: 'center',\n                  }}\n                >\n                  <Heading>Oh no you can't post, yet</Heading>\n                </Flex>\n                <Text sx={{ textAlign: 'center', color: 'grey' }}>\n                  {page ? (\n                    <>\n                      <p>\n                        <strong>\n                          This is a new feature and we are currently rolling it out to a small group\n                          of people.\n                        </strong>\n                      </p>\n                      <p>\n                        Let us know if you have a project to share and want to be an early tester.\n                        We'd love to set you up.\n                      </p>\n                    </>\n                  ) : (\n                    <p>\n                      <strong>\n                        You don't have the right permissions to go here right now. If this is wrong,\n                        please let us know.\n                      </strong>\n                    </p>\n                  )}\n                  <ExternalLink\n                    href={`mailto:${settings.emailFrom}&subject:Cannot access ${page || url}`}\n                  >\n                    <Button>{actionLabel}</Button>\n                  </ExternalLink>\n                </Text>\n              </Flex>\n            </Card>\n          </Flex>\n        </Flex>\n      </Flex>\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.how-to.$slug._index.tsx",
    "content": "// The library/projects section use to be called 'how-tos' so this\n// exists to ensure users get to the right place\n\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { redirect } from 'react-router';\n\nexport function loader({ params }: LoaderFunctionArgs) {\n  return redirect(`/library/${params.slug}`, 301);\n}\n"
  },
  {
    "path": "src/routes/_.how-to.$slug.edit.tsx",
    "content": "// The library/projects section use to be called 'how-tos' so this\n// exists to ensure users get to the right place\n\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { redirect } from 'react-router';\n\nexport function loader({ params }: LoaderFunctionArgs) {\n  return redirect(`/library/${params.slug}/edit`, 301);\n}\n"
  },
  {
    "path": "src/routes/_.how-to._index.tsx",
    "content": "// The library/projects section use to be called 'how-tos' so this\n// exists to ensure users get to the right place\n\nimport { redirect } from 'react-router';\n\nexport function loader() {\n  return redirect('/library', 301);\n}\n"
  },
  {
    "path": "src/routes/_.how-to.create.tsx",
    "content": "// The library/projects section use to be called 'how-tos' so this\n// exists to ensure users get to the right place\n\nimport { redirect } from 'react-router';\n\nexport function loader() {\n  return redirect('/library/create', 301);\n}\n"
  },
  {
    "path": "src/routes/_.how-to.tsx",
    "content": "// The library/projects section use to be called 'how-tos' so this\n// exists to ensure users get to the right place\n\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { redirect } from 'react-router';\n\nexport function loader({ params }: LoaderFunctionArgs) {\n  return redirect(`/library/${params.slug}`, 301);\n}\n"
  },
  {
    "path": "src/routes/_.library.$slug._index.tsx",
    "content": "import type { DBProject, TenantSettings } from 'oa-shared';\nimport { Project } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, useLoaderData } from 'react-router';\nimport { CommentFactory } from 'src/factories/commentFactory.server';\nimport { ProjectPage } from 'src/pages/Library/Content/Page/ProjectPage';\nimport { NotFoundPage } from 'src/pages/NotFound/NotFound';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { ImageServiceServer } from 'src/services/imageService.server';\nimport { LibraryServiceServer } from 'src/services/libraryService.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const result = await new LibraryServiceServer(client).getBySlug(params.slug as string);\n\n  if (result.error || !result.data) {\n    return data({ project: null }, { headers });\n  }\n\n  const dbProject = result.data as unknown as DBProject;\n\n  const contentService = new ContentServiceServer(client);\n\n  if (dbProject.id) {\n    await contentService.incrementViewCount('projects', dbProject.total_views, dbProject.id);\n  }\n\n  const [usefulVotes, subscribers, tags] = await contentService.getMetaFields(\n    dbProject.id,\n    'projects',\n    dbProject.tags,\n  );\n\n  const images = new LibraryServiceServer(client).getProjectPublicMedia(dbProject);\n\n  const project = Project.fromDB(dbProject, tags, images);\n  project.usefulCount = usefulVotes.count || 0;\n  project.subscriberCount = subscribers.count || 0;\n\n  if (dbProject.author) {\n    const factory = new CommentFactory(new ImageServiceServer(client));\n    project.author = await factory.createAuthor(dbProject.author);\n  }\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data({ project, tenantSettings }, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  const project = (loaderData as any)?.project as Project;\n  const tenantSettings = (loaderData as any)?.tenantSettings as TenantSettings;\n\n  if (!project) {\n    return [];\n  }\n\n  const title = `${project.title} - Library - ${tenantSettings.siteName}`;\n\n  return generateTags(title, project.description, project.coverImage?.publicUrl);\n});\n\nexport default function Index() {\n  const data = useLoaderData<typeof loader>();\n\n  if (!data.project) {\n    return <NotFoundPage />;\n  }\n\n  return <ProjectPage item={data.project} />;\n}\n"
  },
  {
    "path": "src/routes/_.library.$slug.edit.tsx",
    "content": "import { DBProject } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useLoaderData } from 'react-router';\nimport { LibraryForm } from 'src/pages/Library/Content/Common/LibraryForm';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { LibraryServiceServer } from 'src/services/libraryService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn(`/library/${params.slug}/edit`, headers);\n  }\n\n  const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n  const libraryService = new LibraryServiceServer(client);\n\n  const projectDb = (await libraryService.getBySlug(params.slug as string))\n    .data as unknown as DBProject;\n\n  if (!profile || !(await libraryService.isAllowedToEditProject(projectDb, profile))) {\n    return redirect('/forbidden?page=library-edit', { headers });\n  }\n\n  const allImages = projectDb.steps?.flatMap((x) => x.images).filter((x) => !!x) || [];\n  if (projectDb.cover_image) {\n    allImages.push(projectDb.cover_image);\n  }\n  const publicImages = allImages.length\n    ? new StorageServiceServer(client).getPublicUrls(allImages)\n    : [];\n\n  const formData = DBProject.toFormData(projectDb, publicImages);\n\n  return data({ formData, id: projectDb.id }, { headers });\n}\n\nexport default function Index() {\n  const { id, formData } = useLoaderData<typeof loader>();\n\n  return <LibraryForm id={id} formData={formData} />;\n}\n"
  },
  {
    "path": "src/routes/_.library._index.tsx",
    "content": "import { data, LoaderFunctionArgs } from 'react-router';\nimport { LibraryList } from 'src/pages/Library/Content/List/LibraryList';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  return generateTags(`Library - ${loaderData?.siteName}`);\n});\n\nexport default function Index() {\n  return <LibraryList />;\n}\n"
  },
  {
    "path": "src/routes/_.library.create.tsx",
    "content": "import { data } from 'react-router';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { UserAction } from 'src/common/UserAction';\nimport { LibraryForm } from 'src/pages/Library/Content/Common/LibraryForm';\nimport { listing } from 'src/pages/Library/labels';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { Box } from 'theme-ui';\n\nexport async function loader({ request }) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn('/library/create', headers);\n  }\n\n  return data({}, { headers });\n}\n\nexport default function Index() {\n  return (\n    <ClientOnly>\n      {() => (\n        <UserAction\n          incompleteProfile={\n            <Box data-cy=\"incomplete-profile-message\" sx={{ alignSelf: 'center', paddingTop: 5 }}>\n              {listing.incompleteProfile}\n            </Box>\n          }\n          loggedIn={<LibraryForm formData={null} id={null} />}\n          loggedOut={<></>}\n        />\n      )}\n    </ClientOnly>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.library.tsx",
    "content": "import { Outlet } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\n\nexport async function loader() {\n  return null;\n}\n\n// This is a Layout file, it will render for all library routes\nexport default function Index() {\n  return (\n    <Main sx={{ flex: 1 }}>\n      <Outlet />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.map.tsx",
    "content": "import { data, LoaderFunctionArgs } from 'react-router';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport Main from 'src/pages/common/Layout/Main';\nimport MapsPage from 'src/pages/Maps/Maps.client';\nimport { MapPinServiceContext, mapPinService } from 'src/pages/Maps/map.service';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\nimport '../styles/leaflet.css';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  return generateTags(`Map - ${loaderData?.siteName}`);\n});\n\nexport default function Index() {\n  return (\n    <Main ignoreMaxWidth={true}>\n      <MapPinServiceContext.Provider value={mapPinService}>\n        <ClientOnly fallback={<></>}>{() => <MapsPage />}</ClientOnly>\n      </MapPinServiceContext.Provider>\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.news.$slug._index.tsx",
    "content": "import { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBNews } from 'oa-shared';\nimport { News, UserRole } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useLoaderData } from 'react-router';\nimport { ProfileFactory } from 'src/factories/profileFactory.server';\nimport { NewsPage } from 'src/pages/News/NewsPage';\nimport { NotFoundPage } from 'src/pages/NotFound/NotFound';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { NewsServiceServer } from 'src/services/newsService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\nimport { ContentServiceServer } from '../services/contentService.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const result = await new NewsServiceServer(client).getBySlug(params.slug!);\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  if (result.error || !result.data) {\n    return data({ news: null, tenantSettings }, { headers });\n  }\n\n  const dbNews = result.data as unknown as DBNews;\n  const profileBadgeId = dbNews.profile_badge?.id;\n\n  if (!profileBadgeId) {\n    const news = await loadNews(client, dbNews);\n    return data({ news, tenantSettings }, { headers });\n  }\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn(`/news/${dbNews.slug}`, headers);\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const dbProfile = await profileService.getByAuthId(claims.data.claims.sub);\n  const profile = new ProfileFactory(client).fromDB(dbProfile!);\n\n  const isAdmin = profile.roles?.includes(UserRole.ADMIN) ?? false;\n  const hasLinkedBadge = !!profile?.badges?.find((badge) => badge.id === profileBadgeId);\n\n  if (isAdmin || hasLinkedBadge) {\n    const news = await loadNews(client, dbNews);\n    return data({ news, tenantSettings }, { headers });\n  }\n\n  return redirect('/news', { headers });\n}\n\nasync function loadNews(client: SupabaseClient, dbNews: DBNews) {\n  const contentService = new ContentServiceServer(client);\n  await contentService.incrementViewCount('news', dbNews.total_views, dbNews!.id);\n\n  const [usefulVotes, subscribers, tags] = await contentService.getMetaFields(\n    dbNews.id,\n    'news',\n    dbNews.tags,\n  );\n\n  const heroImage = await new NewsServiceServer(client).getHeroImage(dbNews.hero_image);\n\n  const news = News.fromDB(dbNews, tags, heroImage);\n  news.usefulCount = usefulVotes.count || 0;\n  news.subscriberCount = subscribers.count || 0;\n\n  return news;\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  const news = (loaderData as any)?.news as News;\n\n  if (!news) {\n    return [];\n  }\n\n  const title = `${news.title} - News - ${loaderData?.tenantSettings?.siteName}`;\n  const imageUrl = news.heroImage?.publicUrl;\n\n  return generateTags(title, news.body, imageUrl, { type: 'article' });\n});\n\nexport default function Index() {\n  const data = useLoaderData<typeof loader>();\n\n  if (!data.news) {\n    return <NotFoundPage />;\n  }\n\n  return <NewsPage news={data.news} />;\n}\n"
  },
  {
    "path": "src/routes/_.news.$slug.edit.tsx",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport { DBNews, NewsFormData, UserRole } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useLoaderData } from 'react-router';\nimport { NewsForm } from 'src/pages/News/Content/Common/NewsForm';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { NewsServiceServer } from 'src/services/newsService.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn(`/news/${params.slug}/edit`, headers);\n  }\n\n  if (!(await isAllowedToEdit(claims.data.claims.sub, client))) {\n    return redirect('/forbidden?page=news-edit', { headers });\n  }\n\n  if (!params.slug) {\n    return data({ formData: null, id: null }, { headers });\n  }\n\n  const result = await new NewsServiceServer(client).getBySlug(params.slug!);\n\n  if (result.error || !result.data) {\n    return data({ formData: null, id: null }, { headers });\n  }\n\n  const dbNews = result.data as unknown as DBNews;\n\n  const publicImage = dbNews.hero_image\n    ? new StorageServiceServer(client).getPublicUrl(dbNews.hero_image)\n    : null;\n\n  const formData: NewsFormData = DBNews.toFormData(dbNews, publicImage);\n\n  return data({ formData, id: result.data.id }, { headers });\n}\n\nasync function isAllowedToEdit(userAuthId: string, client: SupabaseClient) {\n  const { data } = await client\n    .from('profiles')\n    .select('id,roles')\n    .eq('auth_id', userAuthId)\n    .single();\n\n  return data?.roles?.includes(UserRole.ADMIN);\n}\n\nexport default function Index() {\n  const { formData, id } = useLoaderData<typeof loader>();\n\n  return <NewsForm data-testid=\"news-create-form\" formAction=\"edit\" id={id} formData={formData} />;\n}\n"
  },
  {
    "path": "src/routes/_.news._index.tsx",
    "content": "import { data, LoaderFunctionArgs } from 'react-router';\nimport { NewsListing } from 'src/pages/News/NewsListing';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  return generateTags(`News - ${loaderData?.siteName}`);\n});\n\nexport default function Index() {\n  return <NewsListing />;\n}\n"
  },
  {
    "path": "src/routes/_.news.create.tsx",
    "content": "import { UserRole } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { redirect } from 'react-router';\nimport { UserAction } from 'src/common/UserAction';\nimport { NewsForm } from 'src/pages/News/Content/Common/NewsForm';\nimport { listing } from 'src/pages/News/labels';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { Box } from 'theme-ui';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn('/news/create', headers);\n  }\n\n  const { data } = await client\n    .from('profiles')\n    .select('id,roles')\n    .eq('auth_id', claims.data.claims.sub)\n    .limit(1);\n\n  if (!data!.at(0)!.roles?.includes(UserRole.ADMIN)) {\n    return redirect('/forbidden?page=news-create', { headers });\n  }\n\n  return null;\n}\n\nexport default function Index() {\n  return (\n    <UserAction\n      incompleteProfile={\n        <Box\n          data-cy=\"incomplete-profile-message\"\n          sx={{\n            alignSelf: 'center',\n            paddingTop: 5,\n          }}\n        >\n          {listing.incompleteProfile}\n        </Box>\n      }\n      loggedIn={\n        <NewsForm data-testid=\"news-create-form\" formAction=\"create\" formData={null} id={null} />\n      }\n      loggedOut={<></>}\n    />\n  );\n}\n"
  },
  {
    "path": "src/routes/_.news.tsx",
    "content": "import { useContext } from 'react';\nimport { data, LoaderFunctionArgs, Outlet } from 'react-router';\nimport { isModuleSupported, MODULE } from 'src/modules';\nimport Main from 'src/pages/common/Layout/Main';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  const title = `News - ${loaderData?.siteName}`;\n\n  return generateTags(title);\n});\n\nexport default function Index() {\n  const tenantContext = useContext(TenantContext);\n\n  if (!isModuleSupported(tenantContext?.supportedModules || '', MODULE.NEWS)) {\n    return null;\n  }\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <Outlet />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.patreon.tsx",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { PatreonServiceServer } from 'src/services/patreonService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\nimport { Flex, Text } from 'theme-ui';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const url = new URL(request.url);\n  const patreonCode = url.searchParams.get('code');\n\n  const { client, headers } = createSupabaseServerClient(request);\n\n  if (!patreonCode) {\n    return data({ error: 'No code provided' }, { status: 400, headers });\n  }\n\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return redirect('/sign-in', { headers });\n    }\n\n    const protocol = url.host.startsWith('localhost') ? 'http:' : 'https:';\n    const origin = `${protocol}//${url.host}`;\n\n    await new PatreonServiceServer(client).verifyAndUpdatePatreonUser(\n      patreonCode,\n      claims.data.claims.sub,\n      origin,\n    );\n\n    return redirect('/settings/account', { headers });\n  } catch (error) {\n    console.error('Error verifying patreon code', error);\n  }\n\n  return data({}, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(() => {\n  return generateTags('Patreon');\n});\n\nexport default function Index() {\n  return (\n    <Main>\n      <Flex\n        sx={{\n          flexDirection: 'column',\n          maxWidth: '400px',\n          textAlign: 'center',\n          mx: 'auto',\n          mt: 15,\n        }}\n      >\n        <Text>\n          Sorry, we encountered an error integrating your Patreon account. Please try again later!\n        </Text>\n      </Flex>\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.privacy.tsx",
    "content": "import Main from 'src/pages/common/Layout/Main';\nimport PrivacyPolicy from 'src/pages/policy/PrivacyPolicy';\nimport { generateTags } from 'src/utils/seo.utils';\n\nexport const meta = generateTags('Privacy Policy');\n\nexport default function Index() {\n  return (\n    <Main style={{ flex: 1 }}>\n      <PrivacyPolicy />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.questions.$slug._index.tsx",
    "content": "import type { DBQuestion } from 'oa-shared';\nimport { Question } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, useLoaderData } from 'react-router';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { CommentFactory } from 'src/factories/commentFactory.server';\nimport { NotFoundPage } from 'src/pages/NotFound/NotFound';\nimport { QuestionPage } from 'src/pages/Question/QuestionPage';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ImageServiceServer } from 'src/services/imageService.server';\nimport { QuestionServiceServer } from 'src/services/questionService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\nimport { ContentServiceServer } from '../services/contentService.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const result = await new QuestionServiceServer(client).getBySlug(params.slug!);\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  if (result.error || !result.data) {\n    return data({ question: null, tenantSettings }, { headers });\n  }\n\n  const dbQuestion = result.data as unknown as DBQuestion;\n\n  const contentService = new ContentServiceServer(client);\n\n  if (dbQuestion.id) {\n    await contentService.incrementViewCount('questions', dbQuestion.total_views, dbQuestion.id);\n  }\n\n  const [usefulVotes, subscribers, tags] = await contentService.getMetaFields(\n    dbQuestion.id,\n    'questions',\n    dbQuestion.tags,\n  );\n\n  const images = new StorageServiceServer(client).getPublicUrls(\n    dbQuestion.images!,\n    IMAGE_SIZES.GALLERY,\n  );\n\n  const question = Question.fromDB(dbQuestion, tags, images);\n  question.usefulCount = usefulVotes.count || 0;\n  question.subscriberCount = subscribers.count || 0;\n\n  if (dbQuestion.author) {\n    const factory = new CommentFactory(new ImageServiceServer(client));\n    question.author = await factory.createAuthor(dbQuestion.author);\n  }\n\n  return data({ question, tenantSettings }, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  const question = (loaderData as any)?.question as Question;\n\n  if (!question) {\n    return [];\n  }\n\n  const title = `${question.title} - Question - ${loaderData?.tenantSettings.siteName}`;\n  const imageUrl = question.images?.at(0)?.publicUrl;\n\n  return generateTags(title, question.description, imageUrl);\n});\n\nexport default function Index() {\n  const data = useLoaderData<typeof loader>();\n\n  if (!data.question) {\n    return <NotFoundPage />;\n  }\n\n  return <QuestionPage question={data.question} />;\n}\n"
  },
  {
    "path": "src/routes/_.questions.$slug.edit.tsx",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport { DBQuestion, UserRole } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useLoaderData } from 'react-router';\nimport { QuestionForm } from 'src/pages/Question/Content/Common/QuestionForm';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { QuestionServiceServer } from 'src/services/questionService.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn(`/questions/${params.slug}/edit`, headers);\n  }\n\n  if (!params.slug) {\n    return data({ formData: null, id: null }, { headers });\n  }\n\n  const result = await new QuestionServiceServer(client).getBySlug(params.slug!);\n\n  if (result.error || !result.data) {\n    return data({ formData: null, id: null }, { headers });\n  }\n\n  const dbQuestion = result.data as unknown as DBQuestion;\n\n  if (!(await isUserAllowedToEdit(dbQuestion, claims.data.claims.sub, client))) {\n    return redirect('/forbidden?page=question-edit', { headers });\n  }\n\n  const publicImages = dbQuestion?.images\n    ? new StorageServiceServer(client).getPublicUrls(dbQuestion?.images)\n    : [];\n\n  const formData = DBQuestion.toFormData(dbQuestion, publicImages);\n\n  return data({ formData, id: dbQuestion.id }, { headers });\n}\n\nasync function isUserAllowedToEdit(\n  dbQuestion: DBQuestion,\n  userAuthId: string,\n  client: SupabaseClient,\n) {\n  const profileResult = await client\n    .from('profiles')\n    .select('id,roles')\n    .eq('auth_id', userAuthId)\n    .single();\n\n  return (\n    profileResult.data?.roles?.includes(UserRole.ADMIN) ||\n    profileResult.data?.id === dbQuestion.author?.id\n  );\n}\n\nexport default function Index() {\n  const data = useLoaderData<typeof loader>();\n\n  return (\n    <QuestionForm\n      data-testid=\"question-create-form\"\n      formAction=\"edit\"\n      formData={data.formData}\n      id={data.id}\n    />\n  );\n}\n"
  },
  {
    "path": "src/routes/_.questions._index.tsx",
    "content": "import { data, LoaderFunctionArgs } from 'react-router';\nimport { QuestionListing } from 'src/pages/Question/QuestionListing';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  return generateTags(`Questions - ${loaderData?.siteName}`);\n});\n\nexport default function Index() {\n  return <QuestionListing />;\n}\n"
  },
  {
    "path": "src/routes/_.questions.create.tsx",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { UserAction } from 'src/common/UserAction';\nimport { QuestionForm } from 'src/pages/Question/Content/Common/QuestionForm';\nimport { listing } from 'src/pages/Question/labels';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { Box } from 'theme-ui';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn('/questions/create', headers);\n  }\n\n  return null;\n}\n\nexport default function Index() {\n  return (\n    <UserAction\n      incompleteProfile={\n        <Box data-cy=\"incomplete-profile-message\" sx={{ alignSelf: 'center', paddingTop: 5 }}>\n          {listing.incompleteProfile}\n        </Box>\n      }\n      loggedIn={\n        <QuestionForm\n          data-testid=\"question-create-form\"\n          formAction=\"create\"\n          formData={null}\n          id={null}\n        />\n      }\n      loggedOut={<></>}\n    />\n  );\n}\n"
  },
  {
    "path": "src/routes/_.questions.tsx",
    "content": "import { useContext } from 'react';\nimport { data, LoaderFunctionArgs, Outlet } from 'react-router';\nimport { isModuleSupported, MODULE } from 'src/modules';\nimport Main from 'src/pages/common/Layout/Main';\nimport { TenantContext } from 'src/pages/common/TenantContext';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  return generateTags(`Questions - ${loaderData?.siteName}`);\n});\n\nexport default function Index() {\n  const tenantContext = useContext(TenantContext);\n\n  if (!isModuleSupported(tenantContext?.supportedModules || '', MODULE.QUESTIONS)) {\n    return null;\n  }\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <Outlet />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.research.$slug._index.tsx",
    "content": "import { ResearchItem } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, useLoaderData } from 'react-router';\nimport { CommentFactory } from 'src/factories/commentFactory.server';\nimport { NotFoundPage } from 'src/pages/NotFound/NotFound';\nimport { ResearchArticlePage } from 'src/pages/Research/Content/ResearchArticlePage';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { ImageServiceServer } from 'src/services/imageService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { ResearchServiceServer } from 'src/services/researchService.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const researchClient = new ResearchServiceServer(client);\n  const result = await researchClient.getBySlug(params.slug as string);\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  if (result.error || !result.item) {\n    return data({ research: null, tenantSettings }, { headers });\n  }\n\n  const claims = await client.auth.getClaims();\n  let currentUser: { id: number; username: string | null } | undefined;\n  if (claims.data?.claims) {\n    const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n    if (profile) {\n      currentUser = { id: profile.id, username: profile.username };\n    }\n  }\n\n  const dbResearch = result.item;\n\n  const contentService = new ContentServiceServer(client);\n\n  if (dbResearch.id) {\n    await contentService.incrementViewCount('research', dbResearch.total_views, dbResearch.id);\n  }\n\n  const [usefulVotes, subscribers, tags] = await contentService.getMetaFields(\n    dbResearch.id,\n    'research',\n    dbResearch.tags,\n  );\n\n  const images = researchClient.getResearchPublicMedia(dbResearch);\n\n  const research = ResearchItem.fromDB(dbResearch, tags, images, result.collaborators, currentUser);\n  research.usefulCount = usefulVotes.count || 0;\n  research.subscriberCount = subscribers.count || 0;\n\n  if (dbResearch.author) {\n    const factory = new CommentFactory(new ImageServiceServer(client));\n    research.author = await factory.createAuthor(dbResearch.author);\n  }\n\n  for (const dbUpdate of dbResearch.updates) {\n    if (dbUpdate.update_author?.photo) {\n      const imageService = new ImageServiceServer(client);\n      const update = research.updates.find(({ id }) => dbUpdate.id === id);\n      if (update?.author) {\n        update.author.photo = imageService.getPublicUrl(dbUpdate.update_author.photo) || null;\n      }\n    }\n  }\n\n  return data({ research, tenantSettings }, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  const research = loaderData?.research as ResearchItem;\n\n  if (!research) {\n    return [];\n  }\n\n  const title = `${research.title} - Research - ${loaderData?.tenantSettings.siteName}`;\n\n  return generateTags(title, research.description, research.image?.publicUrl, { type: 'article' });\n});\n\nexport default function Index() {\n  const data = useLoaderData<typeof loader>();\n\n  if (!data.research) {\n    return <NotFoundPage />;\n  }\n\n  return <ResearchArticlePage research={data.research} />;\n}\n"
  },
  {
    "path": "src/routes/_.research.$slug.edit-update.$updateId.tsx",
    "content": "import { DBResearchUpdate, ResearchItem } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useLoaderData } from 'react-router';\nimport { ResearchUpdateForm } from 'src/pages/Research/Content/Common/ResearchUpdateForm';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { ResearchServiceServer } from 'src/services/researchService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn(\n      `/research/${params.slug}/edit-update/${params.updateId}`,\n      headers,\n    );\n  }\n\n  const researchService = new ResearchServiceServer(client);\n\n  const result = await researchService.getBySlug(params.slug as string);\n\n  if (result.error || !result.item) {\n    return redirect('/research', { headers });\n  }\n\n  const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n  const researchDb = result.item;\n  const currentUser = profile ? { id: profile.id, username: profile.username } : undefined;\n  const research = ResearchItem.fromDB(researchDb, [], [], result.collaborators, currentUser);\n  const update = research.updates.find((x) => x.id === Number(params.updateId));\n\n  if (!update) {\n    return redirect('/research', { headers });\n  }\n\n  if (!profile || !(await researchService.isAllowedToEditResearch(researchDb, profile))) {\n    return redirect('/forbidden?page=research-update-edit', { headers });\n  }\n\n  const updateDb = researchDb.updates.find((x) => x.id === Number(params.updateId));\n\n  const publicImages = updateDb?.images\n    ? new StorageServiceServer(client).getPublicUrls(updateDb?.images)\n    : [];\n\n  const formData = DBResearchUpdate.toFormData(updateDb!, publicImages);\n\n  return data({ id: updateDb!.id, formData, research }, { headers });\n}\n\nexport default function Index() {\n  const data = useLoaderData<typeof loader>();\n\n  return <ResearchUpdateForm id={data.id} formData={data.formData} research={data.research} />;\n}\n"
  },
  {
    "path": "src/routes/_.research.$slug.edit.tsx",
    "content": "import { DBResearchItem, ResearchItem } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useLoaderData } from 'react-router';\nimport ResearchForm from 'src/pages/Research/Content/Common/ResearchForm';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { ResearchServiceServer } from 'src/services/researchService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn(`/research/${params.slug}/edit`, headers);\n  }\n\n  const researchService = new ResearchServiceServer(client);\n  const result = await researchService.getBySlug(params.slug as string);\n\n  if (result.error || !result.item) {\n    return redirect('/research', { headers });\n  }\n\n  const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n  const researchDb = result.item as unknown as DBResearchItem;\n\n  const image = researchDb?.image\n    ? new StorageServiceServer(client).getPublicUrl(researchDb?.image)\n    : null;\n\n  const formData = DBResearchItem.toFormData(researchDb, image);\n  const currentUser = profile ? { id: profile.id, username: profile.username } : undefined;\n  const research = ResearchItem.fromDB(researchDb, [], [], result.collaborators, currentUser);\n\n  if (!profile || !(await researchService.isAllowedToEditResearch(researchDb, profile))) {\n    return redirect('/forbidden?page=research-edit', { headers });\n  }\n\n  return data({ formData, id: researchDb.id, research }, { headers });\n}\n\nexport default function Index() {\n  const { id, formData, research } = useLoaderData<typeof loader>();\n\n  return <ResearchForm id={id} formData={formData} research={research} />;\n}\n"
  },
  {
    "path": "src/routes/_.research.$slug.new-update.tsx",
    "content": "import { DBResearchItem, ResearchItem } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useLoaderData } from 'react-router';\nimport { ResearchUpdateForm } from 'src/pages/Research/Content/Common';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\nimport { ResearchServiceServer } from 'src/services/researchService.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn(`/research/${params.slug}/new-update`, headers);\n  }\n\n  const researchService = new ResearchServiceServer(client);\n\n  const result = await researchService.getBySlug(params.slug as string);\n\n  if (result.error || !result.item) {\n    return redirect('/research', { headers });\n  }\n\n  const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n  const researchDb = result.item as unknown as DBResearchItem;\n  const research = ResearchItem.fromDB(researchDb, [], [], result.collaborators);\n\n  if (!profile || !(await researchService.isAllowedToEditResearch(researchDb, profile))) {\n    return redirect('/forbidden?page=research-edit-create', { headers });\n  }\n\n  return data({ research }, { headers });\n}\n\nexport default function Index() {\n  const data = useLoaderData<typeof loader>();\n\n  return <ResearchUpdateForm id={null} formData={null} research={data.research} />;\n}\n"
  },
  {
    "path": "src/routes/_.research._index.tsx",
    "content": "import { data, LoaderFunctionArgs } from 'react-router';\nimport ResearchList from 'src/pages/Research/Content/ResearchList';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  return generateTags(`Research - ${loaderData?.siteName}`);\n});\n\nexport default function Index() {\n  return <ResearchList />;\n}\n"
  },
  {
    "path": "src/routes/_.research.create.tsx",
    "content": "import { UserRole } from 'oa-shared';\nimport { data, redirect } from 'react-router';\nimport ResearchForm from 'src/pages/Research/Content/Common/ResearchForm';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\n\nexport async function loader({ request }) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn('/research/create', headers);\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n  const roles = (profile?.roles || []) as string[];\n\n  // Check if user has required permissions\n  const isAdmin = roles?.includes(UserRole.ADMIN) ?? false;\n  const isResearchCreator = roles?.includes(UserRole.RESEARCH_CREATOR) ?? false;\n  const canCreate = isAdmin || isResearchCreator;\n\n  if (!canCreate) {\n    return redirect('/forbidden?page=research-create', { headers });\n  }\n\n  return data(null, { headers });\n}\n\nexport default function Index() {\n  return <ResearchForm formData={null} id={null} research={null} />;\n}\n"
  },
  {
    "path": "src/routes/_.research.tsx",
    "content": "import { Outlet } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\n\nexport async function loader() {\n  return null;\n}\n\n// This is a Layout file, it will render for all research routes\nexport default function Index() {\n  return (\n    <Main style={{ flex: 1 }}>\n      <Outlet />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.reset-password.tsx",
    "content": "import { Button, FieldInput, HeroBanner } from 'oa-components';\nimport { Field, Form } from 'react-final-form';\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { data, Link, redirect, useActionData, useNavigate } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { getReturnUrl } from 'src/utils/redirect.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\nimport { required } from 'src/utils/validators';\nimport { Card, Flex, Heading, Label, Text } from 'theme-ui';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n\n  if (claims.data?.claims) {\n    return redirect(getReturnUrl(request), { headers });\n  }\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n};\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  const formData = await request.formData();\n\n  const url = new URL(request.url);\n  const protocol = url.host.startsWith('localhost') ? 'http:' : 'https:';\n  const emailRedirectUrl = `${protocol}//${url.host}/update-password`;\n\n  await client.auth.resetPasswordForEmail(formData.get('email') as string, {\n    redirectTo: emailRedirectUrl,\n  });\n\n  // Always return success and display a generic message, even when the user doesn't exist, for security reasons.\n  return data({ success: true, email: formData.get('email') as string, error: null }, { headers });\n};\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  const title = `Reset Password - ${loaderData?.siteName}`;\n\n  return generateTags(title);\n});\n\nexport default function Index() {\n  const actionResponse = useActionData<typeof action>();\n  const navigate = useNavigate();\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <Form\n        onSubmit={() => {}}\n        render={({ submitting, invalid }) => {\n          return (\n            <form data-cy=\"reset-password-form\" method=\"post\">\n              <Flex\n                sx={{\n                  bg: 'inherit',\n                  px: 2,\n                  width: '100%',\n                  maxWidth: '620px',\n                  mx: 'auto',\n                  mt: [5, 10],\n                  mb: 3,\n                }}\n              >\n                <Flex sx={{ flexDirection: 'column', width: '100%' }}>\n                  <HeroBanner type=\"celebration\" />\n                  <Card sx={{ borderRadius: 3 }}>\n                    <Flex\n                      sx={{\n                        flexWrap: 'wrap',\n                        flexDirection: 'column',\n                        padding: 4,\n                        gap: 4,\n                        width: '100%',\n                      }}\n                    >\n                      <Flex sx={{ gap: 2, flexDirection: 'column' }}>\n                        <Heading>Reset Password</Heading>\n                        {actionResponse?.success ? (\n                          <Text sx={{ fontSize: 3 }} color=\"black\">\n                            We've sent a message to{' '}\n                            <Text\n                              sx={{\n                                background: 'linear-gradient(0deg, #FFE2E1 60%, #FFF 40%)',\n                                paddingX: 1,\n                              }}\n                            >\n                              {actionResponse?.email || 'your email address'}\n                            </Text>\n                            . If it's a registered account you will receive an email with\n                            instructions.\n                            <br />\n                            <br />\n                            Please check you inbox (and spam folder).\n                          </Text>\n                        ) : (\n                          <Text sx={{ fontSize: 1 }} color=\"grey\">\n                            <Link to=\"/sign-in\" data-cy=\"no-account\">\n                              Go back to Login\n                            </Link>\n                          </Text>\n                        )}\n                      </Flex>\n\n                      {actionResponse?.error && <Text color=\"red\">{actionResponse?.error}</Text>}\n\n                      {actionResponse?.success ? (\n                        <Button\n                          data-cy=\"go-back\"\n                          variant=\"secondary\"\n                          sx={{ width: 'fit-content' }}\n                          type=\"button\"\n                          onClick={() => navigate('.')}\n                          icon=\"arrow-back\"\n                        >\n                          Go back to reset form\n                        </Button>\n                      ) : (\n                        <>\n                          <Flex sx={{ flexDirection: 'column' }}>\n                            <Label htmlFor=\"title\">Email</Label>\n                            <Field\n                              name=\"email\"\n                              type=\"email\"\n                              data-cy=\"email\"\n                              component={FieldInput}\n                              validate={required}\n                            />\n                          </Flex>\n\n                          <Flex>\n                            <Button\n                              large\n                              data-cy=\"submit\"\n                              sx={{\n                                borderRadius: 3,\n                                width: '100%',\n                                justifyContent: 'center',\n                              }}\n                              variant=\"primary\"\n                              disabled={submitting || invalid}\n                              type=\"submit\"\n                            >\n                              Reset password\n                            </Button>\n                          </Flex>\n                        </>\n                      )}\n                    </Flex>\n                  </Card>\n                </Flex>\n              </Flex>\n            </form>\n          );\n        }}\n      />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.settings.$.tsx",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { redirect } from 'react-router';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport Main from 'src/pages/common/Layout/Main';\nimport { SettingsPage } from 'src/pages/UserSettings/SettingsPage.client';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { redirectServiceServer } from 'src/services/redirectService.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return redirectServiceServer.redirectSignIn('/settings/profile', headers);\n  }\n\n  const url = new URL(request.url);\n  if (url.pathname === '/settings' || url.pathname === '/settings/') {\n    return redirect('/settings/profile', { headers });\n  }\n\n  return new Response(null, { headers });\n}\n\nexport default function Index() {\n  return (\n    <Main style={{ flex: 1 }}>\n      <ClientOnly fallback={<></>}>{() => <SettingsPage />}</ClientOnly>\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.sign-in.tsx",
    "content": "import { Button, FieldInput, HeroBanner, TextNotification } from 'oa-components';\nimport { Field, Form } from 'react-final-form';\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { data, Link, redirect, useActionData } from 'react-router';\nimport { PasswordField } from 'src/common/Form/PasswordField';\nimport Main from 'src/pages/common/Layout/Main';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { getReturnUrl } from 'src/utils/redirect.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\nimport { required } from 'src/utils/validators';\nimport { Card, Flex, Heading, Label, Text } from 'theme-ui';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n\n  if (claims.data?.claims) {\n    return redirect(getReturnUrl(request), { headers });\n  }\n\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n};\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  const formData = await request.formData();\n\n  const email = formData.get('email') as string;\n  const password = formData.get('password') as string;\n\n  const signInResult = await client.auth.signInWithPassword({\n    email,\n    password,\n  });\n\n  if (signInResult.error) {\n    if (signInResult.error.code === 'email_not_confirmed') {\n      const url = new URL(request.url);\n      const protocol = url.host.startsWith('localhost') ? 'http:' : 'https:';\n      const emailRedirectUrl = `${protocol}//${url.host}/email-confirmation`;\n      await client.auth.resend({\n        type: 'signup',\n        email,\n        options: {\n          emailRedirectTo: emailRedirectUrl,\n        },\n      });\n\n      return data(\n        {\n          error: 'We need to confirm your email before logging in. Please check your inbox :)',\n        },\n        { headers },\n      );\n    }\n\n    console.error(signInResult.error);\n    return data(\n      {\n        error: 'Invalid email or password.',\n      },\n      { headers, status: 400 },\n    );\n  }\n\n  const profileService = new ProfileServiceServer(client);\n\n  try {\n    // This will fail if there is already a profile for the current auth_id, or the auth_id is invalid (can be invalid the the credentials are wrong)\n    await profileService.ensureProfile(signInResult.data.user);\n  } catch (error) {\n    console.error(error);\n  }\n\n  const profile = await profileService.getByAuthId(signInResult.data.user.id);\n\n  const fallbackPath = profile?.username ? `/u/${profile.username}` : '/';\n  const path = getReturnUrl(request, fallbackPath);\n\n  return redirect(path, { headers });\n};\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  const title = `Sign In - ${loaderData?.siteName}`;\n\n  return generateTags(title);\n});\n\nexport default function Index() {\n  const actionResponse = useActionData<typeof action>();\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <Form\n        onSubmit={() => {}}\n        render={({ submitting, invalid }) => {\n          return (\n            <form data-cy=\"login-form\" method=\"post\">\n              <Flex\n                sx={{\n                  bg: 'inherit',\n                  px: 2,\n                  width: '100%',\n                  maxWidth: '620px',\n                  mx: 'auto',\n                  mt: [5, 10],\n                  mb: 3,\n                }}\n              >\n                <Flex sx={{ flexDirection: 'column', width: '100%' }}>\n                  <HeroBanner type=\"celebration\" />\n                  <Card sx={{ borderRadius: 3 }}>\n                    <Flex\n                      sx={{\n                        flexWrap: 'wrap',\n                        flexDirection: 'column',\n                        padding: 4,\n                        gap: 4,\n                        width: '100%',\n                      }}\n                    >\n                      <Flex sx={{ gap: 2, flexDirection: 'column' }}>\n                        <Heading>Log in</Heading>\n                        <Text sx={{ fontSize: 1 }} color=\"grey\">\n                          <Link to=\"/sign-up\" data-cy=\"no-account\">\n                            Don't have an account? Sign-up here\n                          </Link>\n                        </Text>\n                      </Flex>\n\n                      {actionResponse?.error && (\n                        <TextNotification isVisible={true} variant={'failure'}>\n                          <Text>{actionResponse?.error}</Text>\n                        </TextNotification>\n                      )}\n\n                      <Flex sx={{ flexDirection: 'column' }}>\n                        <Label htmlFor=\"title\">Email</Label>\n                        <Field\n                          name=\"email\"\n                          type=\"email\"\n                          data-cy=\"email\"\n                          component={FieldInput}\n                          validate={required}\n                        />\n                      </Flex>\n                      <Flex sx={{ flexDirection: 'column' }}>\n                        <Label htmlFor=\"title\">Password</Label>\n                        <PasswordField\n                          name=\"password\"\n                          data-cy=\"password\"\n                          component={FieldInput}\n                          validate={required}\n                        />\n                      </Flex>\n                      <Flex sx={{ justifyContent: 'space-between' }}>\n                        <Text sx={{ fontSize: 1 }} color={'grey'}>\n                          <Link to=\"/reset-password\" data-cy=\"lost-password\">\n                            Forgotten password?\n                          </Link>\n                        </Text>\n                      </Flex>\n\n                      <Flex>\n                        <Button\n                          large\n                          data-cy=\"submit\"\n                          sx={{\n                            borderRadius: 3,\n                            width: '100%',\n                            justifyContent: 'center',\n                          }}\n                          variant=\"primary\"\n                          disabled={submitting || invalid}\n                          type=\"submit\"\n                        >\n                          Log in\n                        </Button>\n                      </Flex>\n                    </Flex>\n                  </Card>\n                </Flex>\n              </Flex>\n            </form>\n          );\n        }}\n      />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.sign-up-message.tsx",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { useLoaderData } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\nimport SignUpMessagePage from 'src/pages/SignUp/SignUpMessage';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const url = new URL(request.url);\n\n  // Get the raw search string and manually parse the email parameter, otherwise characters like '+' are ignored.\n  const emailMatch = url.search.match(/[?&]email=([^&]*)/);\n  const email = emailMatch ? decodeURIComponent(emailMatch[1]) : null;\n\n  return email;\n}\n\nexport default function Index() {\n  const email = useLoaderData<typeof loader>();\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <SignUpMessagePage email={email} />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.sign-up.tsx",
    "content": "import { Button, ExternalLink, FieldInput, HeroBanner, TextNotification } from 'oa-components';\nimport { FRIENDLY_MESSAGES } from 'oa-shared';\nimport { Field, Form } from 'react-final-form';\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { data, Link, redirect, useActionData } from 'react-router';\nimport { PasswordField } from 'src/common/Form/PasswordField';\nimport Main from 'src/pages/common/Layout/Main';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { AuthServiceServer } from 'src/services/authService.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\nimport { required } from 'src/utils/validators';\nimport { Card, Flex, Heading, Label, Text } from 'theme-ui';\nimport { bool, object, ref, string } from 'yup';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n\n  if (claims.data?.claims) {\n    return redirect('/', { headers });\n  }\n  const tenantSettings = await new TenantSettingsService(client).get();\n\n  return data(tenantSettings, { headers });\n};\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  const title = `Sign Up - ${loaderData?.siteName}`;\n\n  return generateTags(title);\n});\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  const formData = await request.formData();\n  const url = new URL(request.url);\n  const protocol = url.host.startsWith('localhost') ? 'http:' : 'https:';\n  const emailRedirectTo = `${protocol}//${url.host}/email-confirmation`;\n\n  const authServiceServer = new AuthServiceServer(client);\n\n  const email = formData.get('email') as string;\n  const password = formData.get('password') as string;\n\n  const signupResult = await client.auth.signUp({\n    email,\n    password,\n    options: {\n      emailRedirectTo,\n    },\n  });\n\n  if (signupResult.error) {\n    if (signupResult.error.code === 'weak_password') {\n      return data({ error: FRIENDLY_MESSAGES['password-weak'] }, { headers });\n    }\n\n    return data({ error: FRIENDLY_MESSAGES['generic-error'] }, { headers });\n  }\n\n  if (signupResult.data.user) {\n    const response = await authServiceServer.createUserProfile({ user: signupResult.data.user });\n\n    // This will error if there is already a profile with this auth_id + tenant_id\n    if (response.error) {\n      return data({ error: FRIENDLY_MESSAGES['generic-error'] }, { headers });\n    }\n  }\n\n  return redirect(`/sign-up-message?email=${email}`, { headers });\n};\n\nconst rowWidth = ['100%', '100%', `100%`];\n\nexport default function Index() {\n  const actionResponse = useActionData<typeof action>();\n\n  const validationSchema = object({\n    email: string().email(FRIENDLY_MESSAGES['auth/invalid-email']).required('Required'),\n    password: string()\n      .min(6, FRIENDLY_MESSAGES['sign-up/password-short'])\n      .required(FRIENDLY_MESSAGES['sign-up/password-required']),\n    'confirm-password': string()\n      .oneOf([ref('password'), ''], FRIENDLY_MESSAGES['sign-up/password-mismatch'])\n      .required(FRIENDLY_MESSAGES['sign-up/email-required']),\n    consent: bool().oneOf([true], FRIENDLY_MESSAGES['sign-up/terms']),\n  });\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <Form\n        onSubmit={() => {}}\n        validate={async (values: any) => {\n          try {\n            await validationSchema.validate(values, { abortEarly: false });\n          } catch (err) {\n            return err.inner.reduce(\n              (acc: any, error) => ({\n                ...acc,\n                [error.path]: error.message,\n              }),\n              {},\n            );\n          }\n        }}\n        render={({ submitting, invalid, pristine }) => {\n          const disabled = invalid || submitting;\n          return (\n            <form method=\"post\">\n              <Flex\n                bg=\"inherit\"\n                px={2}\n                sx={{ width: '100%' }}\n                css={{ maxWidth: '620px' }}\n                mx={'auto'}\n                mt={[5, 10]}\n                mb={3}\n              >\n                <Flex sx={{ flexDirection: 'column', width: '100%' }}>\n                  <HeroBanner type=\"celebration\" />\n                  <Card sx={{ borderRadius: 3 }}>\n                    <Flex\n                      sx={{\n                        flexWrap: 'wrap',\n                        flexDirection: 'column',\n                        padding: 4,\n                        gap: 4,\n                        width: '100%',\n                      }}\n                    >\n                      <Flex sx={{ flexDirection: 'column', gap: 2 }}>\n                        <Heading>Create an account</Heading>\n                        <Text color={'grey'} sx={{ fontSize: 1 }}>\n                          <Link\n                            to=\"/sign-in\"\n                            style={{\n                              textDecoration: 'underline',\n                            }}\n                          >\n                            Already have an account? Sign-in here\n                          </Link>\n                        </Text>\n                      </Flex>\n\n                      {actionResponse?.error && pristine && (\n                        <TextNotification variant=\"failure\" isVisible>\n                          {actionResponse?.error}\n                        </TextNotification>\n                      )}\n\n                      <Flex\n                        sx={{\n                          flexDirection: 'column',\n                          width: rowWidth,\n                        }}\n                      >\n                        <Label htmlFor=\"email\">Email</Label>\n                        <Text color={'grey'} sx={{ fontSize: 1 }}>\n                          It can be personal or work email.\n                        </Text>\n                        <Field\n                          data-cy=\"email\"\n                          name=\"email\"\n                          type=\"email\"\n                          component={FieldInput}\n                          placeholder=\"yourname@domain.com\"\n                          validate={required}\n                        />\n                      </Flex>\n                      <Flex\n                        sx={{\n                          flexDirection: 'column',\n                          width: rowWidth,\n                        }}\n                      >\n                        <Label htmlFor=\"password\">Password</Label>\n                        <PasswordField\n                          data-cy=\"password\"\n                          name=\"password\"\n                          placeholder=\"Password\"\n                          component={FieldInput}\n                          validate={required}\n                        />\n                      </Flex>\n                      <Flex\n                        sx={{\n                          flexDirection: 'column',\n                          width: rowWidth,\n                        }}\n                      >\n                        <Label htmlFor=\"confirm-password\">Confirm Password</Label>\n                        <PasswordField\n                          data-cy=\"confirm-password\"\n                          name=\"confirm-password\"\n                          placeholder=\"Confirm your Password\"\n                          component={FieldInput}\n                          validate={required}\n                        />\n                      </Flex>\n                      <Flex>\n                        <Label\n                          sx={{\n                            display: 'flex',\n                            alignItems: 'flex-start',\n                          }}\n                        >\n                          <Field\n                            data-cy=\"consent\"\n                            name=\"consent\"\n                            type=\"checkbox\"\n                            component=\"input\"\n                            validate={required}\n                          />\n                          <Text\n                            sx={{\n                              fontSize: 2,\n                            }}\n                          >\n                            I agree to the{' '}\n                            <ExternalLink href=\"/terms\">Terms of Service</ExternalLink>\n                            <span> and </span>\n                            <ExternalLink href=\"/privacy\">Privacy Policy</ExternalLink>\n                          </Text>\n                        </Label>\n                      </Flex>\n\n                      <Flex>\n                        <Button\n                          large\n                          sx={{\n                            borderRadius: 3,\n                            width: '100%',\n                            justifyContent: 'center',\n                          }}\n                          data-cy=\"submit\"\n                          variant=\"primary\"\n                          disabled={disabled}\n                          type=\"submit\"\n                        >\n                          Create account\n                        </Button>\n                      </Flex>\n                    </Flex>\n                  </Card>\n                </Flex>\n              </Flex>\n            </form>\n          );\n        }}\n      />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.terms.tsx",
    "content": "import Main from 'src/pages/common/Layout/Main';\nimport TermsPolicy from 'src/pages/policy/TermsPolicy';\nimport { generateTags } from 'src/utils/seo.utils';\n\nexport const meta = generateTags('Terms of Use');\n\nexport default function Index() {\n  return (\n    <Main style={{ flex: 1 }}>\n      <TermsPolicy />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.tsx",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { data, Outlet, useLoaderData } from 'react-router';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { Toaster } from 'sonner';\nimport { Alerts } from 'src/common/Alerts/Alerts';\nimport { Analytics } from 'src/common/Analytics';\nimport GlobalSiteFooter from 'src/pages/common/GlobalSiteFooter/GlobalSiteFooter';\nimport Header from 'src/pages/common/Header/Header';\nimport { SessionContext } from 'src/pages/common/SessionContext';\nimport { StickyButton } from 'src/pages/common/StickyButton';\nimport {\n  getEnvVariables,\n  TenantContext,\n  TenantSettingsContext,\n} from 'src/pages/common/TenantContext';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { ProfileStoreProvider } from 'src/stores/Profile/profile.store';\nimport { SubscriptionStoreProvider } from 'src/stores/Subscription/subscription.store';\nimport { UsefulVoteStoreProvider } from 'src/stores/UsefulVote/usefulVote.store';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\nimport { Flex } from 'theme-ui';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n  const environment = getEnvVariables();\n  const settings = await new TenantSettingsService(client).get();\n\n  const tenantSettings: TenantSettingsContext = {\n    ...settings,\n    environment,\n  };\n\n  const claims = await client.auth.getClaims();\n\n  return data({ tenantSettings, claims: claims.data?.claims || null }, { headers });\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  return generateTags(\n    loaderData?.tenantSettings.siteName || 'Community Platform',\n    loaderData?.tenantSettings.siteDescription,\n  );\n});\n\n// This is a Layout file, it will render for all routes that have _. prefix.\nexport default function Index() {\n  const { tenantSettings, claims } = useLoaderData<typeof loader>();\n\n  return (\n    <TenantContext.Provider value={tenantSettings}>\n      <SessionContext.Provider value={claims}>\n        <ProfileStoreProvider key={claims?.sub ?? 'anonymous'}>\n          <SubscriptionStoreProvider>\n            <UsefulVoteStoreProvider>\n              <Flex sx={{ height: '100vh', flexDirection: 'column' }} data-cy=\"page-container\">\n                <Analytics />\n                <Header />\n                <Alerts />\n                <ClientOnly fallback={<></>}>\n                  {() => (\n                    <>\n                      <style>\n                        {`\n                          [data-sonner-toast] [data-icon] {\n                            display: none !important;\n                          }\n                        `}\n                      </style>\n                      <Toaster\n                        position=\"bottom-center\"\n                        expand={true}\n                        richColors={false}\n                        closeButton={false}\n                        toastOptions={{\n                          unstyled: true,\n                          duration: 4000,\n                        }}\n                      />\n                    </>\n                  )}\n                </ClientOnly>\n\n                <Outlet />\n\n                <GlobalSiteFooter />\n                <ClientOnly fallback={<></>}>{() => <StickyButton />}</ClientOnly>\n              </Flex>\n            </UsefulVoteStoreProvider>\n          </SubscriptionStoreProvider>\n        </ProfileStoreProvider>\n      </SessionContext.Provider>\n    </TenantContext.Provider>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.u.$id._index.tsx",
    "content": "import type { Profile, UserCreatedDocs } from 'oa-shared';\nimport { AuthorVotes } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data, redirect, useLoaderData } from 'react-router';\nimport { ProfileFactory } from 'src/factories/profileFactory.server';\nimport { ProfilePage } from 'src/pages/User/content/ProfilePage';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { LibraryServiceServer } from 'src/services/libraryService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { QuestionServiceServer } from 'src/services/questionService.server';\nimport { ResearchServiceServer } from 'src/services/researchService.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport { generateTags, mergeMeta } from 'src/utils/seo.utils';\nimport { Text } from 'theme-ui';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n  try {\n    const tenantSettings = await new TenantSettingsService(client).get();\n    const profileService = new ProfileServiceServer(client);\n\n    const username = params.id as string;\n\n    const [profileDb, projects, research, questions] = await Promise.all([\n      profileService.getByUsername(username),\n      new LibraryServiceServer(client).getUserProjects(username),\n      new ResearchServiceServer(client).getUserResearch(username),\n      new QuestionServiceServer(client).getQuestionsByUser(username),\n    ]);\n\n    const userCreatedDocs = {\n      projects,\n      research,\n      questions,\n    } as UserCreatedDocs;\n\n    if (!profileDb) {\n      return data({ profile: null, tenantSettings }, { headers });\n    }\n\n    const authorVotesDb = await profileService.getAuthorUsefulVotes(profileDb.id);\n    const authorVotes = authorVotesDb ? authorVotesDb.map((x) => AuthorVotes.fromDB(x)) : undefined;\n\n    if (profileDb?.id) {\n      // not awaited to not block the render\n      profileService.incrementViewCount(profileDb.id, profileDb.total_views);\n    }\n\n    const profileFactory = new ProfileFactory(client);\n    const profile = profileFactory.fromDB(profileDb, authorVotes);\n\n    return data(\n      {\n        profile,\n        userCreatedDocs,\n        tenantSettings,\n      },\n      { headers },\n    );\n  } catch (error) {\n    console.error(error);\n    return redirect('/', { headers });\n  }\n}\n\nexport const meta = mergeMeta<typeof loader>(({ loaderData }) => {\n  const profile = (loaderData as any)?.profile as Profile;\n\n  if (!profile) {\n    return [];\n  }\n\n  const title = `${profile.displayName} - Profile - ${loaderData?.tenantSettings.siteName}`;\n  const imageUrl = profile.photo?.publicUrl;\n\n  return generateTags(title, profile.about || undefined, imageUrl);\n});\n\nexport default function Index() {\n  const data = useLoaderData<typeof loader>();\n\n  if (!data.profile) {\n    return (\n      <Text\n        sx={{\n          width: '100%',\n          textAlign: 'center',\n          display: 'block',\n          marginTop: 10,\n        }}\n      >\n        User not found\n      </Text>\n    );\n  }\n\n  return <ProfilePage profile={data.profile} userCreatedDocs={data.userCreatedDocs} />;\n}\n"
  },
  {
    "path": "src/routes/_.u.tsx",
    "content": "import { Outlet } from 'react-router';\nimport Main from 'src/pages/common/Layout/Main';\n\nexport default function Index() {\n  return (\n    <Main style={{ flex: 1 }}>\n      <Outlet />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_.update-password.tsx",
    "content": "import { Button, FieldInput, HeroBanner } from 'oa-components';\nimport { useEffect } from 'react';\nimport { Form } from 'react-final-form';\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { data, Link, redirect, useActionData, useLoaderData, useNavigate } from 'react-router';\nimport { PasswordField } from 'src/common/Form/PasswordField';\nimport Main from 'src/pages/common/Layout/Main';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { required } from 'src/utils/validators';\nimport { Card, Flex, Heading, Label, Text } from 'theme-ui';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const url = new URL(request.url);\n  const error = url.searchParams.get('error_description');\n  const token = url.searchParams.get('token');\n\n  const { client, headers } = createSupabaseServerClient(request);\n\n  if (error || token) {\n    return data({ token, error }, { headers });\n  }\n\n  const claims = await client.auth.getClaims();\n\n  if (claims.data?.claims) {\n    return data({ error: null }, { headers });\n  }\n\n  return redirect('/', { headers });\n};\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  const url = new URL(request.url);\n\n  const formData = await request.formData();\n  const password = formData.get('password') as string;\n  const passwordRepeat = formData.get('passwordRepeat') as string;\n\n  if (password !== passwordRepeat) {\n    return data({ success: false, error: 'Passwords do not match' }, { status: 400, headers });\n  }\n\n  // Get the token from URL params (it should still be there)\n  const token = url.searchParams.get('token');\n\n  if (!token) {\n    return data({ success: false, error: 'Your reset link is invalid' }, { status: 400, headers });\n  }\n\n  const tokenVerification = await client.auth.verifyOtp({\n    token_hash: token,\n    type: 'recovery',\n  });\n\n  if (!tokenVerification.data.user) {\n    return data(\n      { success: false, error: 'Your reset link has expired or is invalid' },\n      { status: 400, headers },\n    );\n  }\n\n  // Now update the password\n  const result = await client.auth.updateUser({ password });\n\n  if (result.error) {\n    return data({ success: false, error: result.error.message }, { status: 500, headers });\n  }\n\n  // Redirect with the session headers to automatically log in the user\n  return redirect('/', { headers });\n};\n\nexport default function Index() {\n  const navigate = useNavigate();\n  const data = useLoaderData<typeof loader>();\n  const actionData = useActionData<typeof action>();\n\n  useEffect(() => {\n    if (actionData?.success) {\n      navigate('/');\n    }\n  }, [actionData?.success]);\n\n  return (\n    <Main style={{ flex: 1 }}>\n      <Form\n        onSubmit={() => {}}\n        render={({ submitting, invalid }) => {\n          return (\n            <form data-cy=\"reset-password-form\" method=\"post\">\n              <Flex\n                sx={{\n                  bg: 'inherit',\n                  px: 2,\n                  width: '100%',\n                  maxWidth: '620px',\n                  mx: 'auto',\n                  mt: [5, 10],\n                  mb: 3,\n                }}\n              >\n                <Flex sx={{ flexDirection: 'column', width: '100%' }}>\n                  <HeroBanner type=\"celebration\" />\n                  <Card sx={{ borderRadius: 3 }}>\n                    <Flex\n                      sx={{\n                        flexWrap: 'wrap',\n                        flexDirection: 'column',\n                        padding: 4,\n                        gap: 4,\n                        width: '100%',\n                      }}\n                    >\n                      <Flex sx={{ gap: 2, flexDirection: 'column' }}>\n                        <Heading>Update Password</Heading>\n                        <Text sx={{ fontSize: 1 }} color=\"grey\">\n                          <Link to=\"/sign-in\" data-cy=\"no-account\">\n                            Go back to Login\n                          </Link>\n                        </Text>\n                      </Flex>\n\n                      {data.error ? (\n                        <Text color=\"red\">{data?.error}</Text>\n                      ) : (\n                        <>\n                          {actionData?.error && <Text color=\"red\">{actionData?.error}</Text>}\n\n                          <Flex sx={{ flexDirection: 'column' }}>\n                            <Label htmlFor=\"password\">Your new password</Label>\n                            <PasswordField\n                              name=\"password\"\n                              type=\"password\"\n                              data-cy=\"password\"\n                              component={FieldInput}\n                              validate={required}\n                            />\n                          </Flex>\n\n                          <Flex sx={{ flexDirection: 'column' }}>\n                            <Label htmlFor=\"passwordRepeat\">Repeat your new password</Label>\n                            <PasswordField\n                              name=\"passwordRepeat\"\n                              type=\"password\"\n                              data-cy=\"passwordRepeat\"\n                              component={FieldInput}\n                              validate={required}\n                            />\n                          </Flex>\n\n                          <Flex>\n                            <Button\n                              large\n                              data-cy=\"submit\"\n                              sx={{\n                                borderRadius: 3,\n                                width: '100%',\n                                justifyContent: 'center',\n                              }}\n                              variant=\"primary\"\n                              disabled={submitting || invalid}\n                              type=\"submit\"\n                            >\n                              Update password\n                            </Button>\n                          </Flex>\n                        </>\n                      )}\n                    </Flex>\n                  </Card>\n                </Flex>\n              </Flex>\n            </form>\n          );\n        }}\n      />\n    </Main>\n  );\n}\n"
  },
  {
    "path": "src/routes/_index.tsx",
    "content": "import { redirect } from 'react-router';\n\nexport async function loader() {\n  return redirect('/academy');\n}\n\nexport default function Index() {\n  return null;\n}\n"
  },
  {
    "path": "src/routes/api.account.change-email.tsx",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport { FRIENDLY_MESSAGES } from 'oa-shared';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { unauthorizedError, validationError } from 'src/utils/httpException';\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n\n    const newEmail = formData.get('email') as string;\n    const password = formData.get('password') as string;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      throw unauthorizedError();\n    }\n\n    const signInResult = await client.auth.signInWithPassword({\n      email: claims.data?.claims?.email as string,\n      password,\n    });\n\n    if (signInResult.error) {\n      throw validationError('Invalid password');\n    }\n\n    const url = new URL(request.url);\n    const protocol = url.host.startsWith('localhost') ? 'http:' : 'https:';\n    const emailRedirectUrl = `${protocol}//${url.host}/settings/account`;\n\n    const result = await client.auth.updateUser(\n      { email: newEmail },\n      {\n        emailRedirectTo: emailRedirectUrl,\n      },\n    );\n\n    if (result.error) {\n      const errorMessage =\n        result.error.code === 'email_exists'\n          ? FRIENDLY_MESSAGES['auth/email-already-in-use']\n          : FRIENDLY_MESSAGES['generic'];\n      throw validationError(errorMessage);\n    }\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({ error: 'Error changing email', status: 500 }, { status: 500 });\n  }\n\n  return new Response(null, { headers, status: 204 });\n};\n"
  },
  {
    "path": "src/routes/api.account.change-password.tsx",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { unauthorizedError, validationError } from 'src/utils/httpException';\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n\n    const oldPassword = formData.get('oldPassword') as string;\n    const newPassword = formData.get('newPassword') as string;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      throw unauthorizedError();\n    }\n\n    const signInResult = await client.auth.signInWithPassword({\n      email: claims.data?.claims?.email as string,\n      password: oldPassword,\n    });\n\n    if (signInResult.error) {\n      throw validationError('Invalid password');\n    }\n\n    const result = await client.auth.updateUser({ password: newPassword });\n\n    if (result.error) {\n      throw validationError(result.error.message);\n    }\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({ error: 'Error changing password', status: 500 }, { status: 500 });\n  }\n\n  return new Response(null, { headers, status: 204 });\n};\n"
  },
  {
    "path": "src/routes/api.banner.ts",
    "content": "import Keyv from 'keyv';\nimport type { DBBanner } from 'oa-shared';\nimport { Banner } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { isProductionEnvironment } from 'src/config/config';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nconst cache = new Keyv<Banner[]>({ ttl: 600000 }); // ttl: 10 minutes\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const cachedBanner = await cache.get('banner');\n\n  if (cachedBanner && isProductionEnvironment()) {\n    return Response.json(cachedBanner, { headers, status: 200 });\n  }\n\n  const { data } = await client.from('banners').select('id,text,url').limit(1);\n\n  let banner: Banner = new Banner({ text: null, url: null });\n  if (data && Array.isArray(data) && data.length > 0) {\n    banner = Banner.fromDB(data[0] as DBBanner);\n  }\n\n  cache.set('banner', banner);\n\n  return Response.json(banner, { headers, status: 200 });\n}\n"
  },
  {
    "path": "src/routes/api.categories.$type.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport Keyv from 'keyv';\nimport type { ContentType, DBCategory } from 'oa-shared';\nimport { Category } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { data } from 'react-router';\nimport { isProductionEnvironment } from 'src/config/config';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { validationError } from 'src/utils/httpException';\n\nconst cache = new Keyv<Category[]>({ ttl: 3600000 }); // ttl: 60 minutes\n\nconst filterByType = (categories: Category[], type: ContentType) => {\n  return categories.filter((category) => category.type === type);\n};\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const type = params.type as ContentType;\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    if (!type) {\n      throw validationError('type is required');\n    }\n\n    const cachedCategories = await cache.get('categories');\n\n    if (\n      cachedCategories &&\n      Array.isArray(cachedCategories) &&\n      cachedCategories.length &&\n      isProductionEnvironment()\n    ) {\n      const categoriesForType = filterByType(cachedCategories, type);\n      return data(categoriesForType, { headers, status: 200 });\n    }\n\n    const categoriesResult = await client.from('categories').select('id,name,created_at,type');\n\n    const categories = categoriesResult.data?.map((category) =>\n      Category.fromDB(category as DBCategory),\n    );\n\n    if (categories && categories.length > 0) {\n      cache.set('categories', categories, 3600000);\n    }\n\n    const categoriesForType = categories ? filterByType(categories, type) : [];\n\n    return Response.json(categoriesForType, { headers, status: 200 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error('Error loading categories:', error);\n    return Response.json({ error: 'Error creating research', status: 500 }, { status: 500 });\n  }\n}\n"
  },
  {
    "path": "src/routes/api.comments.$id.source.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const id = Number(params.id);\n\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const result = await client.from('comments').select('source_id').eq('id', id).single();\n\n  return Response.json({ sourceId: result.data?.source_id }, { headers });\n}\n"
  },
  {
    "path": "src/routes/api.discussions.$sourceId.comments.$id.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBComment, DBProfile } from 'oa-shared';\nimport { UserRole } from 'oa-shared';\nimport type { LoaderFunctionArgs, Params } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\ntype Supabase = {\n  headers: Headers;\n  client: SupabaseClient<any, 'public', any>;\n};\n\nexport async function action({ params, request }: LoaderFunctionArgs) {\n  const supabase = createSupabaseServerClient(request);\n  const headers = supabase.headers;\n  const claims = await supabase.client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const { valid, status, statusText } = await validateRequest(params, request);\n\n  if (!valid) {\n    return Response.json({}, { headers, status, statusText });\n  }\n\n  const profile = await getProfileByAuthId(request, claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({}, { headers, status: 400, statusText: 'user not found' });\n  }\n\n  if (!profile.username) {\n    return Response.json(\n      { error: 'You must set a username before modifying comments' },\n      { headers, status: 403 },\n    );\n  }\n\n  const commentId: string = params.id!;\n\n  try {\n    if (request.method === 'DELETE') {\n      return deleteComment(supabase, commentId, profile);\n    }\n\n    return updateComment(supabase, request, commentId, profile);\n  } catch (error) {\n    console.error(error);\n    return Response.json(error, { headers });\n  }\n}\n\nasync function updateComment(\n  { client, headers }: Supabase,\n  request: Request,\n  id: string,\n  user: DBProfile,\n) {\n  const json = await request.json();\n\n  if (!json.comment) {\n    return Response.json({}, { headers, status: 400, statusText: 'comment is required' });\n  }\n\n  const { data, error } = await client.from('comments').select().eq('id', id).single();\n\n  if (error || !data) {\n    return Response.json({}, { headers, status: 404, statusText: 'comment not found' });\n  }\n\n  const comment = data as DBComment;\n\n  if (comment.created_by !== user.id && !isUserAdmin(user)) {\n    return Response.json({}, { headers, status: 403, statusText: 'forbidden' });\n  }\n\n  const result = await client.from('comments').update({ comment: json.comment }).eq('id', id);\n\n  if (result.error) {\n    console.error(result.error);\n    return Response.json({}, { headers, status: 500, statusText: 'Error updating comment' });\n  }\n\n  new ProfileServiceServer(client).updateUserActivity(user.auth_id);\n\n  return new Response(null, { headers, status: 204 });\n}\n\nasync function deleteComment({ client, headers }: Supabase, id: string, user: DBProfile) {\n  const { data, error } = await client.from('comments').select().eq('id', id).single();\n\n  if (error || !data) {\n    return Response.json({}, { headers, status: 404, statusText: 'comment not found' });\n  }\n\n  const comment = data as DBComment;\n\n  if (comment.created_by !== user.id && !isUserAdmin(user)) {\n    return Response.json({}, { headers, status: 403, statusText: 'forbidden' });\n  }\n\n  const result = await client.from('comments').update({ deleted: true }).eq('id', id);\n\n  if (result.error) {\n    console.error(result.error);\n    return Response.json({}, { headers, status: 500, statusText: 'Error deleting comment' });\n  }\n\n  new ProfileServiceServer(client).updateUserActivity(user.auth_id);\n\n  return new Response(null, { headers, status: 204 });\n}\n\nasync function getProfileByAuthId(request: Request, authId: string) {\n  const { client } = createSupabaseServerClient(request);\n\n  const { data, error } = await client.from('profiles').select().eq('auth_id', authId).limit(1);\n\n  if (error || !data?.at(0)) {\n    return null;\n  }\n\n  return data[0] as DBProfile;\n}\n\nfunction isUserAdmin(user: DBProfile) {\n  return user.roles && user.roles.includes(UserRole.ADMIN);\n}\n\nasync function validateRequest(params: Params<string>, request: Request) {\n  if (!params.sourceId) {\n    return { status: 400, statusText: 'sourceId is required' };\n  }\n\n  if (!params.id) {\n    return { status: 400, statusText: 'id is required' };\n  }\n\n  if (request.method !== 'PUT' && request.method !== 'DELETE') {\n    return { status: 405, statusText: 'method not allowed' };\n  }\n\n  return { valid: true };\n}\n"
  },
  {
    "path": "src/routes/api.discussions.$sourceType.$sourceId.comments.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBAuthor, DBProfile, DiscussionContentType } from 'oa-shared';\nimport { DBComment, DiscussionContentTypes } from 'oa-shared';\nimport type { LoaderFunctionArgs, Params } from 'react-router';\nimport { CommentFactory } from 'src/factories/commentFactory.server';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ImageServiceServer } from 'src/services/imageService.server';\nimport { NotificationsSupabaseServiceServer } from 'src/services/notificationsSupabaseService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { SubscribersServiceServer } from 'src/services/subscribersService.server';\n\nexport async function loader({ params, request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  if (!params.sourceId) {\n    return Response.json({}, { headers, status: 400, statusText: 'sourceId is required' });\n  }\n  try {\n    const claims = await client.auth.getClaims();\n\n    let currentUserId: number | null = null;\n\n    if (claims.data?.claims?.sub) {\n      const profileResult = await client\n        .from('profiles')\n        .select('id')\n        .eq('auth_id', claims.data.claims.sub)\n        .single();\n\n      if (!profileResult.error) {\n        currentUserId = profileResult.data.id;\n      }\n    }\n\n    const result = await client.rpc('get_comments_with_votes', {\n      p_source_type: params.sourceType,\n      p_source_id: params.sourceId,\n      p_current_user_id: currentUserId || null,\n    });\n\n    const dbComments = result.data as DBComment[];\n\n    const commentFactory = new CommentFactory(new ImageServiceServer(client));\n    const comments = await commentFactory.fromDBCommentsToThreads(dbComments);\n\n    return Response.json({ comments }, { headers });\n  } catch (error) {\n    console.error(error);\n    return Response.json({}, { status: 500, headers });\n  }\n}\n\nexport async function action({ params, request }: LoaderFunctionArgs) {\n  const data = await request.json();\n\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const { valid, status, statusText } = await validateRequest(\n    params,\n    request,\n    data,\n    params.sourceType!,\n  );\n\n  if (!valid) {\n    return Response.json({}, { headers, status, statusText });\n  }\n\n  const currentUser = await client\n    .from('profiles')\n    .select()\n    .eq('auth_id', claims.data.claims.sub)\n    .limit(1);\n\n  if (currentUser.error || !currentUser.data?.at(0)) {\n    return Response.json(\n      {},\n      {\n        headers,\n        status: 400,\n        statusText: 'profile not found ' + claims.data.claims.sub,\n      },\n    );\n  }\n\n  if (!currentUser.data.at(0)!.username) {\n    return Response.json(\n      { error: 'You must set a username before commenting' },\n      { headers, status: 403 },\n    );\n  }\n\n  const newComment = {\n    comment: data.comment,\n    source_id_legacy: isNaN(+params.sourceId!) ? params.sourceId : null,\n    source_id: isNaN(+params.sourceId!) ? null : +params.sourceId!,\n    source_type: params.sourceType,\n    created_by: currentUser.data[0].id,\n    parent_id: data.parentId ?? null,\n    tenant_id: process.env.TENANT_ID,\n  } as Partial<DBComment>;\n\n  const commentResult = await client\n    .from('comments')\n    .insert(newComment)\n    .select(\n      `\n      id, \n      comment, \n      created_at, \n      modified_at, \n      deleted, \n      source_id, \n      source_id_legacy, \n      parent_id,\n      source_type,\n      created_by,\n      profiles(id, display_name, username, photo, country, badges:profile_badges_relations(\n        profile_badges(\n          id,\n          name,\n          display_name,\n          image_url,\n          action_url\n        )\n      ))\n    `,\n    )\n    .single();\n\n  if (!commentResult.error) {\n    const comment = commentResult.data as DBComment;\n    const profile = currentUser.data[0] as DBProfile;\n\n    addSubscriptions(comment, profile, client);\n\n    new NotificationsSupabaseServiceServer(client).createNotificationsNewComment(comment);\n  }\n\n  const commentDb = new DBComment({\n    ...(commentResult.data as DBComment),\n    profile: (commentResult.data as any).profiles as DBAuthor,\n  });\n\n  const commentFactory = new CommentFactory(new ImageServiceServer(client));\n  const comment = await commentFactory.fromDBWithAuthor(commentDb);\n\n  new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n  return Response.json(comment, {\n    headers,\n    status: commentResult.error ? 500 : 201,\n  });\n}\n\nfunction addSubscriptions(comment: DBComment, profile: DBProfile, client: SupabaseClient) {\n  const subscribersServiceServer = new SubscribersServiceServer(client);\n  if (comment.source_id && !comment.parent_id) {\n    // Subscribe to peer comments...\n    subscribersServiceServer.add(comment.source_type, comment.source_id, profile.id);\n    // ...add replies to this comment\n    subscribersServiceServer.add('comments', comment.id, profile.id);\n  }\n\n  if (comment.source_id && comment.parent_id) {\n    // Subscribe to the parent of this reply\n    subscribersServiceServer.add('comments', comment.parent_id, profile.id);\n  }\n}\n\nasync function validateRequest(\n  params: Params<string>,\n  request: Request,\n  data: any, // TODO create a CommentDTO\n  sourceType: string,\n) {\n  if (!params.sourceId) {\n    return { status: 400, statusText: 'sourceId is required' };\n  }\n\n  if (!params.sourceType) {\n    return { status: 400, statusText: 'sourceType is required' };\n  }\n\n  if (request.method !== 'POST') {\n    return { status: 405, statusText: 'method not allowed' };\n  }\n\n  if (!data.comment) {\n    return { status: 400, statusText: 'comment is required' };\n  }\n\n  if (!sourceType || !DiscussionContentTypes.includes(sourceType as DiscussionContentType)) {\n    return { status: 400, statusText: 'invalid sourceType' };\n  }\n\n  return { valid: true };\n}\n"
  },
  {
    "path": "src/routes/api.documents.$type.$contentId.$docId.tsx",
    "content": "import type { FileObject } from '@supabase/storage-js';\nimport type { SupabaseClient } from '@supabase/supabase-js';\nimport type { IDBDownloadable } from 'oa-shared';\nimport type { LoaderFunctionArgs, Params } from 'react-router';\nimport { redirect } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { resolveType } from 'src/utils/contentType.utils';\n\nexport async function loader({ params, request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { status: 401, headers });\n  }\n\n  const result = await resolveUrl(params, client, headers);\n\n  if (result.status === 302) {\n    // 302 status means the file is returned\n    await incrementDownloadCount(params.type!, +params.contentId!, client);\n  }\n\n  return result;\n}\n\nasync function resolveUrl(params: Params<string>, client: SupabaseClient, headers: Headers) {\n  const tableName = resolveType(params.type!);\n  const contentId = +params.contentId!;\n  const docId = params.docId!;\n\n  if (!tableName) {\n    return Response.json({}, { status: 400, headers });\n  }\n\n  if (docId === 'link') {\n    return await resolveFileLink(tableName, contentId, client, headers);\n  }\n\n  const bucket = `${process.env.TENANT_ID}-documents`;\n\n  // Query storage.objects table to get the actual file path\n  const fileMetadata = await resolveFileFromStorage(docId, bucket, client);\n\n  if (!fileMetadata) {\n    return Response.json({}, { status: 404, headers });\n  }\n\n  const result = await client.storage.from(bucket).createSignedUrl(fileMetadata.fullPath, 3600, {\n    download: true,\n  });\n\n  if (!result.data?.signedUrl) {\n    console.error(result.error);\n    return Response.json({}, { status: 500, headers });\n  }\n\n  return redirect(result.data?.signedUrl);\n}\n\nasync function resolveFileLink(\n  tableName: string,\n  id: number,\n  client: SupabaseClient,\n  headers: Headers,\n) {\n  const { data, error } = await client.from(tableName).select('id,file_link').eq('id', id).single();\n\n  if (!data) {\n    console.error(error);\n    return Response.json({}, { status: 500, headers });\n  }\n\n  return redirect(data.file_link);\n}\n\nasync function resolveFileFromStorage(\n  docId: string,\n  bucket: string,\n  client: SupabaseClient,\n): Promise<{ fullPath: string; name: string } | null> {\n  // Use RPC function to query storage.objects table (which is in storage schema)\n  const { data, error } = await client\n    .rpc('get_storage_object_path', {\n      object_id: docId,\n      bucket_name: bucket,\n    })\n    .single<FileObject>();\n\n  if (!data || error) {\n    console.error('Failed to resolve file from storage.objects:', error);\n    return null;\n  }\n\n  // The name field contains the full path in storage\n  const fullPath = data.name;\n\n  return { fullPath, name: data.name };\n}\n\nasync function incrementDownloadCount(type: string, contentId: number, client: SupabaseClient) {\n  const tableName = resolveType(type)!;\n\n  const { data } = await client\n    .from(tableName)\n    .select('id,file_download_count')\n    .eq('id', +contentId)\n    .single();\n  const downloadableDoc = data as Partial<IDBDownloadable>;\n\n  await client\n    .from(tableName)\n    .update({\n      file_download_count: (downloadableDoc.file_download_count || 0) + 1,\n    })\n    .eq('id', +contentId);\n}\n"
  },
  {
    "path": "src/routes/api.documents.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { ContentType, IMediaFile } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\nimport { methodNotAllowedError, validationError } from 'src/utils/httpException';\n\nexport const action = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n    const contentType = formData.get('contentType') as ContentType;\n    const file = formData.get('file') as File;\n    const id = formData.has('id') ? Number(formData.get('id') as string) : null;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    await validateRequest(request);\n\n    const uploadResult = await new StorageServiceServer(client).uploadFile(\n      [file],\n      `${id ? contentType : 'users'}/${id ?? claims.data.claims.sub}`,\n    );\n\n    if (uploadResult?.errors && uploadResult?.errors.length > 0) {\n      throw validationError(uploadResult?.errors.join(', '));\n    }\n\n    const document: IMediaFile = {\n      id: uploadResult!.media[0].id,\n      name: uploadResult!.media[0].name,\n      size: uploadResult!.media[0].size,\n    };\n\n    return Response.json({ document }, { headers });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({}, { headers, status: 500 });\n  }\n};\n\nasync function validateRequest(request: Request) {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n}\n"
  },
  {
    "path": "src/routes/api.donation-settings.$profileId.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ImageServiceServer } from 'src/services/imageService.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const donationSettingsResult = await client.from('tenant_settings').select('donation_settings');\n    const data = donationSettingsResult.data?.at(0);\n\n    const donationSettings = {\n      spaceName: '',\n      campaignId: data?.donation_settings?.defaultCampaignId,\n      description: data?.donation_settings?.defaultDescription,\n      imageUrl: data?.donation_settings?.defaultImageUrl,\n    };\n\n    if (params.profileId) {\n      try {\n        const profileResult = await client\n          .from('profiles')\n          .select('display_name,cover_images,donations_enabled')\n          .eq('id', params.profileId)\n          .single();\n\n        if (profileResult.data?.donations_enabled) {\n          donationSettings.spaceName = profileResult.data.display_name;\n          donationSettings.campaignId = `${process.env.TENANT_ID}-${params.profileId}`;\n          donationSettings.description = data?.donation_settings?.spaceDescription;\n\n          if (profileResult.data.cover_images?.at(0)) {\n            donationSettings.imageUrl = new ImageServiceServer(client).getPublicUrl(\n              profileResult.data.cover_images[0],\n            )?.publicUrl;\n          }\n        }\n      } catch (error) {\n        console.error('Error fetching profile donation settings:', error);\n      }\n    }\n\n    return Response.json(donationSettings, { headers, status: 200 });\n  } catch (error) {\n    console.error('Error fetching donation settings:', error);\n    return Response.json({}, { headers, status: 500 });\n  }\n}\n"
  },
  {
    "path": "src/routes/api.favicon.ts",
    "content": "import Keyv from 'keyv';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { isProductionEnvironment } from 'src/config/config';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nconst cache = new Keyv<{ body: string; contentType: string }>({\n  ttl: 3600000,\n}); // ttl: 60 minutes\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const cached = await cache.get('favicon');\n  if (cached && isProductionEnvironment()) {\n    // Convert base64 back to Buffer for Response\n    const binary = Buffer.from(cached.body, 'base64');\n    return new Response(binary, {\n      status: 200,\n      headers: {\n        ...headers,\n        'Content-Type': cached.contentType,\n        'Cache-Control': 'public, max-age=600',\n      },\n    });\n  }\n\n  const { data, error } = await client\n    .from('tenant_settings')\n    .select('site_favicon')\n    .eq('tenant_id', process.env.TENANT_ID)\n    .limit(1);\n\n  if (error) {\n    return new Response('Failed to load favicon metadata', { status: 500 });\n  }\n\n  let faviconLink: string | null = null;\n  if (data && Array.isArray(data) && data.length > 0) {\n    faviconLink = data[0].site_favicon;\n  }\n\n  if (!faviconLink) {\n    return new Response('Favicon not found', { status: 404 });\n  }\n\n  // Fetch the image bytes from the stored link\n  const imageRes = await fetch(faviconLink);\n\n  if (!imageRes.ok) {\n    return new Response('Failed to fetch favicon image', { status: 502 });\n  }\n\n  const contentType = imageRes.headers.get('content-type') || 'image/x-icon';\n  const body = await imageRes.arrayBuffer();\n\n  // Cache as base64 string (ArrayBuffer doesn't serialize well)\n  const base64Body = Buffer.from(body).toString('base64');\n  cache.set('favicon', { body: base64Body, contentType });\n\n  return new Response(body, {\n    status: 200,\n    headers: {\n      ...headers,\n      'Content-Type': contentType,\n      'Cache-Control': 'public, max-age=600',\n    },\n  });\n}\n"
  },
  {
    "path": "src/routes/api.images.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { ContentType, MediaWithPublicUrl } from 'oa-shared';\nimport { type ActionFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\nimport { methodNotAllowedError, validationError } from 'src/utils/httpException';\nimport { validateImage } from 'src/utils/storage';\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n    const contentType = formData.get('contentType') as ContentType;\n    const imageFile = formData.get('imageFile') as File;\n    const id = formData.has('id') ? Number(formData.get('id') as string) : null;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    await validateRequest(request, imageFile);\n\n    const storage = new StorageServiceServer(client);\n\n    const uploadResult = await storage.uploadImage(\n      [imageFile],\n      `${id ? contentType : 'users'}/${id ?? claims.data.claims.sub}`,\n    );\n\n    if (uploadResult?.errors && uploadResult?.errors.length > 0) {\n      throw validationError(uploadResult.errors.join(', '));\n    }\n\n    const [publicMedia] = storage.getPublicUrls([uploadResult.media[0]]);\n\n    const image: MediaWithPublicUrl = { ...uploadResult.media[0], ...publicMedia };\n\n    return Response.json({ image }, { headers });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({}, { headers, status: 500 });\n  }\n};\n\nasync function validateRequest(request: Request, imageFile: File) {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n\n  const { error } = validateImage(imageFile);\n\n  if (error) {\n    throw validationError(error.message);\n  }\n}\n"
  },
  {
    "path": "src/routes/api.map-filters.ts",
    "content": "import Keyv from 'keyv';\nimport type { DBMapSettings, DefaultMapFilters, FilterResponse, MapFilters } from 'oa-shared';\nimport { ProfileBadge, ProfileTag, ProfileType } from 'oa-shared';\nimport { isProductionEnvironment } from 'src/config/config';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nconst cache = new Keyv<FilterResponse>({ ttl: 3600000 }); // expires 60 minutes after being set\n\nexport const loader = async ({ request }) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const cachedMapFilters = await cache.get('map-filters');\n\n    if (cachedMapFilters && isProductionEnvironment()) {\n      return Response.json(cachedMapFilters, { headers, status: 200 });\n    }\n\n    const [tags, badges, types, mapSettings] = await Promise.all([\n      client.from('profile_tags').select('*'),\n      client.from('profile_badges').select('*'),\n      client.from('profile_types').select('*'),\n      client.from('map_settings').select('*'),\n    ]);\n\n    const errors = [tags, badges, types, mapSettings]\n      .filter((x) => !!x.error)\n      .flatMap((x) => x.error);\n    if (errors.length) {\n      console.error({ message: 'Error fetching map pin filters', errors });\n    }\n\n    const settings = mapSettings?.data?.at(0) as DBMapSettings | undefined;\n\n    const filters: MapFilters = {\n      tags: tags?.data?.map((x) => ProfileTag.fromDB(x)),\n      badges: badges?.data?.map((x) => ProfileBadge.fromDB(x)),\n      types: types?.data?.map((x) => ProfileType.fromDB(x)),\n      settings: settings?.setting_filters || undefined,\n    };\n\n    const defaultFilters: DefaultMapFilters = {\n      types: settings?.default_type_filters || undefined,\n    };\n\n    const response: FilterResponse = { filters, defaultFilters };\n\n    cache.set('map-filters', response);\n    return Response.json(response, { headers });\n  } catch (error) {\n    console.error(error);\n    return Response.json({}, { status: 500, headers });\n  }\n};\n"
  },
  {
    "path": "src/routes/api.map-pin.ts",
    "content": "import type { DBMapPin } from 'oa-shared';\nimport { MapPinFactory } from 'src/factories/mapPinFactory.server';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport const loader = async ({ request }) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const profileService = new ProfileServiceServer(client);\n    const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n    if (!profile) {\n      return Response.json({}, { headers, status: 400, statusText: 'user not found' });\n    }\n\n    const { data, error } = await client\n      .from('map_pins')\n      .select(\n        `\n        id,\n        profile_id,\n        country,\n        country_code,\n        name,\n        administrative,\n        post_code,\n        lat,\n        lng,\n        moderation,\n        moderation_feedback,\n        profile:profiles(\n          id,\n          country,\n          display_name,\n          photo,\n          type,\n          about,\n          username,\n          badges:profile_badges_relations(\n            profile_badges(\n              id,\n              name,\n              display_name,\n              image_url,\n              action_url\n            )\n          ),\n          tags:profile_tags_relations(\n            profile_tags(\n              id,\n              name\n            )\n          ),\n          type:profile_types(\n            id,\n            name,\n            display_name,\n            image_url,\n            small_image_url,\n            map_pin_name,\n            description,\n            is_space\n          )\n        )\n      `,\n      )\n      .eq('profile_id', profile.id);\n\n    if (error) {\n      console.error(error);\n\n      return Response.json({}, { headers, status: 500, statusText: 'Error fetching map-pins' });\n    }\n\n    if (!data?.length) {\n      return Response.json({ mapPin: null }, { headers });\n    }\n\n    const pinsDb = data[0] as unknown as DBMapPin;\n    const pinFactory = new MapPinFactory(client);\n    const mapPin = pinFactory.fromDBWithProfile(pinsDb);\n\n    return Response.json({ mapPin }, { headers });\n  } catch (error) {\n    console.error(error);\n    return Response.json({}, { status: 500, headers });\n  }\n};\n"
  },
  {
    "path": "src/routes/api.map-pins.$userId.ts",
    "content": "import type { DBMapPin } from 'oa-shared';\nimport { MapPinFactory } from 'src/factories/mapPinFactory.server';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\n// runs on the server\nexport const loader = async ({ request, params }) => {\n  const { client, headers } = createSupabaseServerClient(request);\n  try {\n    const profileId = Number(params.userId);\n\n    const { data, error } = await client\n      .from('map_pins')\n      .select(\n        `\n        id,\n        profile_id,\n        country,\n        country_code,\n        name,\n        administrative,\n        post_code,\n        lat,\n        lng,\n        moderation,\n        moderation_feedback,\n        profile:profiles(\n          id,\n          country,\n          display_name,\n          photo,\n          type,\n          about,\n          username,\n          badges:profile_badges_relations(\n            profile_badges(\n              id,\n              name,\n              display_name,\n              image_url,\n              action_url\n            )\n          ),\n          tags:profile_tags_relations(\n            profile_tags(\n              id,\n              name\n            )\n          ),\n          type:profile_types(\n            id,\n            name,\n            display_name,\n            image_url,\n            small_image_url,\n            map_pin_name,\n            description,\n            is_space\n          )\n        )\n      `,\n      )\n      .eq('profile_id', profileId)\n      .eq('moderation', 'accepted');\n\n    if (error) {\n      console.error(error);\n\n      return Response.json({}, { headers, status: 500, statusText: 'Error fetching map-pins' });\n    }\n\n    if (!data?.length) {\n      return Response.json({ mapPin: null }, { headers });\n    }\n\n    const pinsDb = data[0] as unknown as DBMapPin;\n    const pinFactory = new MapPinFactory(client);\n    const mapPin = pinFactory.fromDBWithProfile(pinsDb);\n\n    return Response.json({ mapPin }, { headers });\n  } catch (error) {\n    console.error(error);\n    return Response.json({}, { status: 500, headers });\n  }\n};\n"
  },
  {
    "path": "src/routes/api.map-pins.ts",
    "content": "import { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { MapPinsServiceServer } from 'src/services/mapPinsService.server';\n\n// runs on the server\nexport const loader = async ({ request }) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const mapPins = await new MapPinsServiceServer(client).get();\n    return Response.json({ mapPins }, { headers });\n  } catch (error) {\n    console.error(error);\n\n    return Response.json({}, { headers, status: 500, statusText: 'Error fetching map-pins' });\n  }\n};\n"
  },
  {
    "path": "src/routes/api.messages.tsx",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { MESSAGE_MAX_CHARACTERS, MESSAGE_MIN_CHARACTERS } from 'src/pages/User/constants';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { TenantSettingsService } from 'src/services/tenantSettingsService.server';\nimport {\n  forbiddenError,\n  methodNotAllowedError,\n  tooManyRequestsError,\n  unauthorizedError,\n  validationError,\n} from 'src/utils/httpException';\nimport { sendEmail } from '../.server/resend';\nimport ReceiverMessage from '../.server/templates/ReceiverMessage';\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n\n    const data = {\n      to: formData.get('to') as string,\n      message: formData.get('message') as string,\n      name: formData.has('name') ? (formData.get('name') as string) : undefined,\n    };\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      throw unauthorizedError();\n    }\n\n    await validateRequest(request, claims.data.claims.email!, data);\n\n    const userProfile = await client\n      .from('profiles')\n      .select('id,username,display_name')\n      .eq('auth_id', claims.data.claims.sub);\n\n    const messenger = userProfile.data?.at(0);\n\n    if (!messenger?.username) {\n      throw forbiddenError('You must have a username to send messages.');\n    }\n\n    const recipientProfile = await client\n      .from('profiles')\n      .select('id,auth_id')\n      .eq('username', data.to);\n\n    const from = messenger.id;\n    const to = recipientProfile.data!.at(0)!.id;\n    const toAuthId = recipientProfile.data!.at(0)!.auth_id;\n\n    const today = new Date();\n    const yesterday = new Date(today.getTime() - 1000 * 60 * 60 * 24);\n    const countResult = await client\n      .from('messages')\n      .select('id', { count: 'exact' })\n      .eq('sender_id', from)\n      .gt('created_at', yesterday.toISOString());\n\n    if (countResult.error) {\n      throw countResult.error;\n    }\n\n    if (countResult.count! >= 20) {\n      throw tooManyRequestsError(\n        \"You've contacted a lot of people today! So to protect the platform from spam we haven't sent this message.\",\n      );\n    }\n\n    const settings = await new TenantSettingsService(client).get();\n\n    const messageResult = await client.from('messages').insert({\n      sender_id: from,\n      receiver_id: to,\n      message: data.message,\n      tenant_id: process.env.TENANT_ID!,\n    });\n\n    if (messageResult.error) {\n      throw messageResult.error;\n    }\n\n    // TODO: use get_user_email_by_id only after removing firebase completely and all profiles have an auth_id\n    const emailResult = toAuthId\n      ? await client.rpc('get_user_email_by_id', { id: toAuthId })\n      : await client.rpc('get_user_email_by_username', {\n          username: data.to,\n        });\n    const receiver = emailResult.data[0];\n\n    const emailTemplate = (\n      <ReceiverMessage\n        settings={{\n          siteName: settings.siteName,\n          messageSignOff: settings.messageSignOff,\n          siteImage: settings.siteImage,\n          siteUrl: settings.siteUrl,\n        }}\n        text={data.message}\n        receiverName={data.to}\n        messengerEmailAddress={claims.data.claims.email as string}\n        messengerName={data.name}\n        messengerUsername={messenger.username}\n      />\n    );\n\n    const sendResult = await sendEmail({\n      from: settings.emailFrom,\n      to: receiver.email,\n      subject: `${messenger.username} sent you a message via ${settings.siteName}!`,\n      emailTemplate,\n    });\n\n    if (sendResult.error) {\n      throw sendResult.error;\n    }\n\n    return Response.json(null, { headers, status: 201 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n\n    return Response.json({ error }, { headers, status: 500, statusText: 'Error sending message' });\n  }\n};\n\nasync function validateRequest(request: Request, userEmail: string | null, data: any) {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n\n  if (!data.to) {\n    throw validationError('to is required', 'to');\n  }\n\n  if (!data.message) {\n    throw validationError('message is required', 'message');\n  }\n\n  if (data.message.length < MESSAGE_MIN_CHARACTERS) {\n    throw validationError(\n      `Message must be at least ${MESSAGE_MIN_CHARACTERS} characters`,\n      'message',\n    );\n  }\n\n  if (data.message.length > MESSAGE_MAX_CHARACTERS) {\n    throw validationError(\n      `Message must be no more than ${MESSAGE_MAX_CHARACTERS} characters`,\n      'message',\n    );\n  }\n\n  if (!userEmail) {\n    throw validationError('Unable to get messenger email address', 'email');\n  }\n}\n"
  },
  {
    "path": "src/routes/api.news.$id.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport { HTTPException } from 'hono/http-exception';\nimport type { DBMedia, DBNews, NewsDTO } from 'oa-shared';\nimport { News } from 'oa-shared';\nimport type { LoaderFunctionArgs, Params } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { NewsServiceServer } from 'src/services/newsService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { getSummaryFromMarkdown } from 'src/utils/getSummaryFromMarkdown';\nimport { hasAdminRights } from 'src/utils/helpers';\nimport {\n  conflictError,\n  forbiddenError,\n  methodNotAllowedError,\n  notFoundError,\n  unauthorizedError,\n  validationError,\n} from 'src/utils/httpException';\nimport { convertToSlug } from 'src/utils/slug';\n\nexport const action = async ({ request, params }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const id = Number(params.id);\n    const formData = await request.formData();\n    const data = {\n      body: formData.get('body') as string,\n      category: formData.has('category') ? Number(formData.get('category')) : null,\n      isDraft: formData.get('isDraft') === 'true',\n      profileBadge: formData.has('profileBadge') ? Number(formData.get('profileBadge')) : null,\n      tags: formData.has('tags') ? formData.getAll('tags').map((x) => Number(x)) : null,\n      title: formData.get('title') as string,\n      heroImage: formData.has('heroImage')\n        ? (JSON.parse(formData.get('heroImage') as string) as DBMedia)\n        : null,\n    } satisfies NewsDTO;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      throw unauthorizedError();\n    }\n\n    const currentNews = await new NewsServiceServer(client).getById(id);\n    const slug = convertToSlug(data.title);\n    await validateRequest(params, request, claims.data.claims.sub, data, currentNews, slug, client);\n    const previousSlugs = ContentServiceServer.updatePreviousSlugs(currentNews, slug);\n\n    const isFirstPublish = currentNews.is_draft && !data.isDraft && !currentNews.published_at;\n\n    const now = new Date();\n\n    const newsResult = await client\n      .from('news')\n      .update({\n        body: data.body,\n        category: data.category,\n        is_draft: data.isDraft,\n        modified_at: new Date(),\n        slug: slug,\n        previous_slugs: previousSlugs,\n        profile_badge: data.profileBadge,\n        summary: getSummaryFromMarkdown(data.body),\n        tags: data.tags,\n        title: data.title,\n        hero_image: data.heroImage,\n        ...(isFirstPublish && { published_at: now }),\n      })\n      .eq('id', id)\n      .select();\n\n    if (newsResult.error || !newsResult.data) {\n      throw newsResult.error;\n    }\n\n    const news = News.fromDB(newsResult.data[0], []);\n\n    new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ news }, { headers, status: 200 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({ error: 'Error updating news', status: 500 }, { status: 500 });\n  }\n};\n\nasync function validateRequest(\n  params: Params<string>,\n  request: Request,\n  userAuthId: string,\n  data: NewsDTO,\n  currentNews: DBNews,\n  slug: string,\n  client: SupabaseClient,\n): Promise<void> {\n  if (request.method !== 'PUT') {\n    throw methodNotAllowedError();\n  }\n\n  if (!params.id) {\n    throw validationError('ID is required', 'id');\n  }\n\n  if (!data.title) {\n    throw validationError('Title is required', 'title');\n  }\n\n  if (!data.body) {\n    throw validationError('Body is required', 'body');\n  }\n\n  if (!data.heroImage) {\n    throw validationError('Hero Image is required', 'heroImage');\n  }\n\n  if (!currentNews) {\n    throw notFoundError('News');\n  }\n\n  if (\n    currentNews.slug !== slug &&\n    (await new ContentServiceServer(client).isDuplicateExistingSlug(slug, currentNews.id, 'news'))\n  ) {\n    throw conflictError('This news already exists');\n  }\n\n  const profile = await new ProfileServiceServer(client).getByAuthId(userAuthId);\n\n  if (!profile) {\n    throw validationError('User not found');\n  }\n\n  if (!profile.username) {\n    throw validationError('You must set a username before editing content', 'username');\n  }\n\n  const isCreator = currentNews.created_by === profile.id;\n\n  if (!isCreator && !hasAdminRights(profile)) {\n    throw forbiddenError();\n  }\n}\n"
  },
  {
    "path": "src/routes/api.news.drafts.count.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({}, { headers, status: 400, statusText: 'invalid user' });\n  }\n\n  const count = await new ContentServiceServer(client).getDraftCount(profile.id, 'news');\n\n  return Response.json({ total: count }, { headers });\n};\n"
  },
  {
    "path": "src/routes/api.news.drafts.ts",
    "content": "import type { DBNews } from 'oa-shared';\nimport { News } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({ items: [], total: 0 }, { headers });\n  }\n\n  const result = await client\n    .from('news')\n    .select('*')\n    .or('deleted.eq.false,deleted.is.null')\n    .eq('is_draft', true)\n    .or(`created_by.eq.${profile.id}`);\n\n  if (result.error) {\n    console.error(result.error);\n    return Response.json({}, { headers, status: 500 });\n  }\n\n  if (!result.data || result.data.length === 0) {\n    return Response.json({ items: [] }, { headers });\n  }\n\n  const drafts = result.data as unknown as DBNews[];\n  const items = drafts.map((x) => {\n    const images = x.hero_image\n      ? new StorageServiceServer(client).getPublicUrls([x.hero_image], IMAGE_SIZES.GALLERY)\n      : [];\n\n    return News.fromDB(x, [], images[0]);\n  });\n\n  return Response.json({ items }, { headers });\n}\n"
  },
  {
    "path": "src/routes/api.news.ts",
    "content": "// TODO: split this in separate files once we update remix to NOT use file-based routing\n\nimport { HTTPException } from 'hono/http-exception';\nimport type { DBMedia, DBNews, DBProfile, Moderation, NewsDTO } from 'oa-shared';\nimport { News } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { ITEMS_PER_PAGE } from 'src/pages/News/constants';\nimport type { NewsSortOption } from 'src/pages/News/NewsSortOptions';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { discordServiceServer } from 'src/services/discordService.server';\nimport { NewsServiceServer } from 'src/services/newsService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { SubscribersServiceServer } from 'src/services/subscribersService.server';\nimport { getSummaryFromMarkdown } from 'src/utils/getSummaryFromMarkdown';\nimport { conflictError, methodNotAllowedError, validationError } from 'src/utils/httpException';\nimport { convertToSlug } from 'src/utils/slug';\nimport { ContentServiceServer } from '../services/contentService.server';\n\nexport const loader = async ({ request }) => {\n  const url = new URL(request.url);\n  const params = new URLSearchParams(url.search);\n  const q = params.get('q');\n  const sort = params.get('sort') as NewsSortOption;\n  const skip = Number(params.get('skip')) || 0;\n\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n  let currentUserBadges: number[] = [];\n  let isAdmin = false;\n  if (claims?.data?.claims?.sub) {\n    const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n    isAdmin = !!profile?.roles?.includes('admin');\n    currentUserBadges = profile?.badges?.map((x) => x.profile_badges.id) || [];\n  }\n\n  let query = client\n    .from('news')\n    .select(\n      `\n      id,\n      created_at,\n      created_by,\n      modified_at,\n      published_at,\n      is_draft,\n      comment_count,\n      body,\n      slug,\n      summary,\n      category:category(id,name),\n      profile_badge:profile_badge(*),\n      tags,\n      title,\n      total_views,\n      hero_image,\n      author:profiles(id, display_name, username, country, badges:profile_badges_relations(\n        profile_badges(\n          id,\n          name,\n          display_name,\n          image_url,\n          action_url\n        )\n      ))`,\n      { count: 'exact' },\n    )\n\n    .eq('is_draft', false);\n\n  if (!isAdmin) {\n    query = query.or(\n      `profile_badge.is.null${currentUserBadges.length > 0 ? `,profile_badge.in.(${currentUserBadges.join(',')})` : ''}`,\n    );\n  }\n\n  if (q) {\n    query = query.textSearch('news_search_fields', q);\n  }\n\n  if (sort === 'Newest') {\n    query = query.order('published_at', { ascending: false });\n  } else if (sort === 'Comments') {\n    query = query.order('comment_count', { ascending: false });\n  } else if (sort === 'LeastComments') {\n    query = query.order('comment_count', { ascending: true });\n  }\n\n  const queryResult = await query.range(skip, skip + ITEMS_PER_PAGE - 1); // 0 based\n\n  const total = queryResult.count;\n  const data = queryResult.data as unknown as DBNews[];\n  const items = data.map((dbNews) => News.fromDB(dbNews, []));\n\n  if (items && items.length > 0) {\n    // Populate useful votes\n    const votes = await client.rpc('get_useful_votes_count_by_content_id', {\n      p_content_type: 'news',\n      p_content_ids: items.map((x) => x.id),\n    });\n\n    if (votes.data) {\n      const votesByContentId = votes.data.reduce((acc, current) => {\n        acc.set(current.content_id, current.count);\n        return acc;\n      }, new Map());\n\n      for (const item of items) {\n        if (votesByContentId.has(item.id)) {\n          item.usefulCount = votesByContentId.get(item.id)!;\n        }\n        item.heroImage = await new NewsServiceServer(client).getHeroImage(\n          data.find((x) => x.id === item.id)?.hero_image || null,\n        );\n      }\n    }\n  }\n\n  return Response.json({ items, total }, { headers });\n};\n\nexport const action = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n    const data = {\n      body: formData.get('body') as string,\n      category: formData.has('category') ? Number(formData.get('category')) : null,\n      isDraft: formData.get('isDraft') === 'true',\n      profileBadge: formData.has('profileBadge') ? Number(formData.get('profileBadge')) : null,\n      tags: formData.has('tags') ? formData.getAll('tags').map((x) => Number(x)) : null,\n      title: formData.get('title') as string,\n      heroImage: formData.has('heroImage')\n        ? (JSON.parse(formData.get('heroImage') as string) as DBMedia)\n        : null,\n    } satisfies NewsDTO;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    await validateRequest(request, data);\n\n    const slug = convertToSlug(data.title);\n\n    if (await new ContentServiceServer(client).isDuplicateNewSlug(slug, 'news')) {\n      throw conflictError('This news already exists');\n    }\n\n    const profileRequest = await client\n      .from('profiles')\n      .select('id,username')\n      .eq('auth_id', claims.data.claims.sub)\n      .limit(1);\n\n    if (profileRequest.error || !profileRequest.data?.at(0)) {\n      console.error(profileRequest.error);\n      throw validationError('User not found');\n    }\n\n    const profile = profileRequest.data[0] as DBProfile;\n\n    if (!profile.username) {\n      throw validationError('You must set a username before creating content', 'username');\n    }\n\n    const newsResult = await client\n      .from('news')\n      .insert({\n        body: data.body,\n        category: data.category,\n        created_by: profile.id,\n        is_draft: data.isDraft,\n        moderation: 'accepted' as Moderation,\n        profile_badge: data.profileBadge,\n        published_at: data.isDraft ? null : new Date(),\n        slug,\n        summary: getSummaryFromMarkdown(data.body),\n        tags: data.tags,\n        hero_image: data.heroImage,\n        tenant_id: process.env.TENANT_ID,\n        title: data.title,\n      })\n      .select();\n\n    if (newsResult.error || !newsResult.data) {\n      throw newsResult.error;\n    }\n\n    const news = News.fromDB(newsResult.data[0], []);\n    new SubscribersServiceServer(client).add('news', news.id, profile.id);\n\n    if (!news.isDraft) {\n      notifyDiscord(news, profile, new URL(request.url).origin.replace('http:', 'https:'));\n    }\n\n    await new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ news }, { headers, status: 201 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({ error: 'Error creating news', status: 500 }, { status: 500 });\n  }\n};\n\nfunction notifyDiscord(news: News, profile: DBProfile, siteUrl: string) {\n  const title = news.title;\n  const slug = news.slug;\n\n  if (!profile.username) {\n    return;\n  }\n\n  discordServiceServer.postWebhookRequest(\n    `📰 ${profile.username} has news: ${title}\\n<${siteUrl}/news/${slug}>`,\n  );\n}\n\nasync function validateRequest(request: Request, data: NewsDTO): Promise<void> {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n\n  if (!data.title) {\n    throw validationError('Title is required', 'title');\n  }\n\n  if (!data.body) {\n    throw validationError('Body is required', 'body');\n  }\n\n  if (!data.heroImage) {\n    throw validationError('Hero image is required', 'body');\n  }\n}\n"
  },
  {
    "path": "src/routes/api.notifications-preferences-via-email.$userCode.ts",
    "content": "import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { tokens } from 'src/utils/tokens.server';\nimport { DEFAULT_NOTIFICATION_PREFERENCES } from './api.notifications-preferences';\n\ninterface DecodedToken {\n  profileId: string;\n  profileCreatedAt: string;\n}\n\nexport const loader = async ({ params, request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    if (!params.userCode) {\n      return Response.json({}, { headers, status: 401, statusText: 'unauthorized' });\n    }\n\n    const decoded = tokens.verify(params.userCode) as DecodedToken;\n    const profileId = Number(decoded.profileId);\n\n    const userData = await client\n      .from('profiles')\n      .select('id,is_contactable')\n      .eq('id', profileId)\n      .eq('created_at', decoded.profileCreatedAt)\n      .maybeSingle();\n\n    const userId = userData.data?.id as number;\n    if (!userId) {\n      return Response.json({}, { headers, status: 401, statusText: 'unauthorized' });\n    }\n\n    const preferencesData = await client\n      .from('notifications_preferences')\n      .select('*')\n      .eq('user_id', userId)\n      .maybeSingle();\n\n    const is_contactable = userData.data?.is_contactable;\n    const preferences = preferencesData.data || DEFAULT_NOTIFICATION_PREFERENCES;\n\n    return Response.json({ preferences, is_contactable }, { headers, status: 200 });\n  } catch (error) {\n    console.error(error);\n    return Response.json({ error }, { headers, status: 500 });\n  }\n};\n\nexport const action = async ({ params, request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    if (!params.userCode) {\n      return Response.json({}, { headers, status: 401, statusText: 'unauthorized' });\n    }\n\n    const decoded = tokens.verify(params.userCode) as DecodedToken;\n    const profileId = Number(decoded.profileId);\n\n    const userData = await client\n      .from('profiles')\n      .select('id')\n      .eq('id', profileId)\n      .eq('created_at', decoded.profileCreatedAt)\n      .maybeSingle();\n\n    const userId = userData.data?.id as number;\n\n    if (!userId) {\n      return Response.json({}, { headers, status: 401, statusText: 'unauthorized' });\n    }\n\n    const { valid, status, statusText } = await validateRequest(request, userId);\n\n    if (!valid) {\n      return Response.json({}, { headers, status, statusText });\n    }\n\n    const formData = await request.formData();\n    const existingPreferences = await client\n      .from('notifications_preferences')\n      .select('id')\n      .eq('user_id', userId)\n      .maybeSingle();\n\n    const existingPreferencesId = existingPreferences.data?.id || null;\n    const comments = formData.get('comments') === 'true';\n    const replies = formData.get('replies') === 'true';\n    const researchUpdates = formData.get('research_updates') === 'true';\n    const isUnsubscribed = formData.get('is_unsubscribed') === 'true';\n\n    if (existingPreferencesId) {\n      await client\n        .from('notifications_preferences')\n        .update({\n          comments,\n          replies,\n          research_updates: researchUpdates,\n          is_unsubscribed: isUnsubscribed,\n        })\n        .eq('id', existingPreferencesId)\n        .select();\n      return Response.json({}, { headers, status: 200 });\n    }\n\n    await client.from('notifications_preferences').insert({\n      user_id: userId,\n      comments,\n      replies,\n      research_updates: researchUpdates,\n      is_unsubscribed: isUnsubscribed,\n      tenant_id: process.env.TENANT_ID!,\n    });\n\n    return Response.json({}, { headers, status: 200 });\n  } catch (error) {\n    console.error(error);\n    return Response.json({ error }, { headers, status: 500 });\n  }\n};\n\nasync function validateRequest(request: Request, userId: number) {\n  if (!userId) {\n    return { valid: false, status: 401, statusText: 'unauthorized' };\n  }\n\n  if (request.method !== 'POST') {\n    return { valid: false, status: 405, statusText: 'Method not allowed' };\n  }\n\n  return { valid: true };\n}\n"
  },
  {
    "path": "src/routes/api.notifications-preferences.ts",
    "content": "import type { DBNotificationsPreferencesFields } from 'oa-shared';\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport const DEFAULT_NOTIFICATION_PREFERENCES: DBNotificationsPreferencesFields = {\n  comments: true,\n  replies: true,\n  research_updates: true,\n  is_unsubscribed: false,\n};\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401, statusText: 'unauthorized' });\n  }\n\n  const { data } = await client\n    .from('notifications_preferences')\n    .select('*, profiles!inner(id)')\n    .eq('profiles.auth_id', claims.data?.claims?.sub)\n    .single();\n\n  const preferences = data || DEFAULT_NOTIFICATION_PREFERENCES;\n\n  return Response.json({ preferences }, { headers, status: 200 });\n};\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n    const id = formData.has('id') ? Number(formData.get('id') as string) : null;\n    const comments = formData.get('comments') === 'true';\n    const replies = formData.get('replies') === 'true';\n    const research_updates = formData.get('research_updates') === 'true';\n    const is_unsubscribed = formData.get('is_unsubscribed') === 'true';\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const { valid, status, statusText } = await validateRequest(request);\n\n    if (!valid) {\n      return Response.json({}, { headers, status, statusText });\n    }\n\n    if (id) {\n      await client\n        .from('notifications_preferences')\n        .update({\n          comments,\n          replies,\n          research_updates,\n          is_unsubscribed,\n        })\n        .eq('id', id)\n        .select();\n\n      return Response.json({}, { headers, status: 200 });\n    }\n\n    const { data, error } = await client\n      .from('profiles')\n      .select('id, auth_id')\n      .eq('auth_id', claims.data.claims.sub)\n      .single();\n\n    if (!data) {\n      console.error(error);\n      return Response.json({}, { headers, status: 401, statusText: 'User not found' });\n    }\n\n    await client.from('notifications_preferences').insert({\n      user_id: data.id,\n      comments,\n      replies,\n      research_updates,\n      is_unsubscribed,\n      tenant_id: process.env.TENANT_ID!,\n    });\n\n    new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({}, { headers, status: 200 });\n  } catch (error) {\n    console.error('Action error:', error);\n    return Response.json({ error }, { headers, status: 500 });\n  }\n};\n\nasync function validateRequest(request: Request) {\n  if (request.method !== 'POST') {\n    return { valid: false, status: 405, statusText: 'Method not allowed' };\n  }\n\n  return { valid: true };\n}\n"
  },
  {
    "path": "src/routes/api.notifications.$id.read.ts",
    "content": "import type { LoaderFunctionArgs, Params } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport const action = async ({ params, request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const { valid, status, statusText } = await validateRequest(params, request);\n\n    if (!valid) {\n      return Response.json({}, { headers, status, statusText });\n    }\n\n    await client.from('notifications').update({ is_read: true }).eq('id', params.id);\n\n    new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({}, { headers, status: 200 });\n  } catch (error) {\n    console.error(error);\n    return Response.json(\n      {},\n      {\n        headers,\n        status: 500,\n        statusText: 'Error setting notification as read',\n      },\n    );\n  }\n};\n\nasync function validateRequest(params: Params<string>, request: Request) {\n  if (request.method !== 'POST') {\n    return { status: 405, statusText: 'method not allowed' };\n  }\n\n  if (!params.id) {\n    return { status: 400, statusText: 'id is required' };\n  }\n\n  return { valid: true };\n}\n"
  },
  {
    "path": "src/routes/api.notifications.all.read.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport const action = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const { valid, status, statusText } = await validateRequest(request);\n\n    if (!valid) {\n      return Response.json({}, { headers, status, statusText });\n    }\n\n    const profile = await client\n      .from('profiles')\n      .select('id')\n      .eq('auth_id', claims.data.claims.sub)\n      .single();\n\n    await client\n      .from('notifications')\n      .update({ is_read: true })\n      .eq('owned_by_id', profile?.data?.id);\n\n    new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({}, { headers, status: 200 });\n  } catch (error) {\n    console.error(error);\n    return Response.json(\n      {},\n      {\n        headers,\n        status: 500,\n        statusText: 'Error setting notification as read',\n      },\n    );\n  }\n};\n\nasync function validateRequest(request: Request) {\n  if (request.method !== 'POST') {\n    return { status: 405, statusText: 'method not allowed' };\n  }\n\n  return { valid: true };\n}\n"
  },
  {
    "path": "src/routes/api.notifications.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBNotification } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { NotificationMapperServiceServer } from 'src/services/notificationMapperService.server';\n\nconst transformNotificationList = async (\n  dbNotifications: DBNotification[],\n  client: SupabaseClient,\n) => {\n  const notificationMapperService = new NotificationMapperServiceServer(client);\n\n  return Promise.allSettled(\n    dbNotifications.map((dbNotification) =>\n      notificationMapperService.transformNotification(dbNotification),\n    ),\n  ).then((results) => {\n    return results.filter((result) => result.status === 'fulfilled').map((result) => result.value);\n  });\n};\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const profileResponse = await client\n      .from('profiles')\n      .select('id')\n      .eq('auth_id', claims.data.claims.sub)\n      .maybeSingle(); // Maybe single due to dup profiles in tests\n\n    if (!profileResponse.data || profileResponse.error) {\n      throw profileResponse.error || 'No user found';\n    }\n\n    const { data, error } = await client\n      .from('notifications')\n      .select(\n        `\n      *,\n      triggered_by:profiles!notifications_triggered_by_id_fkey(id,username,photo)\n    `,\n      )\n      .eq('owned_by_id', profileResponse?.data?.id);\n\n    if (error) {\n      throw error;\n    }\n\n    const notifications = data?.length ? await transformNotificationList(data, client) : [];\n\n    return Response.json({ notifications }, { headers, status: 200 });\n  } catch (error) {\n    console.error(error);\n    return Response.json({ error }, { headers, status: 500 });\n  }\n};\n"
  },
  {
    "path": "src/routes/api.patreon.ts",
    "content": "import { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { PatreonServiceServer } from '../services/patreonService.server';\n\nexport const loader = async ({ request }) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const { data } = await client\n    .from('profiles')\n    .select('patreon,is_supporter')\n    .eq('auth_id', claims.data.claims.sub)\n    .single();\n\n  return Response.json(\n    { isSupporter: data?.is_supporter, patreon: data?.patreon },\n    { headers, status: 200 },\n  );\n};\n\nexport const action = async ({ request }) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  if (request.method !== 'DELETE') {\n    return Response.json({}, { headers, status: 405, statusText: 'method not allowed' });\n  }\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  try {\n    await new PatreonServiceServer(client).disconnectUser(claims.data.claims.sub);\n    new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({}, { headers, status: 200 });\n  } catch (err) {\n    console.error(err);\n    return Response.json({}, { headers, status: 500 });\n  }\n};\n"
  },
  {
    "path": "src/routes/api.profile-badges.ts",
    "content": "import Keyv from 'keyv';\nimport type { DBProfileBadge } from 'oa-shared';\nimport { ProfileBadge } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { isProductionEnvironment } from 'src/config/config';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nconst cache = new Keyv<ProfileBadge[]>({ ttl: 3600000 }); // ttl: 60 minutes\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const cachedProfileBadges = await cache.get('profileBadges');\n\n  if (\n    cachedProfileBadges &&\n    Array.isArray(cachedProfileBadges) &&\n    cachedProfileBadges.length &&\n    isProductionEnvironment()\n  ) {\n    return Response.json(cachedProfileBadges, { headers, status: 200 });\n  }\n\n  const { data } = await client.from('profile_badges').select('*');\n\n  const profileBadges = data?.map((badge) => ProfileBadge.fromDB(badge as DBProfileBadge));\n\n  if (profileBadges && profileBadges.length > 0) {\n    cache.set('profileBadges', profileBadges, 3600000);\n  }\n\n  return Response.json(profileBadges, { headers, status: 200 });\n}\n"
  },
  {
    "path": "src/routes/api.profile-tags.ts",
    "content": "import type { DBProfileTag } from 'oa-shared';\nimport { ProfileTag } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const profileTagsResult = await client\n    .from('profile_tags')\n    .select('id,created_at,name,profile_type');\n\n  const dbTags = (profileTagsResult.data || []) as unknown as DBProfileTag[];\n  const tags = dbTags.map((x) => ProfileTag.fromDB(x));\n\n  return Response.json(tags, { headers, status: 200 });\n}\n"
  },
  {
    "path": "src/routes/api.profile-types.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileTypesServiceServer } from 'src/services/profileTypesService.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const profileService = new ProfileTypesServiceServer(client);\n    const profileTypes = await profileService.get();\n\n    return Response.json(profileTypes, { headers, status: 200 });\n  } catch (error) {\n    console.error(error);\n  }\n\n  return Response.json({}, { headers, status: 500 });\n}\n"
  },
  {
    "path": "src/routes/api.profile.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { DBMedia, ProfileDTO, UserVisitorPreference } from 'oa-shared';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { ProfileFactory } from 'src/factories/profileFactory.server';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { ProfileTypesServiceServer } from 'src/services/profileTypesService.server';\nimport { validationError } from 'src/utils/httpException';\n\nexport const loader = async ({ request }) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const nowUtc = new Date().toISOString();\n\n    const { data, error } = await client\n      .from('profiles')\n      .update({ last_active: nowUtc })\n      .eq('auth_id', claims.data.claims.sub)\n      .select(\n        `*,\n        tags:profile_tags_relations(\n          profile_tags(\n            id,\n            name\n          )\n        ),\n        badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url,\n            premium_tier\n          )\n        ),\n        type:profile_types(\n          id,\n          name,\n          display_name,\n          image_url,\n          small_image_url,\n          description,\n          map_pin_name,\n          is_space\n        )`,\n      )\n      .single();\n\n    if (error) {\n      throw error;\n    }\n\n    const profileFactory = new ProfileFactory(client);\n    const profile = profileFactory.fromDB(data);\n\n    return Response.json(profile, { headers, status: 200 });\n  } catch (error) {\n    console.error(error);\n    return Response.json({ error }, { headers, status: 500 });\n  }\n};\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n    const country = formData.get('country');\n\n    const data = {\n      displayName: String(formData.get('displayName')),\n      about: String(formData.get('about')),\n      country: country === 'null' ? null : String(country),\n      type: String(formData.get('type')),\n      isContactable: formData.get('isContactable') === 'true',\n      showVisitorPolicy: formData.get('showVisitorPolicy') === 'true',\n      visitorPreferenceDetails: formData.get(\n        'visitorPreferenceDetails',\n      ) as UserVisitorPreference['details'],\n      visitorPreferencePolicy: formData.get(\n        'visitorPreferencePolicy',\n      ) as UserVisitorPreference['policy'],\n      coverImages: formData.has('coverImages')\n        ? formData.getAll('coverImages').map((x) => JSON.parse(x as string) as DBMedia)\n        : null,\n      tagIds: formData.has('tagIds') ? formData.getAll('tagIds').map((x) => Number(x)) : null,\n      website: String(formData.get('website')),\n      photo: formData.has('photo')\n        ? (JSON.parse(formData.get('photo') as string) as DBMedia)\n        : null,\n    } satisfies ProfileDTO;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const profileData = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n    const profileTypes = await new ProfileTypesServiceServer(client).get();\n\n    const memberTypes = profileTypes.filter((x) => x.isSpace === false).map((x) => x.name) || null;\n\n    const { valid, status, statusText } = await validateRequest(\n      request,\n      data,\n      profileData,\n      memberTypes,\n    );\n\n    if (!valid) {\n      return Response.json({}, { headers, status, statusText });\n    }\n\n    if (!profileData?.id) {\n      throw validationError('Profile not found', 'id');\n    }\n\n    const profileService = new ProfileServiceServer(client);\n    const profile = await profileService.updateProfile(profileData?.id, data);\n    profileService.updateUserActivity(claims.data.claims.sub);\n\n    return Response.json(profile, { headers, status: 200 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({}, { headers, status: 500 });\n  }\n};\n\nasync function validateRequest(\n  request: Request,\n  data: ProfileDTO,\n  profile: { id: number } | null,\n  memberTypes: string[] | null,\n) {\n  if (request.method !== 'POST') {\n    return { status: 405, statusText: 'method not allowed' };\n  }\n\n  if (!profile?.id) {\n    throw validationError('Profile not found', 'id');\n  }\n\n  if (!data.displayName) {\n    throw validationError('displayName is required', 'displayName');\n  }\n\n  if (!data.type) {\n    throw validationError('type is required', 'type');\n  }\n\n  if (!memberTypes || !memberTypes?.includes(data.type)) {\n    if (!data.photo && !data.photo) {\n      throw validationError('photo is required', 'photo');\n    }\n\n    if (!data.coverImages || data.coverImages.length === 0) {\n      throw validationError('cover images are required', 'coverImages');\n    }\n\n    if (data.showVisitorPolicy && !data.visitorPreferencePolicy) {\n      throw validationError('visitor policy is required', 'visitorPreferencePolicy');\n    }\n  }\n\n  return { valid: true };\n}\n"
  },
  {
    "path": "src/routes/api.profile.username.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { forbiddenError, validationError } from 'src/utils/httpException';\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const body = await request.json();\n    const username = body.username?.trim();\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const profileService = new ProfileServiceServer(client);\n    const profileData = await profileService.getByAuthId(claims.data.claims.sub);\n\n    if (!profileData?.id) {\n      throw validationError('Profile not found', 'id');\n    }\n\n    const isAdmin = (profileData.roles || []).includes('admin');\n    if (profileData.username && !isAdmin) {\n      throw forbiddenError('Username cannot be changed once set');\n    }\n\n    // if username already set and user is not admin, reject (I think this logic will change eventually to let users change)\n    if (!username) {\n      throw validationError('Username is required', 'username');\n    }\n\n    if (/[^a-zA-Z0-9_-]/.test(username)) {\n      throw validationError('Username contains invalid characters', 'username');\n    }\n\n    const usernameCheck = await client.rpc('is_username_available', {\n      username,\n      exclude_profile_id: profileData.id,\n    });\n\n    if (!usernameCheck.data) {\n      throw validationError('Username is already taken', 'username');\n    }\n\n    const profile = await profileService.updateUsername(profileData.id, username);\n\n    return Response.json(profile, { headers, status: 200 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({}, { headers, status: 500 });\n  }\n};\n"
  },
  {
    "path": "src/routes/api.profiles.ts",
    "content": "import type { DBProfile } from 'oa-shared';\nimport { ProfileFactory } from 'src/factories/profileFactory.server';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nexport const loader = async ({ request }) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const url = new URL(request.url);\n  const params = new URLSearchParams(url.search);\n  const q = params.get('q');\n\n  if (!q) {\n    return Response.json({}, { headers, status: 400, statusText: 'q is required' });\n  }\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const { data } = await client\n    .from('profiles')\n    .select(\n      `\n      id,\n      username,\n      display_name,\n      photo,\n      country,\n      created_at,\n      badges:profile_badges_relations(\n        profile_badges(\n          id,\n          name,\n          display_name,\n          image_url,\n          action_url\n        )\n      )`,\n    )\n    .or(`username.ilike.%${q}%,display_name.ilike.%${q}%`)\n    .limit(10);\n  const profileFactory = new ProfileFactory(client);\n\n  const profiles = data?.map((x) => profileFactory.fromDB(x as unknown as DBProfile));\n\n  return Response.json(profiles, { headers, status: 200 });\n};\n"
  },
  {
    "path": "src/routes/api.projects.$id.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport { HTTPException } from 'hono/http-exception';\nimport type {\n  DBMedia,\n  DBProfile,\n  DBProject,\n  DifficultyLevel,\n  IMediaFile,\n  ProjectDTO,\n} from 'oa-shared';\nimport { Project, ProjectStep, UserRole } from 'oa-shared';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { LibraryServiceServer } from 'src/services/libraryService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\nimport { conflictError, methodNotAllowedError, validationError } from 'src/utils/httpException';\nimport { convertToSlug } from 'src/utils/slug';\n\nexport const action = async ({ request, params }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const id = Number(params.id);\n\n    if (request.method === 'DELETE') {\n      return await deleteProject(request, id);\n    }\n\n    const formData = await request.formData();\n\n    const data = {\n      title: formData.get('title') as string,\n      description: formData.get('description') as string,\n      isDraft: formData.get('isDraft') === 'true',\n      time: formData.get('time') as string,\n      category: formData.has('category') ? Number(formData.get('category')) : null,\n      tags: formData.has('tags') ? formData.getAll('tags').map((x) => Number(x)) : null,\n      fileLink: formData.has('fileLink') ? (formData.get('fileLink') as string) : null,\n      coverImage: formData.has('coverImage')\n        ? (JSON.parse(formData.get('coverImage') as string) as DBMedia)\n        : null,\n      files: formData.has('files')\n        ? formData.getAll('files').map((x) => JSON.parse(x as string) as IMediaFile)\n        : null,\n      difficultyLevel: formData.has('difficultyLevel')\n        ? (formData.get('difficultyLevel') as DifficultyLevel)\n        : null,\n      stepCount: parseInt(formData.get('stepCount') as string),\n    } satisfies ProjectDTO;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const profileService = new ProfileServiceServer(client);\n    const libraryService = new LibraryServiceServer(client);\n\n    const currentProject = await libraryService.getById(id);\n    const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n    const slug = convertToSlug(data.title);\n    await validateRequest(request, profile, data, currentProject, slug, client);\n\n    // Remove old cover image if it exists and no new image is provided or a different image is provided\n    if (\n      currentProject.cover_image?.path &&\n      (!data.coverImage || data.coverImage.id !== currentProject.cover_image.id)\n    ) {\n      await new StorageServiceServer(client).removeImages([currentProject.cover_image.path]);\n    }\n\n    // 2. Update project\n\n    const projectDb = await updateProject(client, profile!, currentProject, data, slug);\n    const project = Project.fromDB(projectDb, []);\n    const storage = new StorageServiceServer(client);\n\n    project.coverImage = projectDb.cover_image\n      ? storage.getPublicUrls([projectDb.cover_image])?.at(0) || null\n      : null;\n\n    const existingStepIds = await libraryService.getProjectStepIds(projectDb.id);\n\n    // 3. Upsert Steps\n    const stepsToKeepIds: number[] = [];\n\n    for (let i = 0; i < data.stepCount; i++) {\n      const stepId = formData.has(`steps.[${i}].id`)\n        ? Number(formData.get(`steps.[${i}].id`))\n        : null;\n      if (stepId) {\n        stepsToKeepIds.push(+stepId);\n      }\n\n      const images = formData.has(`steps.[${i}].images`)\n        ? formData.getAll(`steps.[${i}].images`).map((x) => JSON.parse(x as string) as DBMedia)\n        : null;\n\n      const stepDb = await libraryService.upsertStep(\n        projectDb.id,\n        stepId,\n        {\n          title: formData.get(`steps.[${i}].title`) as string,\n          description: formData.get(`steps.[${i}].description`) as string,\n          videoUrl: (formData.get(`steps.[${i}].videoUrl`) as string) || null,\n          images: images,\n        },\n        i + 1,\n      );\n\n      const publicImages = images ? storage.getPublicUrls(images) : undefined;\n      const step = ProjectStep.fromDB(stepDb, publicImages);\n      project.steps.push(step);\n    }\n\n    // delete steps\n    const stepsToDelete = existingStepIds.filter((id) => !stepsToKeepIds.includes(id));\n\n    if (stepsToDelete.length > 0) {\n      await libraryService.deleteStepsById([...stepsToDelete]);\n    }\n\n    profileService.updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ project }, { headers, status: 201 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({ error: 'Error updating project', status: 500 }, { status: 500 });\n  }\n};\n\nasync function validateRequest(\n  request: Request,\n  profile: DBProfile | null,\n  data: ProjectDTO,\n  currentProject: DBProject,\n  slug: string,\n  client: SupabaseClient,\n): Promise<void> {\n  if (!profile) {\n    throw validationError('User not found');\n  }\n  if (!profile.username) {\n    throw validationError('You must set a username before editing content', 'username');\n  }\n  if (request.method !== 'PUT') {\n    throw methodNotAllowedError();\n  }\n\n  if (!data.title) {\n    throw validationError('Title is required', 'title');\n  }\n\n  if (!data.description) {\n    throw validationError('Description is required', 'description');\n  }\n\n  if (!data.isDraft && (!data.stepCount || data.stepCount < 3)) {\n    throw validationError('3 steps are required', 'stepCount');\n  }\n\n  if (\n    currentProject.slug !== slug &&\n    (await new ContentServiceServer(client).isDuplicateExistingSlug(\n      slug,\n      currentProject.id,\n      'projects',\n    ))\n  ) {\n    throw conflictError('A project with this name already exists');\n  }\n}\n\nasync function updateProject(\n  client: SupabaseClient,\n  profile: DBProfile,\n  currentProject: DBProject,\n  data: ProjectDTO,\n  slug: string,\n) {\n  const previousSlugs = ContentServiceServer.updatePreviousSlugs(currentProject, slug);\n\n  let moderation = currentProject.moderation;\n\n  const isFirstPublish = currentProject.is_draft && !data.isDraft && !currentProject.published_at;\n\n  if (currentProject.is_draft && !data.isDraft) {\n    moderation = profile?.roles?.includes(UserRole.ADMIN) ? 'accepted' : 'awaiting-moderation';\n  }\n\n  const now = new Date();\n\n  const projectResult = await client\n    .from('projects')\n    .update({\n      title: data.title,\n      description: data.description,\n      slug,\n      previous_slugs: previousSlugs,\n      category: data.category,\n      tags: data.tags,\n      is_draft: data.isDraft,\n      file_link: data.fileLink,\n      difficulty_level: data.difficultyLevel,\n      time: data.time,\n      files: data.files,\n      moderation,\n      cover_image: data.coverImage || null,\n      modified_at: new Date(),\n      ...(isFirstPublish && { published_at: now }),\n    })\n    .eq('id', currentProject.id)\n    .select();\n\n  if (projectResult.error || !projectResult.data) {\n    throw projectResult.error;\n  }\n\n  return projectResult.data[0] as unknown as DBProject;\n}\n\nasync function deleteProject(request: Request, id: number) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const canEdit = await new LibraryServiceServer(client).isAllowedToEditProjectById(id, profile);\n\n  if (canEdit) {\n    await client\n      .from('projects')\n      .update({\n        modified_at: new Date(),\n        deleted: true,\n      })\n      .eq('id', id);\n\n    return Response.json({}, { status: 200, headers });\n  }\n\n  return Response.json({}, { status: 500, headers });\n}\n"
  },
  {
    "path": "src/routes/api.projects.drafts.count.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({}, { headers, status: 400, statusText: 'invalid user' });\n  }\n\n  const count = await new ContentServiceServer(client).getDraftCount(profile.id, 'projects');\n\n  return Response.json({ total: count }, { headers });\n};\n"
  },
  {
    "path": "src/routes/api.projects.drafts.ts",
    "content": "import type { DBProject } from 'oa-shared';\nimport { Project } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({ items: [], total: 0 }, { headers });\n  }\n\n  const result = await client\n    .from('projects')\n    .select(\n      `       \n        id,\n        created_at,\n        created_by,\n        modified_at,\n        title,\n        description,\n        slug,\n        cover_image,\n        category:category(id,name),\n        tags,\n        moderation,\n        is_draft,\n        author:profiles(id, display_name, username, country, badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url\n          )\n        )),\n        steps:project_steps(\n          id, \n          created_at, \n          title, \n          description\n        )\n      `,\n    )\n    .or('deleted.eq.false,deleted.is.null')\n    .eq('is_draft', true)\n    .or(`created_by.eq.${profile.id}`);\n\n  if (result.error) {\n    console.error(result.error);\n    return Response.json({}, { headers, status: 500 });\n  }\n\n  if (!result.data || result.data.length === 0) {\n    return Response.json({ items: [] }, { headers });\n  }\n\n  const drafts = result.data as unknown as DBProject[];\n  const items = drafts.map((x) => {\n    const images = x.cover_image\n      ? new StorageServiceServer(client).getPublicUrls([x.cover_image], IMAGE_SIZES.LIST)\n      : [];\n\n    return Project.fromDB(x, [], images);\n  });\n\n  return Response.json({ items }, { headers });\n}\n"
  },
  {
    "path": "src/routes/api.projects.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport { HTTPException } from 'hono/http-exception';\nimport type {\n  DBMedia,\n  DBProfile,\n  DBProject,\n  DBProjectStep,\n  DifficultyLevel,\n  IMediaFile,\n  Moderation,\n  ProjectDTO,\n  ProjectStepDTO,\n} from 'oa-shared';\nimport { Project, ProjectStep, UserRole } from 'oa-shared';\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport type { LibrarySortOption } from 'src/pages/Library/Content/List/LibrarySortOptions';\nimport { ITEMS_PER_PAGE } from 'src/pages/Library/constants';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\nimport { SubscribersServiceServer } from 'src/services/subscribersService.server';\nimport { conflictError, methodNotAllowedError, validationError } from 'src/utils/httpException';\nimport { convertToSlug } from 'src/utils/slug';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const url = new URL(request.url);\n  const searchParams = new URLSearchParams(url.search);\n  const q = searchParams.get('q');\n  const category = Number(searchParams.get('category')) || undefined;\n  const sort = searchParams.get('sort') as LibrarySortOption;\n  const skip = Number(searchParams.get('skip')) || 0;\n\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n\n  let username: string | null = null;\n  if (claims.data?.claims) {\n    const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n    username = profile?.username || null;\n  }\n\n  const { data, error } = await client.rpc('get_projects', {\n    search_query: q || null,\n    category_id: category,\n    sort_by: sort,\n    offset_val: skip,\n    limit_val: ITEMS_PER_PAGE,\n    current_username: username,\n  });\n\n  const countResult = await client.rpc('get_projects_count', {\n    search_query: q || null,\n    category_id: category,\n    current_username: username,\n  });\n  const count = countResult.data || 0;\n\n  if (error) {\n    console.error(error);\n    return Response.json({}, { status: 500, headers });\n  }\n\n  const dbItems = data as DBProject[];\n  const items = dbItems.map((x) => {\n    const images = x.cover_image\n      ? new StorageServiceServer(client).getPublicUrls([x.cover_image], IMAGE_SIZES.LIST)\n      : [];\n    return Project.fromDB(x, [], images);\n  });\n\n  if (items && items.length > 0) {\n    // Populate useful votes\n    const votes = await client.rpc('get_useful_votes_count_by_content_id', {\n      p_content_type: 'projects',\n      p_content_ids: items.map((x) => x.id),\n    });\n\n    if (votes.data) {\n      const votesByContentId = votes.data.reduce((acc, current) => {\n        acc.set(current.content_id, current.count);\n        return acc;\n      }, new Map());\n\n      for (const item of items) {\n        if (votesByContentId.has(item.id)) {\n          item.usefulCount = votesByContentId.get(item.id)!;\n        }\n      }\n    }\n  }\n\n  return Response.json({ items, total: count }, { headers });\n};\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n    const data = {\n      title: formData.get('title') as string,\n      description: formData.get('description') as string,\n      time: formData.get('time') as string,\n      category: formData.has('category') ? Number(formData.get('category')) : null,\n      tags: formData.has('tags') ? formData.getAll('tags').map((x) => Number(x)) : null,\n      fileLink: formData.has('fileLink') ? (formData.get('fileLink') as string) : null,\n      difficultyLevel: formData.has('difficultyLevel')\n        ? (formData.get('difficultyLevel') as DifficultyLevel)\n        : null,\n      isDraft: formData.get('isDraft') === 'true',\n      stepCount: parseInt(formData.get('stepCount') as string),\n      coverImage: formData.has('coverImage')\n        ? (JSON.parse(formData.get('coverImage') as string) as DBMedia)\n        : null,\n      files: formData.has('files')\n        ? formData.getAll('files').map((x) => JSON.parse(x as string) as IMediaFile)\n        : null,\n    } satisfies ProjectDTO;\n\n    const slug = convertToSlug((formData.get('title') as string) || '');\n    let moderation = data.isDraft ? null : ('awaiting-moderation' as Moderation);\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    await validateRequest(request, data, slug, client);\n\n    const profileService = new ProfileServiceServer(client);\n    const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n    if (!data.isDraft && profile?.roles?.includes(UserRole.ADMIN)) {\n      moderation = 'accepted';\n    }\n\n    if (!profile) {\n      return Response.json({}, { headers, status: 400, statusText: 'User not found' });\n    }\n\n    if (!profile.username) {\n      return Response.json(\n        { error: 'You must set a username before creating content' },\n        { headers, status: 403 },\n      );\n    }\n\n    const projectDb = await createProject(client, data, slug, moderation, profile);\n    const project = Project.fromDB(projectDb, []);\n\n    project.coverImage = projectDb.cover_image\n      ? new StorageServiceServer(client).getPublicUrls([projectDb.cover_image])?.at(0) || null\n      : null;\n\n    project.steps = await uploadSteps(data, formData, projectDb, client);\n    new SubscribersServiceServer(client).add('projects', project.id, profile.id);\n\n    profileService.updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ project }, { headers, status: 201 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({ error: 'Error creating project', status: 500 }, { status: 500 });\n  }\n};\n\nasync function uploadSteps(data, formData: FormData, projectDb: DBProject, client: SupabaseClient) {\n  const steps: ProjectStep[] = [];\n  const storage = new StorageServiceServer(client);\n\n  for (let i = 0; i < data.stepCount; i++) {\n    const stepImages = formData.has(`steps.[${i}].images`)\n      ? formData.getAll(`steps.[${i}].images`).map((x) => JSON.parse(x as string) as DBMedia)\n      : null;\n\n    const stepDb = await createStep(\n      client,\n      projectDb.id,\n      {\n        title: formData.get(`steps.[${i}].title`) as string,\n        description: formData.get(`steps.[${i}].description`) as string,\n        videoUrl: (formData.get(`steps.[${i}].videoUrl`) as string) || null,\n        images: stepImages,\n      },\n      i + 1,\n    );\n\n    const publicImages = stepImages ? storage.getPublicUrls(stepImages) : undefined;\n    const step = ProjectStep.fromDB(stepDb, publicImages);\n\n    steps.push(step);\n  }\n\n  return steps;\n}\n\nasync function validateRequest(\n  request: Request,\n  data: ProjectDTO,\n  slug: string,\n  client: SupabaseClient,\n): Promise<void> {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n\n  if (!data.title) {\n    throw validationError('Title is required', 'title');\n  } else if (data.title.length < 5) {\n    throw validationError('Title is too short', 'title');\n  }\n\n  if (!data.description) {\n    throw validationError('Description is required', 'description');\n  }\n\n  if (!data.isDraft && (!data.stepCount || data.stepCount < 3)) {\n    throw validationError('3 steps are required', 'stepCount');\n  }\n\n  if (await new ContentServiceServer(client).isDuplicateNewSlug(slug, 'projects')) {\n    throw conflictError('A project with this name already exists');\n  }\n\n  // No need to validate cover image since it's uploaded immediately via /api/images\n}\n\nasync function createProject(\n  client: SupabaseClient,\n  data: ProjectDTO,\n  slug: string,\n  moderation: Moderation | null,\n  profile: DBProfile,\n) {\n  const projectResult = await client\n    .from('projects')\n    .insert({\n      created_by: profile.id,\n      title: data.title,\n      description: data.description,\n      slug: slug,\n      category: data.category,\n      tags: data.tags,\n      is_draft: data.isDraft,\n      published_at: data.isDraft ? null : new Date(),\n      file_link: data.fileLink,\n      difficulty_level: data.difficultyLevel,\n      time: data.time,\n      files: data.files,\n      cover_image: data.coverImage,\n      moderation: moderation,\n      tenant_id: process.env.TENANT_ID,\n    })\n    .select();\n\n  if (projectResult.error || !projectResult.data) {\n    throw projectResult.error;\n  }\n\n  return projectResult.data[0] as unknown as DBProject;\n}\n\nasync function createStep(\n  client: SupabaseClient,\n  projectId: number,\n  values: ProjectStepDTO,\n  order: number,\n) {\n  const { data, error } = await client\n    .from('project_steps')\n    .insert({\n      title: values.title,\n      description: values.description,\n      project_id: projectId,\n      video_url: values.videoUrl,\n      images: values.images,\n      order: order,\n      tenant_id: process.env.TENANT_ID,\n    })\n    .select();\n\n  if (error || !data) {\n    throw error;\n  }\n\n  return data[0] as unknown as DBProjectStep;\n}\n"
  },
  {
    "path": "src/routes/api.questions.$id.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport { HTTPException } from 'hono/http-exception';\nimport type { DBMedia, DBQuestion, QuestionDTO } from 'oa-shared';\nimport { Question } from 'oa-shared';\nimport type { LoaderFunctionArgs, Params } from 'react-router';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { QuestionServiceServer } from 'src/services/questionService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\nimport { hasAdminRights } from 'src/utils/helpers';\nimport {\n  conflictError,\n  forbiddenError,\n  methodNotAllowedError,\n  validationError,\n} from 'src/utils/httpException';\nimport { convertToSlug } from 'src/utils/slug';\nimport { ContentServiceServer } from '../services/contentService.server';\n\nexport const action = async ({ request, params }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const id = Number(params.id);\n\n    const formData = await request.formData();\n\n    const data = {\n      title: formData.get('title') as string,\n      description: formData.get('description') as string,\n      category: formData.has('category') ? Number(formData.get('category')) : null,\n      isDraft: formData.get('isDraft') === 'true',\n      tags: formData.has('tags') ? formData.getAll('tags').map((x) => Number(x)) : null,\n      images: formData.has('images')\n        ? formData.getAll('images').map((x) => JSON.parse(x as string) as DBMedia)\n        : null,\n    } satisfies QuestionDTO;\n\n    const slug = convertToSlug(data.title);\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const currentQuestion = await new QuestionServiceServer(client).getById(id);\n\n    await validateRequest(params, request, claims.data.claims.sub, data, currentQuestion, client);\n\n    const previousSlugs = ContentServiceServer.updatePreviousSlugs(currentQuestion, slug);\n\n    const isFirstPublish =\n      currentQuestion.is_draft && !data.isDraft && !currentQuestion.published_at;\n\n    const now = new Date();\n\n    const questionResult = await client\n      .from('questions')\n      .update({\n        category: data.category,\n        description: data.description,\n        is_draft: data.isDraft,\n        images: data.images,\n        title: data.title,\n        slug,\n        previous_slugs: previousSlugs,\n        tags: data.tags,\n        modified_at: now,\n        ...(isFirstPublish && { published_at: now }),\n      })\n      .eq('id', params.id)\n      .select();\n\n    if (questionResult.error || !questionResult.data) {\n      throw questionResult.error;\n    }\n\n    const newImages = new StorageServiceServer(client).getPublicUrls(\n      questionResult.data[0].images,\n      IMAGE_SIZES.GALLERY,\n    );\n\n    const question = Question.fromDB(questionResult.data[0], [], newImages);\n    new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ question }, { headers, status: 200 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({ error: 'Error updating question' }, { headers, status: 500 });\n  }\n};\n\nasync function validateRequest(\n  params: Params<string>,\n  request: Request,\n  userAuthId: string,\n  data: QuestionDTO,\n  currentQuestion: DBQuestion,\n  client: SupabaseClient,\n) {\n  if (request.method !== 'PUT') {\n    throw methodNotAllowedError();\n  }\n\n  if (!params.id) {\n    throw validationError('ID is required', 'id');\n  }\n\n  if (!data.title) {\n    throw validationError('Title is required', 'title');\n  }\n\n  if (!data.description) {\n    throw validationError('Description is required', 'description');\n  }\n\n  if (!currentQuestion) {\n    throw validationError('Question not found');\n  }\n\n  const slug = convertToSlug(data.title);\n\n  if (\n    currentQuestion.slug !== slug &&\n    (await new ContentServiceServer(client).isDuplicateExistingSlug(\n      slug,\n      currentQuestion.id,\n      'questions',\n    ))\n  ) {\n    throw conflictError('This question already exists');\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(userAuthId);\n\n  if (!profile) {\n    throw validationError('User not found');\n  }\n\n  if (!profile.username) {\n    throw validationError('You must set a username before editing content', 'username');\n  }\n\n  const isCreator = currentQuestion.created_by === profile.id;\n\n  if (!isCreator && !hasAdminRights(profile)) {\n    throw forbiddenError();\n  }\n}\n"
  },
  {
    "path": "src/routes/api.questions.drafts.count.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({}, { headers, status: 400, statusText: 'invalid user' });\n  }\n\n  const count = await new ContentServiceServer(client).getDraftCount(profile.id, 'questions');\n\n  return Response.json({ total: count }, { headers });\n};\n"
  },
  {
    "path": "src/routes/api.questions.drafts.ts",
    "content": "import type { DBQuestion } from 'oa-shared';\nimport { Question } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({ items: [], total: 0 }, { headers });\n  }\n\n  const result = await client\n    .from('questions')\n    .select(\n      `\n        id,\n        created_at,\n        created_by,\n        modified_at,\n        title,\n        slug,\n        category:category(id,name),\n        is_draft,\n        comment_count,\n        author:profiles(id, display_name, username, country, badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url\n          )\n        ))\n  `,\n    )\n    .or('deleted.eq.false,deleted.is.null')\n    .eq('is_draft', true)\n    .or(`created_by.eq.${profile.id}`);\n\n  if (result.error) {\n    console.error(result.error);\n    return Response.json({}, { headers, status: 500 });\n  }\n\n  if (!result.data || result.data.length === 0) {\n    return Response.json({ items: [] }, { headers });\n  }\n\n  const drafts = result.data as unknown as DBQuestion[];\n  const items = drafts.map((x) => {\n    const images = x.images\n      ? new StorageServiceServer(client).getPublicUrls(x.images, IMAGE_SIZES.LIST)\n      : [];\n\n    return Question.fromDB(x, [], images);\n  });\n\n  return Response.json({ items }, { headers });\n}\n"
  },
  {
    "path": "src/routes/api.questions.ts",
    "content": "// TODO: split this in separate files once we update remix to NOT use file-based routing\n\nimport { HTTPException } from 'hono/http-exception';\nimport type { DBMedia, DBProfile, DBQuestion, Moderation, QuestionDTO } from 'oa-shared';\nimport { Question } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { ITEMS_PER_PAGE } from 'src/pages/Question/constants';\nimport type { QuestionSortOption } from 'src/pages/Question/QuestionSortOptions';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { discordServiceServer } from 'src/services/discordService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { SubscribersServiceServer } from 'src/services/subscribersService.server';\nimport { conflictError, methodNotAllowedError, validationError } from 'src/utils/httpException';\nimport { convertToSlug } from 'src/utils/slug';\n\nexport const loader = async ({ request }) => {\n  const url = new URL(request.url);\n  const params = new URLSearchParams(url.search);\n  const q = params.get('q');\n  const category = Number(params.get('category')) || undefined;\n  const sort = params.get('sort') as QuestionSortOption;\n  const skip = Number(params.get('skip')) || 0;\n\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const { data, error } = await client.rpc('get_questions', {\n    search_query: q || null,\n    category_id: category,\n    sort_by: sort,\n    offset_val: skip,\n    limit_val: ITEMS_PER_PAGE,\n  });\n\n  const countResult = await client.rpc('get_questions_count', {\n    search_query: q || null,\n    category_id: category,\n  });\n\n  if (error) {\n    console.error(error);\n    return Response.json({}, { status: 500, headers });\n  }\n\n  const total = countResult.data || 0;\n\n  const dbItems = data as DBQuestion[];\n\n  if (!dbItems || dbItems.length === 0) {\n    return Response.json({ items: [], total: 0 }, { headers });\n  }\n\n  const items = dbItems.map((x) => Question.fromDB(x, [], []));\n\n  if (items && items.length > 0) {\n    // Populate useful votes\n    const votes = await client.rpc('get_useful_votes_count_by_content_id', {\n      p_content_type: 'questions',\n      p_content_ids: items.map((x) => x.id),\n    });\n\n    if (votes.data) {\n      const votesByContentId = votes.data.reduce((acc, current) => {\n        acc.set(current.content_id, current.count);\n        return acc;\n      }, new Map());\n\n      for (const item of items) {\n        if (votesByContentId.has(item.id)) {\n          item.usefulCount = votesByContentId.get(item.id)!;\n        }\n      }\n    }\n  }\n\n  return Response.json({ items, total }, { headers });\n};\n\nexport const action = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n    const data = {\n      title: formData.get('title') as string,\n      description: formData.get('description') as string,\n      isDraft: formData.get('isDraft') === 'true',\n      category: formData.has('category') ? Number(formData.get('category')) : null,\n      tags: formData.has('tags') ? formData.getAll('tags').map((x) => Number(x)) : null,\n      images: formData.has('images')\n        ? formData.getAll('images').map((x) => JSON.parse(x as string) as DBMedia)\n        : null,\n    } satisfies QuestionDTO;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n    await validateRequest(request, data);\n\n    const slug = convertToSlug(data.title);\n\n    if (await new ContentServiceServer(client).isDuplicateNewSlug(slug, 'questions')) {\n      throw conflictError('This question already exists');\n    }\n\n    const profileRequest = await client\n      .from('profiles')\n      .select('id,username')\n      .eq('auth_id', claims.data.claims.sub)\n      .limit(1);\n\n    if (profileRequest.error || !profileRequest.data?.at(0)) {\n      console.error(profileRequest.error);\n      throw validationError('User not found');\n    }\n\n    const profile = profileRequest.data[0] as DBProfile;\n\n    if (!profile.username) {\n      throw validationError('You must set a username before creating content', 'username');\n    }\n\n    const questionResult = await client\n      .from('questions')\n      .insert({\n        created_by: profile.id,\n        title: data.title,\n        description: data.description,\n        is_draft: data.isDraft,\n        moderation: 'accepted' as Moderation,\n        images: data.images,\n        published_at: data.isDraft ? null : new Date(),\n        slug,\n        category: data.category,\n        tags: data.tags,\n        tenant_id: process.env.TENANT_ID,\n      })\n      .select();\n\n    if (questionResult.error || !questionResult.data) {\n      throw new Error(questionResult.error?.details || 'Error creating question');\n    }\n\n    const question = Question.fromDB(questionResult.data[0], []);\n    new SubscribersServiceServer(client).add('questions', question.id, profile.id);\n\n    if (!question.isDraft) {\n      notifyDiscord(question, profile, new URL(request.url).origin.replace('http:', 'https:'));\n    }\n\n    new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ question }, { headers, status: 201 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n\n    return Response.json({ error: 'Error creating question' }, { headers, status: 500 });\n  }\n};\n\nfunction notifyDiscord(question: Question, profile: DBProfile, siteUrl: string) {\n  const title = question.title;\n  const slug = question.slug;\n\n  if (!profile?.username) {\n    console.warn(`Profile ${profile?.id} has no username, skipping Discord notification`);\n    return;\n  }\n\n  discordServiceServer.postWebhookRequest(\n    `❓ ${profile.username} has a new question: ${title}\\nHelp them out and answer here: <${siteUrl}/questions/${slug}>`,\n  );\n}\n\nasync function validateRequest(request: Request, data: QuestionDTO) {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n\n  if (!data.title) {\n    throw validationError('Title is required', 'title');\n  }\n\n  if (!data.description) {\n    throw validationError('Description is required', 'description');\n  }\n}\n"
  },
  {
    "path": "src/routes/api.research.$id.status.ts",
    "content": "import type { ResearchStatus } from 'oa-shared';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { ResearchServiceServer } from 'src/services/researchService.server';\n\nexport const action = async ({ request, params }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const id = Number(params.id);\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const formData = await request.formData();\n\n    const data = {\n      status: formData.get('status') as ResearchStatus,\n    };\n\n    const { valid, status, statusText } = await validateRequest(request, data);\n\n    if (!valid) {\n      return Response.json({}, { headers, status, statusText });\n    }\n\n    const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n\n    if (!profile) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const canEdit = await new ResearchServiceServer(client).isAllowedToEditResearchById(\n      id,\n      profile,\n    );\n\n    if (!canEdit) {\n      return Response.json(null, { headers, status: 403 });\n    }\n\n    const result = await client.from('research').update({ status: data.status }).eq('id', id);\n\n    if (result.error) {\n      throw result.error;\n    }\n\n    new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json(null, { headers, status: 200 });\n  } catch (error) {\n    console.error(error);\n    return Response.json({}, { headers, status: 500, statusText: 'Error creating research' });\n  }\n};\n\nasync function validateRequest(request: Request, data: { status: ResearchStatus }) {\n  if (request.method !== 'PATCH') {\n    return { status: 405, statusText: 'method not allowed' };\n  }\n\n  if (data.status !== 'complete' && data.status !== 'in-progress') {\n    return { status: 400, statusText: 'invalid status' };\n  }\n\n  return { valid: true };\n}\n"
  },
  {
    "path": "src/routes/api.research.$id.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport { HTTPException } from 'hono/http-exception';\nimport type { DBMedia, DBResearchItem, ResearchDTO } from 'oa-shared';\nimport { ResearchItem, UserRole } from 'oa-shared';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { ResearchServiceServer } from 'src/services/researchService.server';\nimport { SubscribersServiceServer } from 'src/services/subscribersService.server';\nimport {\n  conflictError,\n  forbiddenError,\n  methodNotAllowedError,\n  validationError,\n} from 'src/utils/httpException';\nimport { convertToSlug } from 'src/utils/slug';\n\nexport const action = async ({ request, params }: ActionFunctionArgs) => {\n  const id = Number(params.id);\n\n  if (request.method === 'DELETE') {\n    return await deleteResearch(request, id);\n  }\n\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n    const data = {\n      title: formData.get('title') as string,\n      description: formData.get('description') as string,\n      category: formData.has('category') ? Number(formData.get('category')) : null,\n      tags: formData.has('tags') ? formData.getAll('tags').map((x) => Number(x)) : null,\n      collaborators: formData.has('collaborators')\n        ? (formData.getAll('collaborators') as string[])\n        : null,\n      isDraft: formData.get('isDraft') === 'true',\n      coverImage: formData.has('coverImage')\n        ? (JSON.parse(formData.get('coverImage') as string) as DBMedia)\n        : null,\n    } satisfies ResearchDTO;\n\n    const slug = convertToSlug(data.title);\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const oldResearch = await new ResearchServiceServer(client).getById(id);\n\n    await validateRequest(request, claims.data.claims.sub, data, oldResearch, slug, client);\n\n    const previousSlugs = ContentServiceServer.updatePreviousSlugs(oldResearch, slug);\n\n    const isFirstPublish = oldResearch.is_draft && !data.isDraft && !oldResearch.published_at;\n\n    const researchResult = await client\n      .from('research')\n      .update({\n        title: data.title,\n        description: data.description,\n        slug,\n        category: data.category,\n        tags: data.tags,\n        previous_slugs: previousSlugs,\n        is_draft: data.isDraft,\n        collaborators: data.collaborators,\n        image: data.coverImage,\n        modified_at: new Date(),\n        ...(isFirstPublish && { published_at: new Date() }),\n      })\n      .eq('id', id)\n      .select()\n      .single();\n\n    if (researchResult.error || !researchResult.data) {\n      throw researchResult.error;\n    }\n\n    const research = ResearchItem.fromDB(researchResult.data, []);\n\n    await new SubscribersServiceServer(client).updateResearchSubscribers(oldResearch, research);\n    new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ research }, { headers, status: 201 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({ error: 'Error updating research', status: 500 }, { status: 500 });\n  }\n};\n\nasync function deleteResearch(request, id: number) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const profile = await new ProfileServiceServer(client).getByAuthId(claims.data.claims.sub);\n\n    if (!profile) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const canEdit = await new ResearchServiceServer(client).isAllowedToEditResearchById(\n      id,\n      profile,\n    );\n\n    if (canEdit) {\n      await client\n        .from('research')\n        .update({\n          modified_at: new Date(),\n          deleted: true,\n        })\n        .eq('id', id);\n\n      return Response.json({}, { status: 200, headers });\n    }\n  } catch (error) {\n    console.error('Delete research error:', error);\n  }\n\n  return Response.json({}, { status: 500, headers });\n}\n\nasync function validateRequest(\n  request: Request,\n  userAuthId: string,\n  data: ResearchDTO,\n  research: DBResearchItem,\n  slug: string,\n  client: SupabaseClient,\n): Promise<void> {\n  if (request.method !== 'PUT') {\n    throw methodNotAllowedError();\n  }\n\n  if (!data.title) {\n    throw validationError('Title is required', 'title');\n  }\n\n  if (!data.description) {\n    throw validationError('Description is required', 'description');\n  }\n\n  if (!data.isDraft && !data.coverImage) {\n    throw validationError('Cover image is required', 'image');\n  }\n\n  if (\n    research.slug !== slug &&\n    (await new ContentServiceServer(client).isDuplicateExistingSlug(slug, research.id, 'research'))\n  ) {\n    throw conflictError('This research already exists');\n  }\n\n  const profile = await new ProfileServiceServer(client).getByAuthId(userAuthId);\n\n  if (!profile) {\n    throw validationError('User not found');\n  }\n\n  if (!profile.username) {\n    throw validationError('You must set a username before editing content', 'username');\n  }\n\n  if (profile.roles?.includes(UserRole.ADMIN)) {\n    return;\n  }\n\n  if (\n    research.created_by !== profile.id &&\n    !(profile.username && research.collaborators?.includes(profile.username))\n  ) {\n    throw forbiddenError();\n  }\n}\n"
  },
  {
    "path": "src/routes/api.research.$id.updates.$updateId.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { DBMedia, DBResearchUpdate, IMediaFile, ResearchUpdateDTO } from 'oa-shared';\nimport { ResearchUpdate } from 'oa-shared';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { BroadcastCoordinationServiceServer } from 'src/services/broadcastCoordinationService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { ResearchServiceServer } from 'src/services/researchService.server';\nimport { forbiddenError, methodNotAllowedError, validationError } from 'src/utils/httpException';\n\nexport const action = async ({ request, params }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const researchId = Number(params.id);\n    const updateId = Number(params.updateId);\n\n    if (request.method === 'DELETE') {\n      return await deleteResearchUpdate(request, researchId, updateId);\n    }\n\n    const formData = await request.formData();\n\n    const data = {\n      title: formData.get('title') as string,\n      description: formData.get('description') as string,\n      videoUrl: formData.get('videoUrl') as string,\n      images: formData.has('images')\n        ? formData.getAll('images').map((x) => JSON.parse(x as string) as DBMedia)\n        : null,\n      files: formData.has('files')\n        ? formData.getAll('files').map((x) => JSON.parse(x as string) as IMediaFile)\n        : null,\n      fileLink: formData.get('fileLink') as string,\n      isDraft: formData.get('isDraft') === 'true',\n    } satisfies ResearchUpdateDTO;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    validateRequest(request, data);\n\n    const profileService = new ProfileServiceServer(client);\n    const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n    if (!new ResearchServiceServer(client).isAllowedToEditUpdate(profile, researchId, updateId)) {\n      throw forbiddenError();\n    }\n\n    const researchUpdateResult = await client\n      .from('research_updates')\n      .select()\n      .eq('id', updateId)\n      .single();\n\n    const oldResearchUpdate = researchUpdateResult.data as DBResearchUpdate;\n    const isFirstPublish =\n      oldResearchUpdate.is_draft && !data.isDraft && !oldResearchUpdate.published_at;\n\n    const researchUpdateAfterUpdating = await client\n      .from('research_updates')\n      .update({\n        title: data.title,\n        description: data.description,\n        is_draft: data.isDraft,\n        images: data.images,\n        modified_at: new Date(),\n        video_url: data.videoUrl,\n        files: data.files,\n        ...(isFirstPublish && { published_at: new Date() }),\n      })\n      .eq('id', oldResearchUpdate.id)\n      .select('*,research:research(id,title,slug,is_draft)')\n      .single();\n\n    if (researchUpdateAfterUpdating.error || !researchUpdateAfterUpdating.data) {\n      throw researchUpdateAfterUpdating.error;\n    }\n\n    const researchUpdate = ResearchUpdate.fromDB(researchUpdateAfterUpdating.data, []);\n    researchUpdate.research = researchUpdateAfterUpdating.data.research;\n\n    new BroadcastCoordinationServiceServer(client).researchUpdate(\n      researchUpdate,\n      profile,\n      request,\n      oldResearchUpdate,\n    );\n    profileService.updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ researchUpdate }, { headers, status: 201 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n    console.error(error);\n    return Response.json({}, { headers, status: 500, statusText: 'Error creating research' });\n  }\n};\n\nasync function deleteResearchUpdate(request: Request, id: number, updateId: number) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n  if (!new ResearchServiceServer(client).isAllowedToEditUpdate(profile, id, updateId)) {\n    return Response.json({}, { status: 403, headers });\n  }\n\n  await client\n    .from('research_updates')\n    .update({\n      modified_at: new Date(),\n      deleted: true,\n    })\n    .eq('id', updateId);\n\n  return Response.json({}, { status: 200, headers });\n}\n\nfunction validateRequest(request: Request, data: ResearchUpdateDTO) {\n  if (request.method !== 'PUT') {\n    throw methodNotAllowedError();\n  }\n\n  if (!data.title) {\n    throw validationError('title is required', 'title');\n  }\n\n  if (!data.description) {\n    throw validationError('description is required', 'description');\n  }\n\n  if (!data.images && !data.videoUrl) {\n    throw validationError('images or video URL are required', 'images');\n  }\n}\n"
  },
  {
    "path": "src/routes/api.research.$id.updates.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { DBMedia, DBProfile, DBResearchItem, IMediaFile, ResearchUpdateDTO } from 'oa-shared';\nimport { ResearchUpdate, UserRole } from 'oa-shared';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { BroadcastCoordinationServiceServer } from 'src/services/broadcastCoordinationService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { SubscribersServiceServer } from 'src/services/subscribersService.server';\nimport {\n  forbiddenError,\n  methodNotAllowedError,\n  unauthorizedError,\n  validationError,\n} from 'src/utils/httpException';\n\nexport const action = async ({ request, params }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const researchId = Number(params.id);\n    const formData = await request.formData();\n    const data = {\n      title: formData.get('title') as string,\n      description: formData.get('description') as string,\n      videoUrl: formData.get('videoUrl') as string,\n      images: formData.has('images')\n        ? formData.getAll('images').map((x) => JSON.parse(x as string) as DBMedia)\n        : null,\n      files: formData.has('files')\n        ? formData.getAll('files').map((x) => JSON.parse(x as string) as IMediaFile)\n        : null,\n      fileLink: formData.get('fileLink') as string,\n      isDraft: formData.get('isDraft') === 'true',\n    } satisfies ResearchUpdateDTO;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      throw unauthorizedError();\n    }\n\n    const researchResult = await client\n      .from('research')\n      .select('id,title,slug,collaborators,author:profiles(id, username)')\n      .eq('id', researchId)\n      .single();\n    const research = researchResult.data as unknown as DBResearchItem;\n    const profileService = new ProfileServiceServer(client);\n    const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n    if (!profile) {\n      throw validationError('User not found', 'profile');\n    }\n\n    if (!profile.username) {\n      throw forbiddenError('You must set a username before creating content');\n    }\n\n    validateRequest(request, data, research, profile);\n\n    const updateResult = await client\n      .from('research_updates')\n      .insert({\n        title: data.title,\n        description: data.description,\n        video_url: data.videoUrl,\n        images: data.images,\n        files: data.files,\n        is_draft: data.isDraft,\n        published_at: data.isDraft ? null : new Date(),\n        research_id: researchId,\n        created_by: profile.id,\n        tenant_id: process.env.TENANT_ID,\n      })\n      .select('*,research:research(id,title,collaborators,created_by,is_draft,slug)')\n      .single();\n\n    if (updateResult.error || !updateResult.data) {\n      throw updateResult.error;\n    }\n\n    const dbResearchUpdate = updateResult.data;\n    const researchUpdate = ResearchUpdate.fromDB(dbResearchUpdate, []);\n    researchUpdate.research = updateResult.data.research;\n\n    await new SubscribersServiceServer(client).addResearchUpdateSubscribers(\n      researchUpdate,\n      profile.id,\n    );\n\n    new BroadcastCoordinationServiceServer(client).researchUpdate(researchUpdate, profile, request);\n    profileService.updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ researchUpdate }, { headers, status: 201 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({}, { headers, status: 500, statusText: 'Error creating research' });\n  }\n};\n\nfunction validateRequest(\n  request: Request,\n  data: ResearchUpdateDTO,\n  research: DBResearchItem | null,\n  profile: DBProfile | null,\n) {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n\n  if (!data.title) {\n    throw validationError('title is required', 'title');\n  }\n\n  if (!data.description) {\n    throw validationError('description is required', 'description');\n  }\n\n  if (!data.isDraft && !data.images && !data.videoUrl) {\n    throw validationError('images or video URL are required', 'images');\n  }\n\n  if (!research) {\n    throw validationError('Research not found', 'research');\n  }\n\n  if (!profile) {\n    throw validationError('User not found', 'profile');\n  }\n\n  if (\n    profile.id !== research.author?.id &&\n    !(profile.username && research.collaborators?.includes(profile.username)) &&\n    !profile.roles?.includes(UserRole.ADMIN)\n  ) {\n    throw forbiddenError('You do not have permission to add updates to this research');\n  }\n}\n"
  },
  {
    "path": "src/routes/api.research.drafts.count.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({}, { headers, status: 400, statusText: 'invalid user' });\n  }\n\n  const count = await new ContentServiceServer(client).getDraftCount(profile.id, 'research');\n\n  return Response.json({ total: count }, { headers });\n};\n"
  },
  {
    "path": "src/routes/api.research.drafts.ts",
    "content": "import type { DBResearchItem } from 'oa-shared';\nimport { ResearchItem } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n  const profileService = new ProfileServiceServer(client);\n  const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n  if (!profile) {\n    return Response.json({ items: [], total: 0 }, { headers });\n  }\n\n  const result = await client\n    .from('research')\n    .select(\n      `       \n        id,\n       created_at,\n       created_by,\n       modified_at,\n       title,\n       description,\n       slug,\n       image,\n       category:category(id,name),\n       tags,\n       total_views,\n       status,\n       is_draft,\n       collaborators,\n       author:profiles(id, display_name, username, country, badges:profile_badges_relations(\n        profile_badges(\n          id,\n          name,\n          display_name,\n          image_url,\n          action_url\n        )\n      )),\n       updates:research_updates(\n        id, \n        created_at, \n        title, \n        description, \n        images, \n        files, \n        file_link, \n        file_download_count, \n        video_url, \n        is_draft, \n        comment_count, \n        modified_at, \n        deleted,\n        is_draft,\n        update_author:profiles(id, display_name, username, photo, country, badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url\n          )\n        ))\n      )\n      `,\n    )\n    .or('deleted.eq.false,deleted.is.null')\n    .eq('is_draft', true)\n    .or(\n      `created_by.eq.${profile.id}${profile.username ? `,collaborators.cs.{${profile.username}}` : ''}`,\n    );\n\n  if (result.error) {\n    console.error(result.error);\n    return Response.json({}, { headers, status: 500 });\n  }\n\n  if (!result.data || result.data.length === 0) {\n    return Response.json({ items: [] }, { headers });\n  }\n\n  const storage = new StorageServiceServer(client);\n\n  const drafts = result.data as unknown as DBResearchItem[];\n  const items = drafts.map((x) => {\n    const images = x.image ? storage.getPublicUrls([x.image], IMAGE_SIZES.LIST) : [];\n\n    return ResearchItem.fromDB(x, [], images);\n  });\n\n  return Response.json({ items }, { headers });\n}\n"
  },
  {
    "path": "src/routes/api.research.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { DBMedia, DBResearchItem, ResearchDTO, ResearchStatus } from 'oa-shared';\nimport { ResearchItem } from 'oa-shared';\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { ITEMS_PER_PAGE } from 'src/pages/Research/constants';\nimport type { ResearchSortOption } from 'src/pages/Research/ResearchSortOptions.ts';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentServiceServer } from 'src/services/contentService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport { StorageServiceServer } from 'src/services/storageService.server';\nimport { SubscribersServiceServer } from 'src/services/subscribersService.server';\nimport { conflictError, methodNotAllowedError, validationError } from 'src/utils/httpException';\nimport { convertToSlug } from 'src/utils/slug';\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n  const url = new URL(request.url);\n  const searchParams = new URLSearchParams(url.search);\n  const q = searchParams.get('q');\n  const category = Number(searchParams.get('category')) || undefined;\n  const sort = searchParams.get('sort') as ResearchSortOption;\n  const skip = Number(searchParams.get('skip')) || 0;\n  const status: ResearchStatus | null = searchParams.get('status') as ResearchStatus;\n\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const { data, error } = await client.rpc('get_research', {\n    search_query: q || null,\n    category_id: category,\n    research_status: status || null,\n    sort_by: sort,\n    offset_val: skip,\n    limit_val: ITEMS_PER_PAGE,\n  });\n\n  const countResult = await client.rpc('get_research_count', {\n    search_query: q || null,\n    category_id: category,\n    research_status: status || null,\n  });\n  const count = countResult.data || 0;\n\n  if (error) {\n    console.error(error);\n    return Response.json({}, { status: 500, headers });\n  }\n\n  const dbItems = data as DBResearchItem[];\n\n  if (!dbItems || dbItems.length === 0) {\n    return Response.json({ items: [], total: 0 }, { headers });\n  }\n\n  const items = dbItems.map((dbResearchItem) => {\n    const images = dbResearchItem.image\n      ? new StorageServiceServer(client).getPublicUrls([dbResearchItem.image], IMAGE_SIZES.LIST)\n      : [];\n    return ResearchItem.fromDB(dbResearchItem, [], images);\n  });\n\n  if (items && items.length > 0) {\n    // Populate useful votes\n    const votes = await client.rpc('get_useful_votes_count_by_content_id', {\n      p_content_type: 'research',\n      p_content_ids: items.map((x) => x.id),\n    });\n\n    if (votes.data) {\n      const votesByContentId = votes.data.reduce((acc, current) => {\n        acc.set(current.content_id, current.count);\n        return acc;\n      }, new Map());\n\n      for (const item of items) {\n        if (votesByContentId.has(item.id)) {\n          item.usefulCount = votesByContentId.get(item.id)!;\n        }\n      }\n    }\n  }\n\n  return Response.json({ items, total: count }, { headers });\n};\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const formData = await request.formData();\n    const data = {\n      title: formData.get('title') as string,\n      description: formData.get('description') as string,\n      isDraft: formData.get('isDraft') === 'true',\n      category: formData.has('category') ? Number(formData.get('category')) : null,\n      tags: formData.has('tags') ? formData.getAll('tags').map((x) => Number(x)) : null,\n      collaborators: formData.has('collaborators')\n        ? (formData.getAll('collaborators') as string[])\n        : null,\n      coverImage: formData.has('coverImage')\n        ? (JSON.parse(formData.get('coverImage') as string) as DBMedia)\n        : null,\n    } satisfies ResearchDTO;\n\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    validateRequest(request, data);\n\n    const slug = convertToSlug(data.title);\n\n    if (await new ContentServiceServer(client).isDuplicateNewSlug(slug, 'research')) {\n      throw conflictError('This research already exists');\n    }\n\n    const profileService = new ProfileServiceServer(client);\n    const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n    if (!profile) {\n      throw validationError('User not found');\n    }\n\n    if (!profile.username) {\n      throw validationError('You must set a username before creating content', 'username');\n    }\n\n    const researchStatus: ResearchStatus = 'in-progress';\n    const researchResult = await client\n      .from('research')\n      .insert({\n        created_by: profile.id,\n        title: data.title,\n        description: data.description,\n        slug,\n        category: data.category,\n        tags: data.tags,\n        collaborators: data.collaborators,\n        status: researchStatus,\n        is_draft: data.isDraft,\n        image: data.coverImage,\n        published_at: data.isDraft ? null : new Date(),\n        tenant_id: process.env.TENANT_ID,\n      })\n      .select()\n      .single();\n\n    if (researchResult.error || !researchResult.data) {\n      throw researchResult.error;\n    }\n\n    const research = ResearchItem.fromDB(\n      researchResult.data,\n      [],\n      [],\n      researchResult.data.collaborators,\n    );\n\n    await new SubscribersServiceServer(client).addResearchSubscribers(research, profile.id);\n    profileService.updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ research }, { headers, status: 201 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({ error: 'Error creating research', status: 500 }, { status: 500 });\n  }\n};\n\nfunction validateRequest(request: Request, data: ResearchDTO) {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n\n  if (!data.title) {\n    throw validationError('Title is required', 'title');\n  }\n\n  if (!data.description) {\n    throw validationError('Description is required', 'description');\n  }\n\n  if (!data.isDraft && !data.coverImage) {\n    throw validationError('Cover Image is required', 'coverImage');\n  }\n}\n"
  },
  {
    "path": "src/routes/api.settings.impact.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { IImpactDataField, IUserImpact } from 'oa-shared';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { data } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ImpactServiceServer } from 'src/services/impactService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport {\n  forbiddenError,\n  methodNotAllowedError,\n  unauthorizedError,\n  validationError,\n} from 'src/utils/httpException';\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      throw unauthorizedError();\n    }\n\n    const profileService = new ProfileServiceServer(client);\n    const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n    if (!profile) {\n      throw forbiddenError();\n    }\n\n    const formData = await request.formData();\n    const fieldData = {\n      year: Number(formData.get('year')),\n      fields: formData.get('fields') as string,\n    };\n\n    await validateRequest(request, fieldData);\n\n    const fields: IImpactDataField[] = JSON.parse(fieldData.fields);\n    const impactService = new ImpactServiceServer(client);\n    const result = await impactService.update(profile.id, fieldData.year, fields);\n\n    if (result?.error) {\n      console.error(result.error);\n      throw new Error(result.error.message || 'Error saving impact');\n    }\n\n    const impact = result.data as unknown as IUserImpact;\n\n    profileService.updateUserActivity(claims.data.claims.sub);\n\n    return data(impact, { headers, status: 200 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({}, { headers, status: 500, statusText: 'Error saving impact' });\n  }\n};\n\nasync function validateRequest(request: Request, data: any) {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n  if (!data.year) {\n    throw validationError('year is required');\n  }\n\n  if (!data.fields) {\n    throw validationError('fields are required');\n  }\n\n  try {\n    const fields: IImpactDataField[] = JSON.parse(data.fields);\n\n    if (!Array.isArray(fields) || !fields?.length) {\n      throw validationError('fields are not valid');\n    }\n  } catch (_) {\n    throw validationError('fields are not valid');\n  }\n}\n"
  },
  {
    "path": "src/routes/api.settings.map.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport type { DBMapPin, DBProfile, UpsertPin } from 'oa-shared';\nimport type { ActionFunctionArgs } from 'react-router';\nimport { MapPinFactory } from 'src/factories/mapPinFactory.server';\nimport { ProfileFactory } from 'src/factories/profileFactory.server';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { MapPinsServiceServer } from 'src/services/mapPinsService.server';\nimport { MapServiceServer } from 'src/services/mapService.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\nimport {\n  forbiddenError,\n  methodNotAllowedError,\n  unauthorizedError,\n  validationError,\n} from 'src/utils/httpException';\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      throw unauthorizedError();\n    }\n\n    const profileService = new ProfileServiceServer(client);\n    const dbProfile = await profileService.getByAuthId(claims.data.claims.sub);\n\n    if (!dbProfile) {\n      throw forbiddenError();\n    }\n\n    if (request.method === 'DELETE') {\n      return await deletePin(request, dbProfile);\n    }\n\n    const profile = new ProfileFactory(client).fromDB(dbProfile);\n\n    const formData = await request.formData();\n    const data: UpsertPin = {\n      name: formData.get('name') as string,\n      country: formData.get('country') as string,\n      country_code: formData.get('countryCode') as string,\n      administrative: formData.get('administrative') as string,\n      post_code: formData.get('postCode') as string,\n      lat: Number(formData.get('lat')),\n      lng: Number(formData.get('lng')),\n      profile_id: profile.id,\n    };\n\n    await validateRequest(request, data);\n\n    const mapService = new MapServiceServer(client);\n    const result = await mapService.upsert(data, profile);\n\n    if (result?.error) {\n      console.error(result.error);\n      throw new Error(result.error.message || 'Error saving map pin');\n    }\n\n    const pinFactory = new MapPinFactory(client);\n    const mapPin = pinFactory.fromDBWithProfile(result.data as unknown as DBMapPin);\n\n    profileService.updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({ mapPin }, { headers, status: 200 });\n  } catch (error) {\n    if (error instanceof HTTPException) {\n      return error.getResponse();\n    }\n\n    console.error(error);\n    return Response.json({}, { headers, status: 500, statusText: 'Error saving map pin' });\n  }\n};\n\nasync function validateRequest(request: Request, data: UpsertPin) {\n  if (request.method !== 'POST') {\n    throw methodNotAllowedError();\n  }\n  if (!data.country) {\n    throw validationError('country is required');\n  }\n  if (!data.country_code) {\n    throw validationError('countryCode is required');\n  }\n  if (!data.lat) {\n    throw validationError('lat is required');\n  }\n  if (!data.lng) {\n    throw validationError('lng is required');\n  }\n}\n\nasync function deletePin(request: Request, profile: DBProfile) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    await new MapPinsServiceServer(client).delete(profile.id);\n  } catch (error) {\n    console.error(error);\n    return Response.json({}, { headers, status: 500 });\n  }\n\n  return Response.json({}, { headers, status: 200 });\n}\n"
  },
  {
    "path": "src/routes/api.subscribers.$contentType.$contentId.subscribed.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const { data } = await client\n    .from('subscribers')\n    .select('id, profiles!inner(id)')\n    .eq('content_id', params.contentId)\n    .eq('content_type', params.contentType)\n    .eq('profiles.auth_id', claims.data.claims.sub);\n\n  const subscribed = !!data && !(data.length === 0);\n\n  return Response.json({ subscribed }, { headers, status: 200 });\n}\n"
  },
  {
    "path": "src/routes/api.subscribers.$contentType.$contentId.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport async function action({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  if (request.method !== 'POST' && request.method !== 'DELETE') {\n    return Response.json({}, { headers, status: 405, statusText: 'method not allowed' });\n  }\n\n  try {\n    const claims = await client.auth.getClaims();\n\n    if (!claims.data?.claims) {\n      return Response.json({}, { headers, status: 401 });\n    }\n\n    const profileService = new ProfileServiceServer(client);\n    const profile = await profileService.getByAuthId(claims.data.claims.sub);\n\n    if (!profile) {\n      throw { status: 400, statusText: 'user not found' };\n    }\n\n    if (request.method === 'POST') {\n      const response = await client\n        .from('subscribers')\n        .select('*')\n        .eq('content_type', params.contentType)\n        .eq('content_id', Number(params.contentId))\n        .eq('user_id', profile.id)\n        .single();\n\n      if (response.data) {\n        return response.data;\n      }\n\n      await client.from('subscribers').insert({\n        content_type: params.contentType,\n        content_id: Number(params.contentId),\n        user_id: profile.id,\n        tenant_id: process.env.TENANT_ID!,\n      });\n    }\n\n    if (request.method === 'DELETE') {\n      await client\n        .from('subscribers')\n        .delete()\n        .eq('content_type', params.contentType)\n        .eq('content_id', Number(params.contentId))\n        .eq('user_id', profile.id);\n    }\n\n    profileService.updateUserActivity(claims.data.claims.sub);\n\n    return Response.json({}, { headers, status: 200 });\n  } catch (error) {\n    if (error) {\n      console.error(error);\n      return Response.json({}, { headers, status: 500, statusText: 'error' });\n    }\n  }\n}\n"
  },
  {
    "path": "src/routes/api.tags.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const tagsResult = await client.from('tags').select('id,name,created_at,modified_at');\n\n  const tags = tagsResult.data || [];\n\n  return Response.json(tags, { headers, status: 200 });\n}\n"
  },
  {
    "path": "src/routes/api.upgrade-badges.ts",
    "content": "import Keyv from 'keyv';\nimport type { DBUpgradeBadge } from 'oa-shared';\nimport { UpgradeBadge } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { isProductionEnvironment } from 'src/config/config';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nconst cache = new Keyv<UpgradeBadge[]>({ ttl: 1800000 }); // ttl: 30 minutes\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  try {\n    const cachedUpgradeBadges = await cache.get('upgradeBadges');\n\n    if (\n      cachedUpgradeBadges &&\n      Array.isArray(cachedUpgradeBadges) &&\n      cachedUpgradeBadges.length &&\n      isProductionEnvironment()\n    ) {\n      return Response.json(cachedUpgradeBadges, { headers, status: 200 });\n    }\n\n    const { data, error } = await client.from('upgrade_badge').select(\n      `\n        *,\n        badge:profile_badges(id, name, display_name, image_url, action_url)\n      `,\n    );\n\n    if (error) {\n      throw error;\n    }\n\n    const upgradeBadges = data?.map((badge) => UpgradeBadge.fromDB(badge as DBUpgradeBadge));\n\n    if (upgradeBadges && upgradeBadges.length > 0) {\n      cache.set('upgradeBadges', upgradeBadges, 1800000);\n    }\n\n    return Response.json(upgradeBadges, { headers, status: 200 });\n  } catch (error) {\n    console.error(error);\n    return Response.json({}, { headers, status: 500 });\n  }\n}\n"
  },
  {
    "path": "src/routes/api.useful.$contentType.$contentId.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ProfileServiceServer } from 'src/services/profileService.server';\n\nexport async function action({ request, params }: LoaderFunctionArgs) {\n  if (request.method !== 'POST' && request.method !== 'DELETE') {\n    return Response.json({}, { status: 405, statusText: 'method not allowed' });\n  }\n\n  const { client, headers } = createSupabaseServerClient(request);\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const profileResult = await client\n    .from('profiles')\n    .select()\n    .eq('auth_id', claims.data.claims.sub)\n    .eq('tenant_id', process.env.TENANT_ID!)\n    .limit(1);\n\n  if (!profileResult.data || profileResult.error) {\n    console.error(profileResult.error + ' auth_id:' + claims.data.claims.sub);\n    return Response.json({}, { headers, status: 400, statusText: 'user not found' });\n  }\n\n  let result;\n  if (request.method === 'POST') {\n    result = await client.from('useful_votes').insert({\n      content_type: params.contentType,\n      content_id: Number(params.contentId),\n      user_id: profileResult.data[0].id,\n      tenant_id: process.env.TENANT_ID!,\n    });\n  } else {\n    result = await client\n      .from('useful_votes')\n      .delete()\n      .eq('content_type', params.contentType)\n      .eq('content_id', Number(params.contentId))\n      .eq('user_id', profileResult.data[0].id)\n      .eq('tenant_id', process.env.TENANT_ID!);\n  }\n\n  if (result.error) {\n    console.error(result.error);\n    return Response.json({}, { headers, status: 500, statusText: 'error' });\n  }\n\n  await new ProfileServiceServer(client).updateUserActivity(claims.data.claims.sub);\n\n  return Response.json({}, { headers, status: 200 });\n}\n"
  },
  {
    "path": "src/routes/api.useful.$contentType.$contentId.users.ts",
    "content": "import type { DBProfile, ProfileListItem } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { ProfileFactory } from 'src/factories/profileFactory.server';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n  const { contentType, contentId } = params;\n  const profileFactory = new ProfileFactory(client);\n\n  if (!contentType || !contentId) {\n    return Response.json({ error: 'Missing parameters' }, { status: 400 });\n  }\n\n  const { data, error } = await client\n    .from('useful_votes')\n    .select(\n      `profiles(id, username, display_name, photo, country, type:profile_types(\n            id,\n            name,\n            display_name,\n            image_url,\n            small_image_url,\n            map_pin_name,\n            description,\n            is_space\n          ), badges:profile_badges_relations(\n        profile_badges(\n          id,\n          name,\n          display_name,\n          image_url,\n          action_url\n    )))`,\n    )\n    .eq('content_type', contentType)\n    .eq('content_id', Number(contentId))\n    .eq('tenant_id', process.env.TENANT_ID!);\n\n  if (error) {\n    console.error(error);\n    return Response.json({ error: error.message }, { status: 500 });\n  }\n\n  // Transform the data using ProfileFactory and pick required properties\n  const users: ProfileListItem[] = [];\n\n  for (const row of data) {\n    if (row.profiles === null) {\n      continue;\n    }\n\n    const profile = profileFactory.fromDB(row.profiles as unknown as DBProfile);\n\n    users.push({\n      id: profile.id,\n      username: profile.username,\n      displayName: profile.displayName,\n      photo: profile.photo,\n      country: profile.country,\n      badges: profile.badges,\n      type: profile.type,\n    });\n  }\n\n  return Response.json(users, { headers });\n}\n"
  },
  {
    "path": "src/routes/api.useful.$contentType.$contentId.voted.ts",
    "content": "import type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const claims = await client.auth.getClaims();\n\n  if (!claims.data?.claims) {\n    return Response.json({}, { headers, status: 401 });\n  }\n\n  const useful = await client\n    .from('useful_votes')\n    .select('id, profiles!inner(id)', { count: 'exact' })\n    .eq('content_id', params.contentId)\n    .eq('content_type', params.contentType)\n    .eq('profiles.auth_id', claims.data.claims.sub);\n\n  const voted = useful.count === 1;\n\n  return Response.json({ voted }, { headers, status: 200 });\n}\n"
  },
  {
    "path": "src/routes/api.useful.$contentType.$id.count.ts",
    "content": "import type { UsefulContentType } from 'oa-shared';\nimport type { LoaderFunctionArgs } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nexport async function loader({ request, params }: LoaderFunctionArgs) {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const { contentType, id } = params;\n\n  const allowedContentTypes = [\n    'questions',\n    'projects',\n    'research',\n    'news',\n    'comments',\n  ] as const satisfies readonly UsefulContentType[];\n\n  if (!allowedContentTypes.includes(contentType as UsefulContentType)) {\n    return Response.json({ error: 'Unsupported content type' }, { status: 400 });\n  }\n\n  const commentId = Number(id);\n\n  if (!commentId || commentId <= 0 || !Number.isInteger(commentId)) {\n    return Response.json({ error: 'Invalid comment ID' }, { status: 400 });\n  }\n\n  const { data, error } = await client\n    .from('useful_votes')\n    .select('content_id')\n    .eq('content_type', contentType)\n    .eq('content_id', commentId);\n\n  if (error) {\n    console.error('Supabase error:', error);\n    return Response.json({ error: 'Error fetching votes' }, { status: 500, headers });\n  }\n\n  const count = Array.isArray(data) ? data.length : 0;\n\n  return Response.json({ count }, { headers, status: 200 });\n}\n"
  },
  {
    "path": "src/routes/logout.ts",
    "content": "import type { ActionFunctionArgs } from 'react-router';\nimport { data, redirect } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\n\nexport const loader = async ({ request }: ActionFunctionArgs) => {\n  const { client, headers } = createSupabaseServerClient(request);\n\n  const { error } = await client.auth.signOut();\n\n  if (error) {\n    return data({ success: false }, { headers });\n  }\n\n  const url = new URL(request.url);\n  const returnUrl = url.searchParams.get('returnUrl');\n\n  const location = returnUrl || '/';\n\n  return redirect(location, { headers });\n};\n"
  },
  {
    "path": "src/routes/redirect.tsx",
    "content": "import { type NotificationContentType, NotificationContentTypes } from 'oa-shared';\nimport { type LoaderFunctionArgs, redirect } from 'react-router';\nimport { createSupabaseServerClient } from 'src/repository/supabase.server';\nimport { ContentRedirectServiceServer } from 'src/services/contentRedirectService.server';\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n  try {\n    const { client } = createSupabaseServerClient(request);\n\n    const requestUrl = new URL(request.url);\n    const id = Number(requestUrl.searchParams.get('id'));\n    const contentType = requestUrl.searchParams.get('ct') as NotificationContentType;\n\n    if (!NotificationContentTypes.includes(contentType)) {\n      throw new Error('Invalid ct param');\n    }\n\n    if (!id) {\n      throw new Error('Invalid id param');\n    }\n\n    const url = await new ContentRedirectServiceServer(client).getUrl(id, contentType);\n\n    if (!url) {\n      throw new Error(`Url could not be resolved for - id: ${id}, ct: ${contentType}`);\n    }\n\n    return redirect(url);\n  } catch (error) {\n    console.error(error);\n  }\n\n  return redirect('/');\n}\n"
  },
  {
    "path": "src/routes.ts",
    "content": "import { type RouteConfig } from '@react-router/dev/routes';\nimport { flatRoutes } from '@react-router/fs-routes';\n\nexport default flatRoutes() satisfies RouteConfig;\n"
  },
  {
    "path": "src/services/authService.server.ts",
    "content": "import type { SupabaseClient, User } from '@supabase/supabase-js';\n\ntype CreateProfileArgs = {\n  user: User;\n};\n\nexport class AuthServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async createUserProfile(args: CreateProfileArgs) {\n    // Should add more typing here about the required fields needed to create a profile\n\n    const { data, error } = await this.client\n      .from('profile_types')\n      .select('*')\n      .eq('name', 'member');\n\n    if (error) {\n      console.error(error);\n      throw 'Default member type not found';\n    }\n\n    return await this.client.from('profiles').insert({\n      auth_id: args.user.id,\n      display_name: '',\n      tenant_id: process.env.TENANT_ID,\n      profile_type: data[0].id,\n    });\n  }\n}\n"
  },
  {
    "path": "src/services/broadcastCoordinationService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBProfile, DBResearchUpdate, ResearchUpdate } from 'oa-shared';\nimport { discordServiceServer } from './discordService.server';\nimport { NotificationsSupabaseServiceServer } from './notificationsSupabaseService.server';\n\nexport class BroadcastCoordinationServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  researchUpdate(\n    update: ResearchUpdate,\n    profile: DBProfile | null,\n    request: Request,\n    oldUpdate?: DBResearchUpdate,\n  ) {\n    const beforeCheck = oldUpdate ? !!oldUpdate.is_draft : true;\n    const research = update.research;\n    const siteUrl = new URL(request.url).origin.replace('http:', 'https:');\n\n    if (!research || !profile || research?.is_draft) {\n      return;\n    }\n\n    if (beforeCheck && update.isDraft === false && !!update.research) {\n      new NotificationsSupabaseServiceServer(this.client).createNotificationsResearchUpdate(\n        research,\n        update,\n        profile as DBProfile,\n      );\n\n      if (!profile.username) {\n        console.warn(\n          `Profile with id ${profile.id} does not have a username, using \"Someone\" as fallback for Discord notification.`,\n        );\n        return;\n      }\n\n      discordServiceServer.postWebhookRequest(\n        `🧪 ${profile.username} posted a new research update: ${update.title}\\nCheck it out here: <${siteUrl}/research/${research.slug}#update_${update.id}>`,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "src/services/categoryService.ts",
    "content": "import type { Category, ContentType } from 'oa-shared';\nimport { logger } from 'src/logger';\n\nconst getCategories = async (type: ContentType) => {\n  try {\n    const response = await fetch(`/api/categories/${type}`);\n    return (await response.json()) as Category[];\n  } catch (error) {\n    logger.error('Failed to fetch categories', { error });\n    return [];\n  }\n};\n\nexport const categoryService = {\n  getCategories,\n};\n"
  },
  {
    "path": "src/services/commentService.ts",
    "content": "import type { Comment, DiscussionContentType } from 'oa-shared';\n\nconst deleteComment = async (sourceId: string | number, id: number) => {\n  return await fetch(`/api/discussions/${sourceId}/comments/${id}`, {\n    method: 'DELETE',\n  });\n};\n\nconst editComment = async (sourceId: string | number, id: number, comment: string) => {\n  return await fetch(`/api/discussions/${sourceId}/comments/${id}`, {\n    method: 'PUT',\n    body: JSON.stringify({\n      comment,\n    }),\n  });\n};\n\nconst postComment = async (\n  sourceId: string | number,\n  comment: string,\n  sourceType: DiscussionContentType,\n  parentId?: number,\n) => {\n  return await fetch(`/api/discussions/${sourceType}/${sourceId}/comments`, {\n    method: 'POST',\n    body: JSON.stringify({\n      comment,\n      parentId,\n    }),\n  });\n};\n\nconst getComments = async (sourceType: DiscussionContentType, sourceId: string | number) => {\n  const result = await fetch(`/api/discussions/${sourceType}/${sourceId}/comments`);\n  const { comments } = (await result.json()) as { comments: Comment[] };\n  return comments;\n};\n\nconst getCommentSourceId = async (commentId: number) => {\n  const result = await fetch(`/api/comments/${commentId}/source`);\n  const { sourceId } = (await result.json()) as { sourceId: number };\n\n  return sourceId;\n};\n\nexport const commentService = {\n  getComments,\n  getCommentSourceId,\n  postComment,\n  editComment,\n  deleteComment,\n};\n"
  },
  {
    "path": "src/services/contentRedirectService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBComment, DBResearchItem, NotificationContentType } from 'oa-shared';\n\n// TODO: Add Tests\nexport class ContentRedirectServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async getUrl(id: number, contentType: NotificationContentType): Promise<string | null> {\n    switch (contentType) {\n      case 'research_updates':\n        return this.resolveResearchUpdateUrl(id);\n      case 'comments':\n        return this.resolveCommentUrl(id);\n    }\n  }\n\n  private async resolveResearchUpdateUrl(updateId: number) {\n    const { data, error } = await this.client\n      .from('research_updates')\n      .select('research:research_id(slug)')\n      .eq('id', updateId)\n      .single();\n\n    if (error || !data || !data.research) {\n      return null;\n    }\n\n    const slug = (data.research as unknown as DBResearchItem).slug;\n\n    return `/research/${slug}#update_${updateId}`;\n  }\n\n  private async resolveCommentUrl(commentId: number) {\n    const { data, error } = await this.client\n      .from('comments')\n      .select('*')\n      .eq('id', commentId)\n      .single();\n\n    if (error || !data) {\n      return null;\n    }\n\n    const comment = data as DBComment;\n\n    switch (comment.source_type) {\n      case 'research_updates':\n        return this.resolveResearchUpdateCommentUrl(comment.source_id!, comment.id);\n      case 'news':\n      case 'projects':\n      case 'questions':\n        return this.resolveGenericContentCommentUrl(\n          comment.source_type,\n          comment.source_id!,\n          comment.id,\n        );\n    }\n  }\n\n  private async resolveResearchUpdateCommentUrl(updateId: number, commentId: number) {\n    const { data, error } = await this.client\n      .from('research_updates')\n      .select('research:research_id(slug)')\n      .eq('id', updateId)\n      .single();\n\n    if (error || !data || !data.research) {\n      return null;\n    }\n\n    const slug = (data.research as unknown as DBResearchItem).slug;\n\n    return `/research/${slug}?update_${updateId}#comment:${commentId}`;\n  }\n\n  private async resolveGenericContentCommentUrl(\n    contentType: 'questions' | 'projects' | 'news',\n    contentId: number,\n    commentId: number,\n  ) {\n    const { data, error } = await this.client\n      .from(contentType)\n      .select('slug')\n      .eq('id', contentId)\n      .single();\n\n    if (error || !data || !data.slug) {\n      return null;\n    }\n\n    const basePath = contentType === 'projects' ? 'library' : contentType;\n\n    return `/${basePath}/${data.slug}#comment:${commentId}`;\n  }\n}\n"
  },
  {
    "path": "src/services/contentService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { ContentType, IDBContentDoc } from 'oa-shared';\nimport { TagsServiceServer } from 'src/services/tagsService.server';\n\ntype Slug = string;\ntype Id = number;\n\nexport class ContentServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async getDraftCount(profileId: number, table: ContentType) {\n    const { count } = await this.client\n      .from(table)\n      .select('id', { count: 'exact' })\n      .eq('is_draft', true)\n      .eq('created_by', profileId)\n      .or('deleted.eq.false,deleted.is.null');\n\n    return count;\n  }\n\n  async getMetaFields(id: Id, table: ContentType, tagIds: number[]) {\n    return await Promise.all([\n      this.client\n        .from('useful_votes')\n        .select('*', { count: 'exact' })\n        .eq('content_id', id)\n        .eq('content_type', table),\n      this.client\n        .from('subscribers')\n        .select('user_id', { count: 'exact' })\n        .eq('content_id', id)\n        .eq('content_type', table),\n      new TagsServiceServer(this.client).getTags(tagIds),\n    ]);\n  }\n\n  async incrementViewCount(table: ContentType, totalViews: number | undefined, id: Id) {\n    return await this.client\n      .from(table)\n      .update({ total_views: (totalViews || 0) + 1 })\n      .eq('id', id);\n  }\n\n  async isDuplicateExistingSlug(slug: Slug, id: Id, table: ContentType) {\n    const { data } = await this.client\n      .from(table)\n      .select('id,slug,previous_slugs')\n      .or(`slug.eq.${slug},previous_slugs.cs.{\"${slug}\"}`)\n      .single();\n\n    return !!data?.id && data.id !== id;\n  }\n\n  async isDuplicateNewSlug(slug: Slug, table: ContentType) {\n    const { data } = await this.client\n      .from(table)\n      .select('slug,previous_slugs')\n      .or(`slug.eq.${slug},previous_slugs.cs.{\"${slug}\"}`)\n      .single();\n\n    return !!data;\n  }\n\n  static updatePreviousSlugs(content: IDBContentDoc, newSlug: Slug) {\n    if (content.slug !== newSlug) {\n      return content.previous_slugs ? [...content.previous_slugs, content.slug] : [content.slug];\n    }\n\n    return content.previous_slugs;\n  }\n}\n"
  },
  {
    "path": "src/services/discordService.server.ts",
    "content": "const postWebhookRequest = async (message: string) => {\n  try {\n    const discordWebhookUrl = process.env.DISCORD_WEBHOOK_URL as string;\n\n    if (!discordWebhookUrl) {\n      return;\n    }\n\n    await fetch(discordWebhookUrl as string, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify({\n        content: message,\n      }),\n    });\n  } catch (error) {\n    console.error(error);\n  }\n};\n\nexport const discordServiceServer = {\n  postWebhookRequest,\n};\n"
  },
  {
    "path": "src/services/formDataHelper.ts",
    "content": "export function createFormData<T extends Record<string, unknown>>(data: T): FormData {\n  const formData = new FormData();\n\n  for (const [key, value] of Object.entries(data)) {\n    if (value === null || value === undefined) continue;\n\n    if (Array.isArray(value)) {\n      if (value[0] && typeof value[0] === 'object') {\n        value.forEach((item) => formData.append(key, JSON.stringify(item)));\n      } else {\n        value.forEach((item) => formData.append(key, String(item)));\n      }\n    } else if (typeof value === 'object') {\n      formData.append(key, JSON.stringify(value));\n    } else {\n      formData.append(key, String(value));\n    }\n  }\n\n  return formData;\n}\n"
  },
  {
    "path": "src/services/imageService.server.ts",
    "content": "import type { TransformOptions } from '@supabase/storage-js';\nimport type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBMedia } from 'oa-shared';\nimport { Image, MediaFile } from 'oa-shared';\n\nexport class ImageServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  getPublicUrl(image: DBMedia | null, size?: TransformOptions): Image | undefined {\n    if (!image) {\n      return undefined;\n    }\n\n    try {\n      const { data } = this.client.storage.from(process.env.TENANT_ID as string).getPublicUrl(\n        image.path,\n        size\n          ? {\n              transform: size,\n            }\n          : undefined,\n      );\n\n      if (!data?.publicUrl) {\n        return undefined;\n      }\n\n      return new Image({ id: image.id, publicUrl: data.publicUrl });\n    } catch (_) {\n      return undefined;\n    }\n  }\n\n  getPublicUrls(images: DBMedia[], size?: TransformOptions): Image[] {\n    const result: Image[] = [];\n\n    for (const x of images) {\n      const img = this.getPublicUrl(x, size);\n      if (img) {\n        result.push(img);\n      }\n    }\n\n    return result;\n  }\n\n  async uploadImage(files: File[], path: string) {\n    if (!files || files.length === 0) {\n      return null;\n    }\n\n    const errors: string[] = [];\n    const media: DBMedia[] = [];\n\n    for (const file of files) {\n      const result = await this.client.storage\n        .from(process.env.TENANT_ID as string)\n        .upload(`${path}/${file.name}`, file, { upsert: true });\n\n      if (result.data === null) {\n        errors.push(`Error uploading file: ${file.name}`);\n        errors.push(`${result.error?.message} ${result.error?.cause}`);\n        continue;\n      }\n\n      media.push(result.data);\n    }\n\n    return { media, errors };\n  }\n\n  async uploadFile(files: File[], path: string) {\n    if (!files || files.length === 0) {\n      return null;\n    }\n\n    const errors: string[] = [];\n    const media: MediaFile[] = [];\n\n    for (const file of files) {\n      const result = await this.client.storage\n        .from(process.env.TENANT_ID + '-documents')\n        .upload(`${path}/${file.name}`, file);\n\n      if (result.data === null) {\n        errors.push(`Error uploading file: ${file.name}`);\n        continue;\n      }\n\n      media.push({\n        id: result.data.id,\n        name: result.data.path.split('/').at(-1)!,\n        size: file.size,\n      });\n    }\n\n    return { media, errors };\n  }\n\n  async removeFiles(paths: string[]) {\n    await this.client.storage.from(process.env.TENANT_ID + '-documents').remove(paths);\n  }\n\n  async removeImages(paths: string[]) {\n    await this.client.storage.from(process.env.TENANT_ID as string).remove(paths);\n  }\n\n  async getPathDocuments(path: string, mapUrlPrefix: string) {\n    const documentsBucket = process.env.TENANT_ID + '-documents';\n\n    const { data, error } = await this.client.storage.from(documentsBucket).list(path);\n\n    if (!data || error) {\n      return [];\n    }\n\n    return data?.map(\n      (x) =>\n        new MediaFile({\n          id: x.id,\n          name: x.name,\n          size: x.metadata.size,\n          url: `${mapUrlPrefix}/${x.id}`,\n        }),\n    );\n  }\n}\n"
  },
  {
    "path": "src/services/impactService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { IImpactDataField, IUserImpact } from 'oa-shared';\n\nexport class ImpactServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async update(profileId: number, year: number, fields: IImpactDataField[]) {\n    const existingImpact = await this.client\n      .from('profiles')\n      .select('id,impact')\n      .eq('id', profileId)\n      .single();\n\n    let impact: IUserImpact = {};\n\n    if (existingImpact.data?.impact) {\n      const rawImpact = existingImpact.data.impact;\n      impact = typeof rawImpact === 'string' ? JSON.parse(rawImpact) : rawImpact;\n    }\n\n    impact[year] = fields;\n\n    return await this.client\n      .from('profiles')\n      .update({\n        impact: JSON.stringify(impact),\n      })\n      .eq('id', profileId)\n      .select('impact')\n      .single();\n  }\n}\n"
  },
  {
    "path": "src/services/libraryService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport {\n  type DBProject,\n  type DBProjectStep,\n  Image,\n  Project,\n  ProjectStepDTO,\n  UserRole,\n} from 'oa-shared';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { ImageServiceServer } from './imageService.server';\nimport { StorageServiceServer } from './storageService.server';\n\nexport class LibraryServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  getBySlug(slug: string) {\n    return this.client\n      .from('projects')\n      .select(\n        `\n        id,\n        created_at,\n        created_by,\n        modified_at,\n        published_at,\n        title,\n        description,\n        slug,\n        cover_image,\n        category:categories(id,name),\n        tags,\n        total_views,\n        is_draft,\n        files, \n        file_link, \n        file_download_count,\n        time,\n        difficulty_level,\n        comment_count,\n        moderation,\n        moderation_feedback,\n        author:profiles(id, display_name, username, photo, country, donations_enabled,\n          profile_type(id, display_name, name, is_space, image_url, small_image_url),\n          badges:profile_badges_relations(\n            profile_badges(\n              id,\n              name,\n              display_name,\n              image_url,\n              action_url\n            )\n          )\n        ),\n        steps:project_steps(\n          id, \n          created_at, \n          title, \n          description, \n          images, \n          video_url,\n          order\n        )\n     `,\n      )\n      .or(`slug.eq.${slug},previous_slugs.cs.{\"${slug}\"}`)\n      .or('deleted.eq.false,deleted.is.null')\n      .single();\n  }\n\n  async getUserProjects(username: string): Promise<Partial<Project>[]> {\n    const imageService = new ImageServiceServer(this.client);\n    const { data, error } = await this.client.rpc('get_user_projects', {\n      username_param: username,\n    });\n\n    if (error) {\n      console.error('Error fetching user projects:', error);\n      return [];\n    }\n\n    return data?.map((x) => {\n      const coverImage = x.cover_image ? imageService.getPublicUrl(x.cover_image) : null;\n\n      return {\n        id: x.id,\n        commentCount: x.comment_count,\n        coverImage,\n        title: x.title,\n        slug: x.slug,\n        usefulCount: x.total_useful,\n      };\n    });\n  }\n\n  getProjectPublicMedia(projectDb: DBProject) {\n    const allImages: Image[] = [];\n\n    const storage = new StorageServiceServer(this.client);\n    if (projectDb.cover_image) {\n      const coverImage = storage\n        .getPublicUrls([projectDb.cover_image], IMAGE_SIZES.LANDSCAPE)\n        ?.at(0);\n\n      if (coverImage) {\n        allImages.push(coverImage);\n      }\n    }\n\n    const stepImages = projectDb.steps?.flatMap((x) => x.images)?.filter((x) => !!x) || [];\n\n    const publicStepImages = stepImages\n      ? storage.getPublicUrls(stepImages, IMAGE_SIZES.GALLERY)\n      : [];\n\n    return [...allImages, ...publicStepImages.filter((x) => !!x)];\n  }\n\n  async isAllowedToEditProject(\n    project: DBProject,\n    profile: { id: number; username: string | null; roles: string[] | null },\n  ) {\n    if (profile.id === project.author?.id) {\n      return true;\n    }\n\n    return profile.roles?.includes(UserRole.ADMIN);\n  }\n\n  async isAllowedToEditProjectById(\n    id: number,\n    profile: { id: number; username: string | null; roles: string[] | null },\n  ) {\n    const projectResult = await this.client\n      .from('projects')\n      .select('id,created_by,author:profiles(id,username)')\n      .eq('id', id)\n      .single();\n\n    const project = projectResult.data as unknown as DBProject;\n\n    return this.isAllowedToEditProject(project, profile);\n  }\n\n  async getById(id: number) {\n    const result = await this.client.from('projects').select().eq('id', id).single();\n    return result.data as DBProject;\n  }\n\n  async getProjectStepIds(id: number): Promise<number[]> {\n    const result = await this.client.from('project_steps').select('id').eq('project_id', id);\n\n    return result.data?.map((x) => x.id) as number[];\n  }\n\n  async upsertStep(\n    projectId: number,\n    stepId: number | null,\n    values: ProjectStepDTO,\n    order: number,\n  ) {\n    if (stepId) {\n      const { data, error } = await this.client\n        .from('project_steps')\n        .update({\n          title: values.title,\n          description: values.description,\n          project_id: projectId,\n          video_url: values.videoUrl,\n          images: values.images,\n          order,\n        })\n        .eq('id', stepId)\n        .select();\n      if (error || !data) {\n        throw error;\n      }\n      return data[0] as unknown as DBProjectStep;\n    } else {\n      const { data, error } = await this.client\n        .from('project_steps')\n        .insert({\n          title: values.title,\n          description: values.description,\n          project_id: projectId,\n          video_url: values.videoUrl,\n          images: values.images,\n          tenant_id: process.env.TENANT_ID,\n          order,\n        })\n        .select();\n      if (error || !data) {\n        throw error;\n      }\n      return data[0] as unknown as DBProjectStep;\n    }\n  }\n\n  async deleteStepsById(ids: number[]) {\n    await this.client.from('project_steps').delete().in('id', ids);\n  }\n}\n"
  },
  {
    "path": "src/services/mapPinsService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport Keyv from 'keyv';\nimport type { DBMapPin, MapPin } from 'oa-shared';\nimport { isProductionEnvironment } from 'src/config/config';\nimport { MapPinFactory } from 'src/factories/mapPinFactory.server';\n\nconst cache = new Keyv<MapPin[]>({ ttl: 3600000 }); // ttl: 60 minutes\n\nexport class MapPinsServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async get() {\n    const cachedMappins = await cache.get('mappins');\n\n    // check if cached map pins are available and a producation environment, if not - load from db and cache them\n    if (\n      cachedMappins &&\n      Array.isArray(cachedMappins) &&\n      cachedMappins.length > 0 &&\n      isProductionEnvironment()\n    ) {\n      return cachedMappins;\n    }\n\n    // get all profile tags\n    const { data, error } = await this.client\n      .from('map_pins')\n      .select(\n        `\n        id,\n        profile_id,\n        country,\n        country_code,\n        name,\n        administrative,\n        post_code,\n        lat,\n        lng,\n        moderation,\n        profile:profiles(\n          id,\n          country,\n          display_name,\n          photo,\n          cover_images,\n          about,\n          username,\n          last_active,\n          badges:profile_badges_relations(\n            profile_badges(\n              id,\n              name,\n              display_name,\n              image_url,\n              action_url\n            )\n          ),\n          tags:profile_tags_relations(\n            profile_tags(\n              id,\n              name\n            )\n          ),\n          type:profile_types(\n            id,\n            name,\n            display_name,\n            image_url,\n            small_image_url,\n            map_pin_name,\n            description,\n            is_space\n          )\n        )\n      `,\n      )\n      .eq('moderation', 'accepted');\n\n    if (!data || error) {\n      throw error;\n    }\n\n    const pinsDb = data as unknown as DBMapPin[];\n    const pinFactory = new MapPinFactory(this.client);\n    const mapPins = pinsDb\n      .filter((pin) => pin.profile)\n      .map((pin) => pinFactory.fromDBWithProfile(pin));\n\n    cache.set('mappins', mapPins);\n\n    return mapPins;\n  }\n\n  async delete(profileId: number) {\n    const { error } = await this.client.from('map_pins').delete().eq('profile_id', profileId);\n\n    if (error) {\n      throw error;\n    }\n\n    cache.delete('mappins');\n  }\n\n  clearCache() {\n    cache.delete('mappins');\n  }\n}\n"
  },
  {
    "path": "src/services/mapService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { Moderation, Profile, UpsertPin } from 'oa-shared';\n\nexport class MapServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async upsert(pin: UpsertPin, profile: Profile) {\n    const existingPin = await this.client\n      .from('map_pins')\n      .select('id,moderation')\n      .eq('profile_id', pin.profile_id);\n    const existingPinId = existingPin.data?.at(0)?.id;\n\n    if (existingPinId) {\n      const moderation: Moderation =\n        existingPin.data![0].moderation === 'accepted' ? 'accepted' : 'awaiting-moderation';\n\n      return await this.client\n        .from('map_pins')\n        .update({\n          country: pin.country,\n          country_code: pin.country_code,\n          name: pin.name,\n          administrative: pin.administrative,\n          post_code: pin.post_code,\n          moderation,\n          lat: pin.lat,\n          lng: pin.lng,\n        })\n        .eq('id', existingPinId)\n        .select(\n          `\n          id,\n          profile_id,\n          country,\n          country_code,\n          name,\n          administrative,\n          post_code,\n          lat,\n          lng,\n          moderation,\n          profile:profiles(\n            id,\n            country,\n            display_name,\n            photo,\n            type,\n            username\n            badges:profile_badges_relations(\n              profile_badges(\n                id,\n                name,\n                display_name,\n                image_url,\n                action_url\n              )\n            ),\n            tags:profile_tags_relations(\n              profile_tags(\n                id,\n                name\n              )\n            ),\n            type:profile_types(\n              id,\n              name,\n              display_name,\n              image_url,\n              small_image_url,\n              map_pin_name,\n              description,\n              is_space\n            )\n          )`,\n        )\n        .single();\n    } else {\n      const moderation: Moderation =\n        profile.type?.name === 'member' ? 'accepted' : 'awaiting-moderation';\n\n      return await this.client\n        .from('map_pins')\n        .insert({\n          profile_id: pin.profile_id,\n          country: pin.country,\n          country_code: pin.country_code,\n          name: pin.name,\n          administrative: pin.administrative,\n          post_code: pin.post_code,\n          lat: pin.lat,\n          lng: pin.lng,\n          moderation,\n          tenant_id: process.env.TENANT_ID,\n        })\n        .select(\n          `\n          id,\n          profile_id,\n          country,\n          country_code,\n          name,\n          administrative,\n          post_code,\n          lat,\n          lng,\n          moderation,\n          profile:profiles(\n            id,\n            type,\n            display_name,\n            username,\n            photo,\n            badges:profile_badges_relations(\n              profile_badges(\n                id,\n                name,\n                display_name,\n                image_url,\n                action_url\n              )\n            ),\n            tags:profile_tags_relations(\n              profile_tags(\n                id,\n                name\n              )\n            ),\n            type:profile_types(\n              id,\n              name,\n              display_name,\n              image_url,\n              small_image_url,\n              map_pin_name,\n              description,\n              is_space\n            )\n          )`,\n        )\n        .single();\n    }\n  }\n}\n"
  },
  {
    "path": "src/services/messageService.ts",
    "content": "import type { SendMessage } from 'oa-shared';\n\nconst sendMessage = async (data: SendMessage): Promise<void> => {\n  const formData = new FormData();\n\n  formData.append('to', data.to);\n  formData.append('message', data.message);\n  formData.append('name', data.name);\n\n  const response = await fetch('/api/messages', {\n    method: 'POST',\n    body: formData,\n  });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error saving research' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving research';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  return;\n};\n\nexport const messageService = {\n  sendMessage,\n};\n"
  },
  {
    "path": "src/services/newsService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBMedia, DBNews } from 'oa-shared';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { StorageServiceServer } from './storageService.server';\n\nexport class NewsServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async getById(id: number) {\n    const result = await this.client.from('news').select().eq('id', id).single();\n    return result.data as DBNews;\n  }\n\n  getBySlug(slug: string) {\n    return this.client\n      .from('news')\n      .select(\n        `\n       id,\n       created_at,\n       created_by,\n       modified_at,\n       published_at,\n       comment_count,\n       body,\n       is_draft,\n       moderation,\n       slug,\n       summary,\n       category:category(id,name),\n       profile_badge:profile_badge(*),\n       tags,\n       title,\n       total_views,\n       tenant_id,\n       hero_image,\n       author:profiles(id, display_name, username, country, badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url\n          )\n        ))\n     `,\n      )\n      .or(`slug.eq.${slug},previous_slugs.cs.{\"${slug}\"}`)\n      .or('deleted.eq.false,deleted.is.null')\n      .single();\n  }\n\n  async getHeroImage(dbImage: DBMedia | null) {\n    if (!dbImage) {\n      return null;\n    }\n\n    const images = new StorageServiceServer(this.client).getPublicUrls(\n      [dbImage],\n      IMAGE_SIZES.GALLERY,\n    );\n\n    return images[0];\n  }\n}\n"
  },
  {
    "path": "src/services/newsService.ts",
    "content": "import type { DBNews, NewsFormData } from 'oa-shared';\nimport { DBMedia, News, NewsDTO } from 'oa-shared';\nimport { createFormData } from './formDataHelper';\n\nconst upsert = async (id: number | null, form: NewsFormData) => {\n  const body = createFormData<NewsDTO>({\n    title: form.title,\n    body: form.body,\n    category: Number(form.category?.value) || null,\n    heroImage: form.heroImage ? DBMedia.fromPublicMedia(form.heroImage) : null,\n    isDraft: form.isDraft,\n    profileBadge: Number(form.profileBadge?.value) || null,\n    tags: form.tags || null,\n  });\n\n  const response =\n    id === null\n      ? await fetch(`/api/news`, {\n          method: 'POST',\n          body,\n        })\n      : await fetch(`/api/news/${id}`, {\n          method: 'PUT',\n          body,\n        });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error saving news' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving news';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  const data: { news: DBNews } = await response.json();\n  const news = News.fromDB(data.news, []);\n\n  return news;\n};\n\nexport const newsService = {\n  upsert,\n};\n"
  },
  {
    "path": "src/services/notificationEmailService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBNotification, SubscribedUser } from 'oa-shared';\nimport { createElement } from 'react';\nimport { sendBatchEmails } from 'src/.server/resend';\nimport { InstantNotificationEmail } from 'src/.server/templates/instant-notification-email';\nimport { tokens } from 'src/utils/tokens.server';\nimport { NotificationMapperServiceServer } from './notificationMapperService.server';\nimport { TenantSettingsService } from './tenantSettingsService.server';\n\nexport class NotificationEmailServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async sendInstantNotificationEmails(\n    subscribers: SubscribedUser[],\n    dbNotification: DBNotification,\n  ) {\n    try {\n      const emailsToSend = subscribers.filter((result) => {\n        if (result.profile_id === dbNotification.triggered_by_id) {\n          return false;\n        }\n\n        if (result.is_unsubscribed) {\n          return false;\n        }\n\n        if (result.replies === false && dbNotification.action_type === 'newReply') {\n          return false;\n        }\n\n        if (result.comments === false && dbNotification.action_type === 'newComment') {\n          return false;\n        }\n\n        if (\n          result.research_updates === false &&\n          dbNotification.action_type === 'newContent' &&\n          dbNotification.content_type === 'research_updates'\n        ) {\n          return false;\n        }\n\n        if (\n          result.email.endsWith('@example.com') ||\n          result.email.endsWith('@test.com') ||\n          result.email.endsWith('@resend.dev')\n        ) {\n          return false;\n        }\n\n        return true;\n      });\n\n      if (emailsToSend.length === 0) {\n        throw new Error('No emails to send');\n      }\n\n      const fullNotification = await new NotificationMapperServiceServer(\n        this.client,\n      ).transformNotification(dbNotification);\n\n      const codes = emailsToSend.map((p) => ({\n        email: p.email,\n        code: tokens.generate(p.profile_id, p.profile_created_at),\n      }));\n\n      const tenantSettings = await new TenantSettingsService(this.client).get();\n\n      const emails = codes.map(({ code, email }) => {\n        return {\n          to: email,\n          template: createElement(InstantNotificationEmail, {\n            notification: fullNotification,\n            userCode: code,\n            settings: tenantSettings,\n          }),\n        };\n      });\n\n      sendBatchEmails({\n        from: tenantSettings.emailFrom,\n        subject: fullNotification.email.subject,\n        emails,\n      });\n    } catch (error) {\n      console.error('Error creating email notification:', error);\n    }\n  }\n}\n"
  },
  {
    "path": "src/services/notificationMapperService.server.ts",
    "content": "import { SupabaseClient } from '@supabase/supabase-js';\nimport { DBNotification, Notification, NotificationDisplay } from 'oa-shared';\n\nexport class NotificationMapperServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async transformNotification(dbNotification: DBNotification) {\n    try {\n      const notification = Notification.fromDB(dbNotification);\n\n      if (notification.triggeredBy && dbNotification.triggered_by?.photo) {\n        const { data } = this.client.storage\n          .from(process.env.TENANT_ID as string)\n          .getPublicUrl(dbNotification.triggered_by.photo.path);\n        if (data?.publicUrl) {\n          notification.triggeredBy.photo = {\n            id: dbNotification.triggered_by.photo.id,\n            path: dbNotification.triggered_by.photo.path,\n            fullPath: dbNotification.triggered_by.photo.fullPath,\n            publicUrl: data.publicUrl,\n          };\n        }\n      }\n\n      const content = await this.client\n        .from(notification.contentType)\n        .select('*')\n        .eq('id', notification.contentId)\n        .single();\n\n      if (content.data) {\n        notification.content = content.data;\n      } else {\n        throw Error('Content not found, probably deleted');\n      }\n\n      return NotificationDisplay.fromNotification(notification);\n    } catch (error) {\n      console.error(error);\n      throw error;\n    }\n  }\n}\n"
  },
  {
    "path": "src/services/notificationsPreferencesService.ts",
    "content": "import type { DBNotificationsPreferences, NotificationsPreferencesFormData } from 'oa-shared';\n\nconst getPreferences = async (): Promise<DBNotificationsPreferences | null> => {\n  try {\n    const preferencesData = await fetch('/api/notifications-preferences');\n    const { preferences } = await preferencesData.json();\n    return preferences;\n  } catch (err) {\n    console.error(err);\n    return null;\n  }\n};\n\nconst setPreferences = async (data: NotificationsPreferencesFormData) => {\n  const body = new FormData();\n\n  data.id && body.append('id', data.id.toString());\n  body.append('comments', data.comments.toString());\n  body.append('replies', data.replies.toString());\n  body.append('research_updates', data.research_updates.toString());\n  body.append('is_unsubscribed', 'false');\n\n  return fetch('/api/notifications-preferences', {\n    method: 'POST',\n    body,\n  });\n};\n\nconst setUnsubscribe = async (id: number | undefined) => {\n  const body = new FormData();\n\n  id && body.append('id', id.toString());\n  body.append('comments', 'false');\n  body.append('replies', 'false');\n  body.append('research_updates', 'false');\n  body.append('is_unsubscribed', 'true');\n\n  return fetch('/api/notifications-preferences', {\n    method: 'POST',\n    body,\n  });\n};\n\nexport const notificationsPreferencesService = {\n  getPreferences,\n  setPreferences,\n  setUnsubscribe,\n};\n"
  },
  {
    "path": "src/services/notificationsPreferencesViaEmailService.ts",
    "content": "import type {\n  DBPreferencesWithProfileContact,\n  NotificationsPreferencesViaEmailFormData,\n} from 'oa-shared';\n\nconst getPreferences = async (\n  userCode: string,\n): Promise<DBPreferencesWithProfileContact | null> => {\n  try {\n    const response = await fetch(`/api/notifications-preferences-via-email/${userCode}`);\n\n    if (!response.ok) {\n      console.error(`HTTP error! status: ${response.status}`);\n      return null;\n    }\n\n    const { preferences, is_contactable } = await response.json();\n    return { preferences, is_contactable };\n  } catch (err) {\n    console.error(err);\n    return null;\n  }\n};\n\nconst setPreferences = async (\n  data: NotificationsPreferencesViaEmailFormData,\n): Promise<Response> => {\n  const formData = new FormData();\n  formData.append('comments', data.comments.toString());\n  formData.append('replies', data.replies.toString());\n  formData.append('research_updates', data.research_updates.toString());\n  formData.append('is_unsubscribed', 'false');\n\n  return fetch(`/api/notifications-preferences-via-email/${data.userCode}`, {\n    method: 'POST',\n    body: formData,\n  });\n};\n\nconst setUnsubscribe = async (userCode: string, id?: number): Promise<Response> => {\n  const formData = new FormData();\n  if (id) {\n    formData.append('id', id.toString());\n  }\n  formData.append('comments', 'false');\n  formData.append('replies', 'false');\n  formData.append('research_updates', 'false');\n  formData.append('is_unsubscribed', 'true');\n\n  return fetch(`/api/notifications-preferences-via-email/${userCode}`, {\n    method: 'POST',\n    body: formData,\n  });\n};\n\nexport const notificationsPreferencesViaEmailService = {\n  getPreferences,\n  setPreferences,\n  setUnsubscribe,\n};\n"
  },
  {
    "path": "src/services/notificationsSupabaseService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type {\n  DBComment,\n  DBProfile,\n  DBResearchItem,\n  ResearchUpdate,\n  SubscribableContentTypes,\n  SubscribedUser,\n} from 'oa-shared';\nimport { DBNotification } from 'oa-shared';\nimport { NotificationEmailServiceServer } from './notificationEmailService.server';\n\nexport class NotificationsSupabaseServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async getSubscribedUsers(\n    contentId: number,\n    contentType: SubscribableContentTypes,\n  ): Promise<SubscribedUser[]> {\n    try {\n      const { error, data } = await this.client.rpc('get_subscribed_users_emails_to_notify', {\n        p_content_id: contentId,\n        p_content_type: contentType,\n      });\n\n      if (error || data.length === 0) {\n        throw error || new Error('No emails to send');\n      }\n\n      return data as SubscribedUser[];\n    } catch (error) {\n      console.error(error);\n      throw new Error(error);\n    }\n  }\n\n  async createNotifications(\n    notification: DBNotification,\n    subscriberIds: SubscribedUser[],\n  ): Promise<void> {\n    try {\n      const notificationsToInsert = subscriberIds.map(\n        (subscriber) =>\n          new DBNotification({\n            action_type: notification.action_type,\n            content_id: notification.content_id,\n            content_type: notification.content_type,\n            source_content_id: notification.source_content_id,\n            source_content_type: notification.source_content_type,\n            title: notification.title,\n            triggered_by_id: notification.triggered_by_id,\n            owned_by_id: subscriber.profile_id,\n            is_read: false,\n            tenant_id: process.env.TENANT_ID!,\n          }),\n      );\n\n      const response = await this.client.from('notifications').insert(notificationsToInsert);\n\n      if (response.error) {\n        throw response.error;\n      }\n    } catch (error) {\n      console.error(error);\n    }\n\n    return;\n  }\n\n  async createNotificationsNewComment(comment: DBComment) {\n    if (!comment.created_by) {\n      return;\n    }\n\n    try {\n      const isReply = !!comment.parent_id;\n      const contentId = isReply ? comment.parent_id : comment.source_id;\n\n      if (!contentId) {\n        return new Error('contentId not found');\n      }\n      const contentType: SubscribableContentTypes = isReply ? 'comments' : comment.source_type;\n\n      let title = '';\n      if (!isReply) {\n        const sourceItem = await this.client\n          .from(comment.source_type)\n          .select('title')\n          .eq('id', comment.source_id)\n          .single();\n        title = sourceItem.data?.title;\n      }\n\n      const subscribers = (await this.getSubscribedUsers(contentId, contentType)).filter(\n        (user) => user.profile_id !== comment.created_by,\n      );\n\n      const notification = new DBNotification({\n        action_type: isReply ? 'newReply' : 'newComment',\n        content_id: comment.id!,\n        title: title,\n        triggered_by_id: comment.created_by!,\n        triggered_by: (comment as any).profiles as DBProfile,\n        content_type: 'comments',\n      });\n\n      await this.createNotifications(notification, subscribers);\n\n      await new NotificationEmailServiceServer(this.client).sendInstantNotificationEmails(\n        subscribers,\n        notification,\n      );\n    } catch (error) {\n      console.error(error);\n    }\n  }\n\n  async createNotificationsResearchUpdate(\n    research: DBResearchItem,\n    researchUpdate: ResearchUpdate,\n    profile: DBProfile,\n  ) {\n    try {\n      const contentType: SubscribableContentTypes = 'research';\n      const subscribers = await this.getSubscribedUsers(research.id, contentType);\n      const notification = new DBNotification({\n        action_type: 'newContent',\n        title: research.title,\n        content_id: researchUpdate.id!,\n        content_type: 'research_updates',\n        triggered_by_id: profile.id,\n        triggered_by: profile,\n      });\n\n      await this.createNotifications(notification, subscribers);\n\n      await new NotificationEmailServiceServer(this.client).sendInstantNotificationEmails(\n        subscribers,\n        notification,\n      );\n    } catch (error) {\n      console.error('Error creating notifications: Research update', error);\n    }\n  }\n}\n"
  },
  {
    "path": "src/services/notificationsSupabaseService.ts",
    "content": "import type { NotificationDisplay } from 'oa-shared';\n\nconst getNotifications = async () => {\n  try {\n    const response = await fetch('/api/notifications');\n    const result = (await response.json()) as {\n      notifications: NotificationDisplay[];\n    };\n\n    return result.notifications;\n  } catch (error) {\n    console.error(error);\n  }\n  return [];\n};\n\nexport const notificationSupabaseService = {\n  getNotifications,\n};\n"
  },
  {
    "path": "src/services/patreonService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type {\n  IPatreonMembershipAttributes,\n  IPatreonTierAttributes,\n  IPatreonUser,\n  IPatreonUserAttributes,\n  PatreonSettings,\n} from 'oa-shared';\nimport { TenantSettingsService } from './tenantSettingsService.server';\n\nexport class PatreonServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async verifyAndUpdatePatreonUser(code: string, userAuthId: string, origin: string) {\n    const settings = await new TenantSettingsService(this.client).get(true);\n    const PATREON_CLIENT_ID = settings.patreonId;\n    const PATREON_CLIENT_SECRET = process.env.PATREON_CLIENT_SECRET; // TODO: use supabase vault\n\n    if (!PATREON_CLIENT_ID || !PATREON_CLIENT_SECRET) {\n      throw new Error('PATREON_CLIENT_ID and PATREON_CLIENT_SECRET must be set');\n    }\n\n    const response = await fetch(\n      `https://www.patreon.com/api/oauth2/token?code=${code}&grant_type=authorization_code&client_id=${PATREON_CLIENT_ID}&client_secret=${PATREON_CLIENT_SECRET}&redirect_uri=${origin}/patreon`,\n      {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      },\n    );\n\n    if (!response.ok) {\n      const result = await response.json();\n      console.error({ result });\n      throw new Error('Error getting patreon access token');\n    }\n\n    const { access_token } = await response.json();\n    const patreonUser = await this.getCurrentPatreonUser(access_token);\n    const patreonUserParsed = this.parsePatreonUser(patreonUser);\n    const isSupporterUser = await this.isSupporter(patreonUserParsed);\n\n    // Update in supabase\n    await this.client\n      .from('profiles')\n      .update({\n        patreon: patreonUserParsed,\n        is_supporter: isSupporterUser,\n      })\n      .eq('auth_id', userAuthId);\n  }\n\n  async disconnectUser(userAuthId: string) {\n    await this.client\n      .from('profiles')\n      .update({ patreon: null, is_supporter: false })\n      .eq('auth_id', userAuthId);\n  }\n\n  private async isSupporter(patreonUser: IPatreonUser) {\n    if (patreonUser.membership?.attributes.patron_status !== 'active_patron') {\n      return false;\n    }\n\n    const result = await this.client.from('patreon_settings').select('tiers').limit(1);\n    const patreonSettings = result.data?.[0] as PatreonSettings;\n\n    const validIds = patreonSettings.tiers.map((x) => x.id);\n\n    return patreonUser.membership?.tiers.some(({ id }) => validIds.includes(id));\n  }\n\n  private parsePatreonUser(patreonUser: any): IPatreonUser {\n    // As we do not request the identity.membership scope, we only receive the user's membership to the\n    // One Army Patreon page, not other campaigns they may be part of.\n    const membership =\n      patreonUser.data.relationships.memberships.data.length > 0\n        ? patreonUser.included.find(({ type }) => type === 'member')\n        : undefined;\n\n    const tiers = membership?.relationships.currently_entitled_tiers.data\n      .map(({ id }) =>\n        patreonUser.included.find(\n          ({ type, id: includedId }) => type === 'tier' && id === includedId,\n        ),\n      )\n      .map(({ id, attributes }) => ({ id, attributes }));\n\n    const userMembership = membership\n      ? {\n          id: membership.id,\n          attributes: membership.attributes,\n          tiers,\n        }\n      : undefined;\n\n    return {\n      id: patreonUser.data.id,\n      attributes: patreonUser.data.attributes,\n      link: patreonUser.links.self,\n      // Only include membership if the user is a member of the One Army Patreon page.\n      ...(userMembership ? { membership: userMembership } : {}),\n    };\n  }\n\n  /*\n   * docs: https://docs.patreon.com/#get-api-oauth2-v2-identity\n   * to fetch more user attributes, add them to the include and fields query params\n   **/\n  private getCurrentPatreonUser = async (accessToken: string) => {\n    const userFields: Array<keyof IPatreonUserAttributes> = [\n      'about',\n      'created',\n      'email',\n      'first_name',\n      'full_name',\n      'image_url',\n      'last_name',\n      'thumb_url',\n      'url',\n    ];\n\n    const membershipFields: Array<keyof IPatreonMembershipAttributes> = [\n      'campaign_lifetime_support_cents',\n      'currently_entitled_amount_cents',\n      'is_follower',\n      'last_charge_date',\n      'last_charge_status',\n      'lifetime_support_cents',\n      'next_charge_date',\n      'note',\n      'patron_status',\n      'pledge_cadence',\n      'pledge_relationship_start',\n      'will_pay_amount_cents',\n    ];\n\n    const tierFields: Array<keyof IPatreonTierAttributes> = [\n      'amount_cents',\n      'created_at',\n      'description',\n      'edited_at',\n      'image_url',\n      'patron_count',\n      'published',\n      'published_at',\n      'title',\n      'url',\n    ];\n\n    const url = encodeURI(\n      `https://www.patreon.com/api/oauth2/v2/identity?include=memberships,memberships.currently_entitled_tiers&fields[user]=${userFields.join(\n        ',',\n      )}&fields[member]=${membershipFields.join(',')}&fields[tier]=${tierFields.join(',')}`,\n    );\n\n    const response = await fetch(url, {\n      method: 'GET',\n      headers: {\n        Authorization: `Bearer ${accessToken}`,\n      },\n    });\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      console.error(error);\n      throw new Error('Error getting patreon user');\n    }\n\n    return await response.json();\n  };\n}\n"
  },
  {
    "path": "src/services/patreonService.ts",
    "content": "import type { IPatreonUser } from 'oa-shared';\n\nconst getCurrentUserPatreon = async () => {\n  try {\n    const patreonData = await fetch('/api/patreon');\n\n    return (await patreonData.json()) as {\n      patreon: IPatreonUser;\n      isSupporter: boolean;\n    };\n  } catch (err) {\n    console.error(err);\n  }\n\n  return null;\n};\n\nconst disconnectUserPatreon = async () => {\n  const result = await fetch('/api/patreon', { method: 'DELETE' });\n\n  return result.ok;\n};\n\nexport const patreonService = {\n  getCurrentUserPatreon,\n  disconnectUserPatreon,\n};\n"
  },
  {
    "path": "src/services/profileBadgeService.ts",
    "content": "import type { ProfileBadge } from 'oa-shared';\nimport { logger } from 'src/logger';\n\nconst getProfileBadges = async () => {\n  try {\n    const response = await fetch(`/api/profile-badges`);\n    return (await response.json()) as ProfileBadge[];\n  } catch (error) {\n    logger.error('Failed to fetch profile badges', { error });\n    return [];\n  }\n};\n\nexport const ProfileBadgeService = {\n  getProfileBadges,\n};\n"
  },
  {
    "path": "src/services/profileService.server.ts",
    "content": "import type { SupabaseClient, User } from '@supabase/supabase-js';\nimport type { DBAuthorVotes, DBProfile, ProfileDTO, ProfileType } from 'oa-shared';\nimport { ProfileFactory } from 'src/factories/profileFactory.server';\nimport { ProfileTypesServiceServer } from './profileTypesService.server';\n\nexport class ProfileServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async getByAuthId(id: string): Promise<DBProfile | null> {\n    const { data } = await this.client\n      .from('profiles')\n      .select(\n        `*,\n        badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url,\n            premium_tier\n          )\n        ),\n        type:profile_types(\n          id,\n          name,\n          display_name,\n          image_url,\n          small_image_url,\n          description,\n          map_pin_name,\n          is_space\n        )`,\n      )\n      .eq('auth_id', id)\n      .single();\n\n    if (!data) {\n      return null;\n    }\n\n    return data as DBProfile;\n  }\n\n  async getById(id: number): Promise<DBProfile | null> {\n    const { data } = await this.client\n      .from('profiles')\n      .select(\n        `*,\n        type:profile_types(\n          id,\n          name,\n          display_name,\n          image_url,\n          small_image_url,\n          description,\n          map_pin_name,\n          is_space\n        ),\n        pin:map_pins(\n          id,\n          moderation\n        )`,\n      )\n      .eq('id', id)\n      .single();\n\n    if (!data) {\n      return null;\n    }\n\n    return data as DBProfile;\n  }\n\n  async getUsersByUsername(usernames: string[]): Promise<DBProfile[] | null> {\n    const { data } = await this.client\n      .from('profiles')\n      .select(\n        `id,\n        username,\n        display_name,\n        photo,\n        cover_images,\n        country,\n        tags:profile_tags_relations(\n          profile_tags(\n            id,\n            name\n          )\n        ),\n        badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url,\n            premium_tier\n          )\n        ),\n        type:profile_types(\n          id,\n          name,\n          display_name,\n          image_url,\n          small_image_url,\n          description,\n          map_pin_name,\n          is_space\n        )`,\n      )\n      .in('username', usernames);\n\n    if (!data) {\n      return null;\n    }\n\n    return data as unknown as DBProfile[];\n  }\n\n  async getByUsername(username: string): Promise<DBProfile | null> {\n    const { data } = await this.client\n      .from('profiles')\n      .select(\n        `*,\n        badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url,\n            premium_tier\n          )\n        ),\n        tags:profile_tags_relations(\n          profile_tags(\n            id,\n            name\n          )\n        ),\n        type:profile_types(\n          id,\n          name,\n          display_name,\n          image_url,\n          small_image_url,\n          description,\n          map_pin_name,\n          is_space\n        )`,\n      )\n      .eq('username', username)\n      .single();\n\n    if (!data) {\n      return null;\n    }\n\n    return data as DBProfile;\n  }\n\n  async incrementViewCount(id: number, totalViews: number) {\n    return await this.client\n      .from('profiles')\n      .update({ total_views: (totalViews || 0) + 1 })\n      .eq('id', id);\n  }\n\n  async getAuthorUsefulVotes(id: number) {\n    const { data, error } = await this.client.rpc('get_author_vote_counts', {\n      author_id: id,\n    });\n\n    if (error || !data) {\n      console.error(error);\n      return null;\n    }\n\n    return data as DBAuthorVotes[];\n  }\n\n  async updateUsername(id: number, username: string) {\n    const { data, error } = await this.client\n      .from('profiles')\n      .update({ username })\n      .eq('id', id)\n      .select(\n        `*,\n        tags:profile_tags_relations(\n          profile_tags(\n            id,\n            name\n          )\n        ),\n        badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url,\n            premium_tier\n          )\n        )`,\n      )\n      .single();\n\n    if (error) {\n      throw error;\n    }\n\n    return new ProfileFactory(this.client).fromDB(data as unknown as DBProfile);\n  }\n\n  async updateProfile(id: number, values: ProfileDTO) {\n    const types = await new ProfileTypesServiceServer(this.client).get();\n    const typeId = types.find((x) => x.name === values.type)!.id;\n    const existingProfile = await this.getById(id);\n    const pinModeration = this.determinePinModeration(types, existingProfile!, values.type);\n\n    await this.updateTags(id, values.tagIds || []);\n\n    const { data, error } = await this.client\n      .from('profiles')\n      .update({\n        about: values.about,\n        country: values.country,\n        display_name: values.displayName,\n        website: values.website,\n        is_contactable: values.isContactable,\n        profile_type: typeId,\n        photo: values.photo,\n        cover_images: values.coverImages,\n        visitor_policy: values.visitorPreferencePolicy\n          ? JSON.stringify({\n              policy: values.visitorPreferencePolicy,\n              details: values.visitorPreferenceDetails,\n            })\n          : null,\n      })\n      .eq('id', id)\n      .select(\n        `*,\n        tags:profile_tags_relations(\n          profile_tags(\n            id,\n            name\n          )\n        ),\n        badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url,\n            premium_tier\n          )\n        )`,\n      )\n      .single();\n    if (error) {\n      throw error;\n    }\n\n    if (pinModeration) {\n      await this.client.from('map_pins').update({ moderation: pinModeration }).eq('profile_id', id);\n    }\n\n    return new ProfileFactory(this.client).fromDB(data as unknown as DBProfile);\n  }\n\n  async updateTags(profileId: number, tagIds: number[]) {\n    const { data } = await this.client\n      .from('profile_tags_relations')\n      .select('*')\n      .eq('profile_id', profileId);\n\n    // Determine which tags to add and remove\n    const existingTagIds = data?.map((rel) => rel.profile_tag_id) || [];\n    const tagsToAdd = tagIds.filter((tagId) => !existingTagIds.includes(tagId));\n    const tagsToRemove = existingTagIds.filter((tagId) => !tagIds.includes(tagId));\n\n    // Remove tags that are no longer needed\n    if (tagsToRemove.length > 0) {\n      const { error } = await this.client\n        .from('profile_tags_relations')\n        .delete()\n        .eq('profile_id', profileId)\n        .in('profile_tag_id', tagsToRemove);\n\n      if (error) {\n        console.error(error);\n      }\n    }\n\n    // Add new tags\n    if (tagsToAdd.length > 0) {\n      const newRelations = tagsToAdd.map((tagId) => ({\n        profile_id: profileId,\n        profile_tag_id: tagId,\n        tenant_id: process.env.TENANT_ID,\n      }));\n\n      const { error } = await this.client.from('profile_tags_relations').insert(newRelations);\n\n      if (error) {\n        console.error(error);\n      }\n    }\n  }\n\n  async ensureProfile(user: User) {\n    const { data } = await this.client\n      .from('profiles')\n      .select('id')\n      .eq('auth_id', user.id)\n      .limit(1);\n\n    if (data?.at(0)) {\n      return;\n    }\n\n    // Doesn't exist - create it (without username; user sets it in settings)\n    const profileType = await this.client\n      .from('profile_types')\n      .select('id')\n      .eq('is_space', false)\n      .limit(1);\n    const { error } = await this.client.from('profiles').insert({\n      auth_id: user.id,\n      display_name: '',\n      is_contactable: true,\n      profile_type: profileType.data?.at(0)?.id || null,\n      tenant_id: process.env.TENANT_ID,\n    });\n\n    if (error) {\n      console.error('Error creating profile for user:', error);\n    }\n  }\n\n  async updateUserActivity(userId: string) {\n    return await this.client\n      .from('profiles')\n      .update({ last_active: new Date().toISOString() })\n      .eq('auth_id', userId);\n  }\n\n  /**\n   * Calculate the moderation status for a profile's map pin based on profile type changes.\n   *    If a profile changes from a space to 'member', the pin is automatically accepted.\n   *    If it changes from 'member' to a space, the pin requires moderation.\n   */\n  private determinePinModeration(types: ProfileType[], profile: DBProfile, type: string) {\n    if (!profile.pin) {\n      return undefined;\n    }\n\n    const selectedType = types.find((x) => x.name === type);\n    const currentType = types.find((x) => x.id === profile.profile_type);\n\n    let newValue: 'accepted' | 'awaiting-moderation' | undefined = undefined;\n\n    if (!selectedType || !currentType) {\n      return undefined;\n    }\n    if (currentType.isSpace && !selectedType.isSpace) {\n      newValue = 'accepted';\n    }\n    if (!currentType.isSpace && selectedType.isSpace) {\n      newValue = 'awaiting-moderation';\n    }\n\n    if (newValue === profile.pin.moderation) {\n      return undefined;\n    }\n\n    return newValue;\n  }\n}\n"
  },
  {
    "path": "src/services/profileService.ts",
    "content": "import {\n  DBMedia,\n  type IImpactDataField,\n  type IUserImpact,\n  type MapPin,\n  type MapPinFormData,\n  type Profile,\n  type ProfileDTO,\n  type ProfileFormData,\n} from 'oa-shared';\nimport { logger } from 'src/logger';\nimport { createFormData } from './formDataHelper';\n\nconst get = async (): Promise<Profile | undefined> => {\n  try {\n    const url = new URL('/api/profile', window.location.origin);\n\n    const response = await fetch(url);\n\n    return (await response.json()) as Profile;\n  } catch (error) {\n    logger.error('Failed to fetch profile', { error });\n  }\n};\n\nconst update = async (value: ProfileFormData) => {\n  const url = new URL('/api/profile', window.location.origin);\n  const data = createFormData<ProfileDTO>({\n    displayName: value.displayName,\n    about: value.about,\n    country: value.country,\n    type: value.type.toString(),\n    isContactable: value.isContactable,\n    website: value.website,\n    showVisitorPolicy: value.showVisitorPolicy,\n    visitorPreferenceDetails: value.showVisitorPolicy ? value.visitorPreferenceDetails : undefined,\n    visitorPreferencePolicy: value.showVisitorPolicy ? value.visitorPreferencePolicy || null : null,\n    tagIds: value.tagIds && value.tagIds.length > 0 ? value.tagIds : null,\n    photo: value.photo ? DBMedia.fromPublicMedia(value.photo) : null,\n    coverImages:\n      value.coverImages && value.coverImages.length > 0\n        ? value.coverImages.map(DBMedia.fromPublicMedia)\n        : null,\n  });\n\n  const response = await fetch(url, {\n    body: data,\n    method: 'POST',\n  });\n\n  if (!response.ok) {\n    const errorData = await response.json().catch(() => ({ error: 'Failed to update profile' }));\n    const errorMessage = errorData.error || errorData.message || 'Failed to update profile';\n    throw new Error(errorMessage);\n  }\n\n  const result = (await response.json()) as Profile | null;\n\n  if (!result) {\n    throw new Error('Failed to update profile');\n  }\n\n  return result;\n};\n\nconst updateUsername = async (username: string): Promise<Profile> => {\n  const url = new URL('/api/profile/username', window.location.origin);\n\n  const response = await fetch(url, {\n    body: JSON.stringify({ username }),\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n  });\n\n  if (!response.ok) {\n    const errorData = await response.json().catch(() => ({ error: 'Failed to update username' }));\n    const errorMessage = errorData.error || errorData.message || 'Failed to update username';\n    throw new Error(errorMessage);\n  }\n\n  const result = (await response.json()) as Profile | null;\n\n  if (!result) {\n    throw new Error('Failed to update username');\n  }\n\n  return result;\n};\n\nconst upsertPin = async (pin: MapPinFormData): Promise<MapPin> => {\n  const data = createFormData<MapPinFormData>({\n    name: pin.name,\n    country: pin.country,\n    countryCode: pin.countryCode,\n    administrative: pin.administrative || '',\n    postCode: pin.postCode || '',\n    lat: pin.lat,\n    lng: pin.lng,\n  });\n\n  const response = await fetch(`/api/settings/map`, {\n    method: 'POST',\n    body: data,\n  });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error saving research' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving research';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  const { mapPin } = await response.json();\n\n  return mapPin as MapPin;\n};\n\nconst deletePin = async () => {\n  const response = await fetch(`/api/settings/map`, {\n    method: 'DELETE',\n  });\n\n  if (!response.ok) {\n    const errorData = await response.json().catch(() => ({ error: 'Failed to delete map pin' }));\n    const errorMessage = errorData.error || errorData.message || 'Failed to delete map pin';\n    throw new Error(errorMessage);\n  }\n\n  return;\n};\n\nconst updateImpact = async (year: number, fields: IImpactDataField[]): Promise<IUserImpact> => {\n  const data = new FormData();\n\n  data.append('year', year.toString());\n  data.append('fields', JSON.stringify(fields));\n\n  const response = await fetch('/api/settings/impact', {\n    method: 'POST',\n    body: data,\n  });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error saving research' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving research';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  const { impact } = await response.json();\n\n  return JSON.parse(impact) as IUserImpact;\n};\n\nexport const profileService = {\n  get,\n  update,\n  updateUsername,\n  upsertPin,\n  deletePin,\n  updateImpact,\n};\n"
  },
  {
    "path": "src/services/profileTagsService.ts",
    "content": "import type { ProfileTag } from 'oa-shared';\n\nconst getAllTags = async () => {\n  try {\n    const response = await fetch('/api/profile-tags');\n\n    const profileTags = (await response.json()) as ProfileTag[];\n\n    return profileTags;\n  } catch (error) {\n    console.error({ error });\n    return [];\n  }\n};\n\nexport const profileTagsService = {\n  getAllTags,\n};\n"
  },
  {
    "path": "src/services/profileTypesService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport Keyv from 'keyv';\nimport { ProfileType } from 'oa-shared';\nimport { isProductionEnvironment } from 'src/config/config';\n\nconst cache = new Keyv<ProfileType[]>({ ttl: 3600000 }); // ttl: 60 minutes\n\nexport class ProfileTypesServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async get(cached = true) {\n    if (cached) {\n      const cachedProfileTypes = await cache.get('profile-types');\n\n      if (\n        cachedProfileTypes &&\n        Array.isArray(cachedProfileTypes) &&\n        cachedProfileTypes.length &&\n        isProductionEnvironment()\n      ) {\n        return cachedProfileTypes;\n      }\n    }\n\n    const profileTypesResult = await this.client.from('profile_types').select(`\n      id,\n      name,\n      display_name,\n      order,\n      image_url,\n      small_image_url,\n      description,\n      map_pin_name,\n      is_space\n      `);\n\n    const dbProfileTypes = profileTypesResult.data || [];\n    const profileTypes = dbProfileTypes.map((x) => ProfileType.fromDB(x));\n\n    await cache.set('profile-types', profileTypes);\n\n    return profileTypes;\n  }\n}\n"
  },
  {
    "path": "src/services/profileTypesService.ts",
    "content": "import type { ProfileType } from 'oa-shared';\n\nconst getProfileTypes = async () => {\n  try {\n    const response = await fetch('/api/profile-types', {\n      cache: 'force-cache',\n      headers: {\n        'Cache-Control': 'max-age=1800', // 30 minutes\n      },\n    });\n\n    return ((await response.json()) as ProfileType[]) || [];\n  } catch (error) {\n    console.error({ error });\n    return [];\n  }\n};\n\nexport const profileTypesService = {\n  getProfileTypes,\n};\n"
  },
  {
    "path": "src/services/profilesService.ts",
    "content": "import type { Profile } from 'oa-shared';\nimport { logger } from 'src/logger';\n\nconst search = async (q: string) => {\n  try {\n    const url = new URL('/api/profiles', window.location.origin);\n    url.searchParams.append('q', q);\n\n    const response = await fetch(url);\n\n    return (await response.json()) as Profile[];\n  } catch (error) {\n    logger.error('Failed to fetch profiles', { error });\n    return [];\n  }\n};\n\nexport const profilesService = {\n  search,\n};\n"
  },
  {
    "path": "src/services/questionService.server.test.ts",
    "content": "import { QuestionServiceServer } from 'src/services/questionService.server';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst mockClient: any = {\n  rpc: vi.fn(),\n};\n\ndescribe('getQuestionsByUser', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('returns array of questions', async () => {\n    const id = 20;\n    const title = 'this is a test question?';\n    const slug = 'this-is-a-test-question';\n    const usefulCount = 1;\n\n    const mockDBQuestion = {\n      id: id,\n      comment_count: 1,\n      images: null,\n      title: title,\n      slug: slug,\n      total_useful: usefulCount,\n    };\n\n    const mockResponse = {\n      error: null,\n      data: [mockDBQuestion],\n      count: null,\n      status: 200,\n      statusText: 'OK',\n    };\n\n    const expected = [\n      {\n        id: id,\n        commentCount: 1,\n        images: null,\n        title: title,\n        slug: slug,\n        usefulCount: usefulCount,\n      },\n    ];\n\n    mockClient.rpc.mockResolvedValueOnce(mockResponse);\n\n    const result = await new QuestionServiceServer(mockClient).getQuestionsByUser('testuser');\n\n    expect(mockClient.rpc).toHaveBeenCalledWith('get_user_questions', {\n      username_param: 'testuser',\n    });\n    expect(result).toEqual(expected);\n  });\n\n  it('returns empty array on error', async () => {\n    const mockResponse = {\n      error: new Error('RPC failed'),\n      data: null,\n    };\n\n    mockClient.rpc.mockResolvedValueOnce(mockResponse);\n\n    const result = await new QuestionServiceServer(mockClient).getQuestionsByUser('testuser');\n\n    expect(result).toEqual([]);\n  });\n\n  it('returns empty array on empty response', async () => {\n    const mockResponse = {\n      error: null,\n      data: [],\n      count: null,\n      status: 200,\n      statusText: 'OK',\n    };\n\n    mockClient.rpc.mockResolvedValueOnce(mockResponse);\n\n    const result = await new QuestionServiceServer(mockClient).getQuestionsByUser('testuser');\n\n    expect(result).toEqual([]);\n  });\n});\n"
  },
  {
    "path": "src/services/questionService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBQuestion, Question } from 'oa-shared';\nimport { ImageServiceServer } from './imageService.server';\n\nexport class QuestionServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async getById(id: number): Promise<DBQuestion> {\n    const result = await this.client.from('questions').select().eq('id', id).single();\n    return result.data as DBQuestion;\n  }\n\n  async getBySlug(slug: string) {\n    return this.client\n      .from('questions')\n      .select(\n        `\n         id,\n         created_at,\n         created_by,\n         is_draft,\n         modified_at,\n         published_at,\n         comment_count,\n         description,\n         moderation,\n         slug,\n         category:category(id,name),\n         tags,\n         title,\n         total_views,\n         images,\n         author:profiles(id, display_name, username, photo, country, badges:profile_badges_relations(\n            profile_badges(\n              id,\n              name,\n              display_name,\n              image_url,\n              action_url\n            )\n          ))\n       `,\n      )\n      .or(`slug.eq.${slug},previous_slugs.cs.{\"${slug}\"}`)\n      .or('deleted.eq.false,deleted.is.null')\n      .single();\n  }\n\n  async getQuestionsByUser(username: string): Promise<Partial<Question>[]> {\n    const imageService = new ImageServiceServer(this.client);\n    const functionResult = await this.client.rpc('get_user_questions', {\n      username_param: username,\n    });\n\n    if (functionResult.error || functionResult.count === 0) {\n      console.error('Error fetching user questions:', functionResult.error);\n      return [];\n    }\n\n    const items = functionResult.data.map((x) => {\n      const images = x.images ? imageService.getPublicUrls(x.images) : null;\n      return {\n        id: x.id,\n        commentCount: x.comment_count,\n        images,\n        title: x.title,\n        slug: x.slug,\n        usefulCount: x.total_useful,\n      };\n    });\n\n    return items;\n  }\n}\n"
  },
  {
    "path": "src/services/questionService.ts",
    "content": "import type { QuestionDTO, QuestionFormData } from 'oa-shared';\nimport { DBMedia, DBQuestion, Question } from 'oa-shared';\nimport { createFormData } from './formDataHelper';\n\nconst upsert = async (id: number | null, question: QuestionFormData) => {\n  const body = createFormData<QuestionDTO>({\n    title: question.title,\n    description: question.description,\n    category: Number(question.category?.value) || null,\n    images: question.images?.length ? question.images.map(DBMedia.fromPublicMedia) : null,\n    isDraft: question.isDraft,\n    tags: question.tags,\n  });\n\n  const response =\n    id === null\n      ? await fetch(`/api/questions`, {\n          method: 'POST',\n          body,\n        })\n      : await fetch(`/api/questions/${id}`, {\n          method: 'PUT',\n          body,\n        });\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error saving question' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving question';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  const newQuestion = await response.json();\n\n  return Question.fromDB(new DBQuestion(newQuestion.question), []);\n};\n\nexport const questionService = {\n  upsert,\n};\n"
  },
  {
    "path": "src/services/redirectService.server.ts",
    "content": "import { redirect } from 'react-router';\n\nconst redirectSignIn = (returnUrl: string, headers: HeadersInit) => {\n  return redirect(`/sign-in?returnUrl=${encodeURIComponent(returnUrl)}`, {\n    headers,\n  });\n};\n\nexport const redirectServiceServer = {\n  redirectSignIn,\n};\n"
  },
  {
    "path": "src/services/researchService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport {\n  Author,\n  DBAuthor,\n  DBProfile,\n  DBResearchItem,\n  DBResearchUpdate,\n  ResearchItem,\n  UserRole,\n} from 'oa-shared';\nimport { IMAGE_SIZES } from 'src/config/imageTransforms';\nimport { ImageServiceServer } from './imageService.server';\nimport { ProfileServiceServer } from './profileService.server';\nimport { StorageServiceServer } from './storageService.server';\n\nexport class ResearchServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async getBySlug(slug: string) {\n    const { data, error } = await this.client\n      .from('research')\n      .select(\n        `\n       id,\n       created_at,\n       created_by,\n       modified_at,\n       published_at,\n       title,\n       description,\n       slug,\n       image,\n       category:category(id,name),\n       tags,\n       total_views,\n       status,\n       is_draft,\n       collaborators,\n       author:profiles(id, display_name, username, photo, country, donations_enabled,\n          profile_type(id, display_name, name, is_space, image_url, small_image_url),\n          badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url\n          )\n        )),\n       updates:research_updates(\n        id,\n        created_at,\n        modified_at,\n        published_at,\n        title,\n        description,\n        images,\n        files,\n        file_link,\n        file_download_count,\n        video_url,\n        is_draft,\n        comment_count,\n        deleted,\n        update_author:profiles(id, display_name, username, photo, country, badges:profile_badges_relations(\n          profile_badges(\n            id,\n            name,\n            display_name,\n            image_url,\n            action_url\n          )\n        ))\n      )\n     `,\n      )\n      .or(`slug.eq.${slug},previous_slugs.cs.{\"${slug}\"}`)\n      .or('deleted.eq.false,deleted.is.null')\n      .single();\n\n    if (!data || error) {\n      return { error };\n    }\n\n    const item = data as unknown as DBResearchItem;\n    let collaborators: Author[] | undefined = [];\n\n    if (item?.collaborators?.length) {\n      // potential improvement: could make an rpc query for the whole research + collaborators instead of 2 queries\n      collaborators = await this.getCollaborators(item.collaborators);\n    }\n\n    return { item, collaborators, error };\n  }\n\n  private async getCollaborators(collaboratorIds: string[] | null) {\n    if (collaboratorIds === null || collaboratorIds.length === 0) {\n      return [];\n    }\n\n    const profileService = new ProfileServiceServer(this.client);\n    const users = await profileService.getUsersByUsername(collaboratorIds);\n\n    return users?.map((user) => Author.fromDB(user as unknown as DBAuthor)) || [];\n  }\n\n  async getUpdate(researchId: number, updateId: number) {\n    return this.client\n      .from('research_updates')\n      .select(\n        'id, research_id, created_at, modified_at, published_at, title, description, images, file_ids, file_link, video_url, is_draft, comment_count, deleted',\n      )\n      .eq('id', updateId)\n      .eq('research_id', researchId)\n      .single();\n  }\n\n  async getUserResearch(username: string): Promise<Partial<ResearchItem>[]> {\n    const imageService = new ImageServiceServer(this.client);\n    const { data, error } = await this.client.rpc('get_user_research', {\n      username_param: username,\n    });\n\n    if (error) {\n      console.error('Error fetching user research:', error);\n      return [];\n    }\n\n    return data?.map((x) => {\n      const image = x.image ? imageService.getPublicUrl(x.image) : null;\n      return {\n        id: x.id,\n        // commentCount: x.comment_count,\n        image,\n        title: x.title,\n        slug: x.slug,\n        usefulCount: x.total_useful,\n      };\n    });\n  }\n\n  getResearchPublicMedia(researchDb: DBResearchItem) {\n    const allImages = researchDb.updates?.flatMap((x) => x.images)?.filter((x) => !!x) || [];\n    if (researchDb.image) {\n      allImages.push(researchDb.image);\n    }\n\n    return allImages\n      ? new StorageServiceServer(this.client).getPublicUrls(allImages, IMAGE_SIZES.LANDSCAPE)\n      : [];\n  }\n\n  async isAllowedToEditResearch(research: DBResearchItem, profile: DBProfile) {\n    if (profile.id === research.author?.id) {\n      return true;\n    }\n\n    if (\n      profile.username &&\n      Array.isArray(research.collaborators) &&\n      research.collaborators.includes(profile.username)\n    ) {\n      return true;\n    }\n\n    return profile.roles?.includes(UserRole.ADMIN);\n  }\n\n  async isAllowedToEditResearchById(id: number, profile: DBProfile) {\n    const researchResult = await this.client\n      .from('research')\n      .select('id,created_by,collaborators,author:profiles(id,username)')\n      .eq('id', id)\n      .single();\n\n    const research = researchResult.data as unknown as DBResearchItem;\n\n    return this.isAllowedToEditResearch(research, profile);\n  }\n\n  async getById(id: number) {\n    const result = await this.client.from('research').select().eq('id', id).single();\n    return result.data as DBResearchItem;\n  }\n\n  async getUpdateById(id: number) {\n    const result = await this.client.from('research_updates').select().eq('id', id).single();\n    return result.data as DBResearchUpdate;\n  }\n\n  async isAllowedToEditUpdate(profile: DBProfile | null, researchId: number, updateId: number) {\n    const research = await this.getById(researchId);\n    const researchUpdate = await this.getUpdateById(updateId);\n\n    if (research.id !== researchUpdate.research_id) {\n      return false;\n    }\n\n    return (\n      profile &&\n      (profile.id === research.author?.id ||\n        (profile.username && research.collaborators?.includes(profile.username)) ||\n        profile?.roles?.includes(UserRole.ADMIN))\n    );\n  }\n}\n"
  },
  {
    "path": "src/services/storageService.server.ts",
    "content": "import type { TransformOptions } from '@supabase/storage-js';\nimport type { SupabaseClient } from '@supabase/supabase-js';\nimport type { DBMedia } from 'oa-shared';\nimport { Image, MediaFile } from 'oa-shared';\nimport sharp from 'sharp';\n\nexport class StorageServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  getPublicUrl(image: DBMedia, size?: TransformOptions): Image | null {\n    try {\n      const { data } = this.client.storage.from(process.env.TENANT_ID as string).getPublicUrl(\n        image.path,\n        size\n          ? {\n              transform: size,\n            }\n          : undefined,\n      );\n      return new Image({\n        id: image.id,\n        publicUrl: data.publicUrl,\n      });\n    } catch (_) {\n      // Skip null images - don't add to result\n    }\n\n    return null;\n  }\n\n  getPublicUrls(images: DBMedia[], size?: TransformOptions): Image[] {\n    const result: Image[] = [];\n\n    for (const x of images || []) {\n      try {\n        const { data } = this.client.storage.from(process.env.TENANT_ID as string).getPublicUrl(\n          x.path,\n          size\n            ? {\n                transform: size,\n              }\n            : undefined,\n        );\n\n        result.push(\n          new Image({\n            id: x.id,\n            publicUrl: data.publicUrl,\n          }),\n        );\n      } catch (_) {\n        // Skip null images - don't add to result\n      }\n    }\n\n    return result;\n  }\n\n  async uploadImage(\n    files: File[],\n    path: string,\n  ): Promise<{\n    media: DBMedia[];\n    errors: string[];\n  }> {\n    const errors: string[] = [];\n    const media: DBMedia[] = [];\n\n    for (const file of files) {\n      try {\n        const arrayBuffer = await file.arrayBuffer();\n        const buffer = Buffer.from(arrayBuffer);\n\n        // Determine format and dimensions\n        const metadata = await sharp(buffer).metadata();\n\n        // Check if image needs processing\n        // Always process JPEG/PNG for WebP conversion\n        // Process other formats if: dimensions too large OR file size > 1MB\n        const isJpegOrPng =\n          metadata.format === 'jpeg' || metadata.format === 'jpg' || metadata.format === 'png';\n        const needsProcessing =\n          isJpegOrPng ||\n          (metadata.width &&\n            metadata.height &&\n            (metadata.width > 2048 || metadata.height > 2048)) ||\n          buffer.length > 1024 * 1024; // 1MB in bytes\n\n        let finalBuffer: Buffer;\n        let finalContentType = file.type;\n        let finalFileName = file.name;\n\n        if (needsProcessing) {\n          let processedImage = sharp(buffer);\n\n          // Only resize if dimensions exceed limits\n          const needsResize =\n            metadata.width && metadata.height && (metadata.width > 2048 || metadata.height > 2048);\n\n          if (needsResize) {\n            processedImage = processedImage.resize(2048, 2048, {\n              fit: 'inside',\n              withoutEnlargement: true,\n              kernel: 'lanczos3', // High-quality resizing\n            });\n          }\n\n          switch (metadata.format) {\n            case 'jpeg':\n            case 'jpg':\n              // Convert JPEG to WebP for better compression (25-35% smaller)\n              processedImage = processedImage.webp({\n                quality: 82, // Slightly higher quality for JPEG conversions\n                alphaQuality: 100,\n                effort: 6,\n                smartSubsample: true,\n              });\n              finalContentType = 'image/webp';\n              // Update filename extension from .jpg/.jpeg to .webp\n              finalFileName = file.name.replace(/\\.(jpg|jpeg)$/i, '.webp');\n              break;\n            case 'png':\n              // Convert PNG to WebP for better compression (25-35% smaller)\n              processedImage = processedImage.webp({\n                quality: 85, // Slightly higher quality for PNG conversions\n                alphaQuality: 100, // Preserve transparency quality\n                effort: 6, // Maximum compression effort\n                smartSubsample: true,\n              });\n              finalContentType = 'image/webp';\n              // Update filename extension from .png to .webp\n              finalFileName = file.name.replace(/\\.png$/i, '.webp');\n              break;\n            case 'webp':\n              processedImage = processedImage.webp({\n                quality: 80, // WebP is efficient, 80 provides excellent quality\n                alphaQuality: 100, // Preserve transparency quality\n                effort: 6, // Maximum compression effort (0-6)\n                smartSubsample: true, // Better quality/size balance\n              });\n              finalContentType = 'image/webp';\n              break;\n            case 'gif':\n              // Keep GIF format to preserve animations and transparency\n              processedImage = processedImage.gif();\n              finalContentType = 'image/gif';\n              break;\n            case 'tiff':\n              processedImage = processedImage.tiff({\n                compression: 'lzw', // Lossless compression\n                quality: 85,\n              });\n              finalContentType = 'image/tiff';\n              break;\n            case 'avif':\n              processedImage = processedImage.avif({\n                quality: 80,\n                effort: 6,\n              });\n              finalContentType = 'image/avif';\n              break;\n            default:\n              // Keep original format for other types (preserves transparency, animations, etc.)\n              processedImage = processedImage.toFormat(metadata.format as any);\n          }\n\n          const processedBuffer = await processedImage.toBuffer();\n\n          // Use processed version only if it's smaller, otherwise keep original\n          if (processedBuffer.length < buffer.length) {\n            finalBuffer = processedBuffer;\n          } else {\n            finalBuffer = buffer;\n            finalContentType = file.type; // Keep original content type\n            finalFileName = file.name; // Keep original filename\n          }\n        } else {\n          // Non-JPEG/PNG image already optimized (dimensions ≤ 2048x2048 and size ≤ 1MB)\n          finalBuffer = buffer;\n        }\n\n        const result = await this.client.storage\n          .from(process.env.TENANT_ID as string)\n          .upload(`${path}/${finalFileName}`, finalBuffer, {\n            upsert: true,\n            contentType: finalContentType,\n          });\n\n        if (result.data === null) {\n          errors.push(file.name + ': ' + result.error?.message);\n          continue;\n        }\n\n        media.push(result.data);\n      } catch (error) {\n        errors.push(file.name + ': ' + (error instanceof Error ? error.message : 'Unknown error'));\n      }\n    }\n\n    return { media, errors };\n  }\n\n  async uploadFile(\n    files: File[],\n    path: string,\n  ): Promise<{\n    media: MediaFile[];\n    errors: string[];\n  } | null> {\n    if (!files || files.length === 0) {\n      return null;\n    }\n\n    const errors: string[] = [];\n    const media: MediaFile[] = [];\n\n    for (const file of files) {\n      // Check if file exists and determine unique filename if needed\n      let fileName = file.name;\n      const fileInfo = await this.client.storage\n        .from(process.env.TENANT_ID + '-documents')\n        .info(`${path}/${fileName}`);\n\n      if (fileInfo.data) {\n        // File exists, find a unique name\n        const lastDotIndex = file.name.lastIndexOf('.');\n        const nameWithoutExt = lastDotIndex > 0 ? file.name.substring(0, lastDotIndex) : file.name;\n        const extension = lastDotIndex > 0 ? file.name.substring(lastDotIndex) : '';\n        let i = 1;\n        let uniqueFileInfo;\n\n        do {\n          fileName = `${nameWithoutExt}-${i}${extension}`;\n          uniqueFileInfo = await this.client.storage\n            .from(process.env.TENANT_ID + '-documents')\n            .info(`${path}/${fileName}`);\n          i++;\n        } while (uniqueFileInfo.data);\n      }\n\n      const result = await this.client.storage\n        .from(process.env.TENANT_ID + '-documents')\n        .upload(`${path}/${fileName}`, file);\n\n      if (result.data === null) {\n        errors.push(result.error.message);\n        continue;\n      }\n\n      media.push({\n        id: result.data.id,\n        name: result.data.path.split('/').at(-1)!,\n        size: file.size,\n      });\n    }\n\n    return { media, errors };\n  }\n\n  async removeFiles(paths: string[]): Promise<void> {\n    await this.client.storage.from(process.env.TENANT_ID + '-documents').remove(paths);\n  }\n\n  async removeImages(paths: string[]): Promise<void> {\n    await this.client.storage.from(process.env.TENANT_ID as string).remove(paths);\n  }\n\n  async getPathDocuments(path: string, mapUrlPrefix: string): Promise<MediaFile[]> {\n    const documentsBucket = process.env.TENANT_ID + '-documents';\n\n    const { data, error } = await this.client.storage.from(documentsBucket).list(path);\n\n    if (!data || error) {\n      return [];\n    }\n\n    return data?.map(\n      (x) =>\n        new MediaFile({\n          id: x.id,\n          name: x.name,\n          size: x.metadata.size,\n          url: `${mapUrlPrefix}/${x.id}`,\n        }),\n    );\n  }\n\n  async moveImage(\n    sourcePath: string,\n    destinationPath: string,\n    fileName: string,\n  ): Promise<DBMedia | null> {\n    const bucket = process.env.TENANT_ID as string;\n    const fullDestinationPath = `${destinationPath}/${fileName}`;\n\n    try {\n      // Copy the file to the new location\n      const { data: copyData, error: copyError } = await this.client.storage\n        .from(bucket)\n        .copy(sourcePath, fullDestinationPath);\n\n      if (copyError) {\n        console.error('Error copying file:', copyError);\n        return null;\n      }\n\n      // Delete the original file\n      await this.client.storage.from(bucket).remove([sourcePath]);\n\n      return {\n        id: fullDestinationPath,\n        path: copyData.path,\n        fullPath: fullDestinationPath,\n      };\n    } catch (error) {\n      console.error('Error moving image:', error);\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "src/services/storageService.ts",
    "content": "import type { ContentType, IMediaFile, MediaWithPublicUrl } from 'oa-shared';\n\ntype ImageFolder = ContentType | 'profiles';\n\nconst imageUpload = async (id: number | null, contentType: ImageFolder, imageFile: File) => {\n  const body = new FormData();\n  if (id) {\n    body.append('id', id.toString());\n  }\n  body.append('contentType', contentType);\n  body.append('imageFile', imageFile, getCleanFileName(imageFile.name));\n\n  const response = await fetch(`/api/images`, {\n    method: 'POST',\n    body,\n  });\n\n  if (response.status === 413) {\n    throw new Error('The image is too large, the maximum allowed is 10MB', {\n      cause: response.status,\n    });\n  }\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error saving image' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving image';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  const data: { image } = await response.json();\n  return data.image as MediaWithPublicUrl;\n};\n\nconst fileUpload = async (id: number | null, contentType: ContentType, file: File) => {\n  const body = new FormData();\n  if (id) {\n    body.append('id', id.toString());\n  }\n  body.append('contentType', contentType);\n  body.append('file', file, getCleanFileName(file.name));\n\n  const response = await fetch(`/api/documents`, {\n    method: 'POST',\n    body,\n  });\n\n  if (response.status === 413) {\n    throw new Error('The file is too large', {\n      cause: response.status,\n    });\n  }\n\n  if (response.status !== 200 && response.status !== 201) {\n    const errorData = await response.json().catch(() => ({ error: 'Error saving file' }));\n    const errorMessage = errorData.error || errorData.message || 'Error saving file';\n    throw new Error(errorMessage, { cause: response.status });\n  }\n\n  const data: { document: IMediaFile } = await response.json();\n  return data.document;\n};\n\nconst getCleanFileName = (fileName: string) => {\n  return fileName.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/_{2,}/g, '_'); // replace special characters with underscore\n};\n\nexport const storageService = {\n  imageUpload,\n  fileUpload,\n};\n"
  },
  {
    "path": "src/services/subscribersService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport type {\n  DBResearchItem,\n  ResearchItem,\n  ResearchUpdate,\n  SubscribableContentTypes,\n} from 'oa-shared';\n\nexport class SubscribersServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async combineSubscribers(\n    ids: (number | null | undefined)[],\n    usernames: string[],\n  ): Promise<number[]> {\n    const profilesToSubscribe: number[] = ids.map((id) => Number(id));\n\n    for (const username of usernames) {\n      const { data } = await this.client\n        .from('profiles')\n        .select('id')\n        .eq('username', username)\n        .single();\n      if (data && !!Number(data.id)) {\n        profilesToSubscribe.push(Number(data.id));\n      }\n    }\n\n    const uniqueIdSet = new Set([...profilesToSubscribe]);\n\n    return [...uniqueIdSet];\n  }\n\n  async addResearchSubscribers(research: ResearchItem, profileId: number) {\n    const subscribers = await this.combineSubscribers(\n      [profileId],\n      research.collaboratorsUsernames || [],\n    );\n    return Promise.all([\n      subscribers.map((subscriber) => {\n        this.add('research', research.id, subscriber);\n      }),\n    ]);\n  }\n\n  async addResearchUpdateSubscribers(update: ResearchUpdate, profileId: number) {\n    const subscribers = await this.combineSubscribers(\n      [profileId, update.research?.created_by],\n      update.research?.collaborators || [],\n    );\n    return Promise.all([\n      subscribers.map((subscriber) => {\n        this.add('research_updates', update.id, subscriber);\n      }),\n    ]);\n  }\n\n  async add(contentType: SubscribableContentTypes, contentId: number, profileId: number) {\n    try {\n      const response = await this.client\n        .from('subscribers')\n        .select('*')\n        .eq('content_type', contentType)\n        .eq('content_id', contentId)\n        .eq('user_id', profileId)\n        .single();\n\n      if (response.data) {\n        // already exists\n        return;\n      }\n\n      await this.client.from('subscribers').insert({\n        content_type: contentType,\n        content_id: contentId,\n        user_id: profileId,\n        tenant_id: process.env.TENANT_ID!,\n      });\n    } catch (error) {\n      console.error(error);\n    }\n  }\n\n  async updateResearchSubscribers(oldResearch: DBResearchItem, newResearch: ResearchItem) {\n    const oldCollaborators = oldResearch.collaborators || [];\n    const newCollaborators = newResearch.collaboratorsUsernames || [];\n\n    const newCollaboratorUsernames = newCollaborators.filter(\n      (username) => !oldCollaborators.includes(username),\n    );\n\n    if (newCollaboratorUsernames.length === 0) {\n      return;\n    }\n\n    const subscribers = await this.combineSubscribers([], newCollaboratorUsernames);\n    return Promise.all([\n      subscribers.map((subscriber) => {\n        this.add('research', newResearch.id, subscriber);\n      }),\n    ]);\n  }\n}\n"
  },
  {
    "path": "src/services/subscribersService.ts",
    "content": "import type { SubscribableContentTypes } from 'oa-shared';\n\nconst add = async (contentType: SubscribableContentTypes, id: number) => {\n  return await fetch(`/api/subscribers/${contentType}/${id}`, {\n    method: 'POST',\n    body: JSON.stringify({}),\n  });\n};\n\nconst remove = async (contentType: SubscribableContentTypes, id: number) => {\n  return await fetch(`/api/subscribers/${contentType}/${id}`, {\n    method: 'DELETE',\n  });\n};\n\nconst isSubscribed = async (contentType: SubscribableContentTypes, id: number) => {\n  try {\n    const response = await fetch(`/api/subscribers/${contentType}/${id}/subscribed`);\n\n    const { subscribed } = await response.json();\n\n    return !!subscribed;\n  } catch (error) {\n    console.error(error);\n    return false;\n  }\n};\n\nexport const subscribersService = {\n  add,\n  remove,\n  isSubscribed,\n};\n"
  },
  {
    "path": "src/services/tagsService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport { Tag } from 'oa-shared';\n\nexport class TagsServiceServer {\n  constructor(private client: SupabaseClient) {}\n\n  async getTags(tagIds: number[]) {\n    let tags: Tag[] = [];\n\n    if (tagIds?.length > 0) {\n      const tagsResult = await this.client\n        .from('tags')\n        .select('id,name,created_at,modified_at')\n        .in('id', tagIds);\n\n      if (tagsResult.data) {\n        tags = tagsResult.data.map((x) => Tag.fromDB(x));\n      }\n    }\n\n    return tags;\n  }\n}\n"
  },
  {
    "path": "src/services/tagsService.ts",
    "content": "import type { Tag } from 'oa-shared';\n\nconst getAllTags = async () => {\n  try {\n    const response = await fetch('/api/tags');\n    return (await response.json()) as Tag[];\n  } catch (error) {\n    console.error({ error });\n    return [];\n  }\n};\n\nexport const tagsService = {\n  getAllTags,\n};\n"
  },
  {
    "path": "src/services/tenantSettingsService.server.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport Keyv from 'keyv';\nimport { PWAIcons, TenantSettings, UserRole } from 'oa-shared';\nimport { isProductionEnvironment } from 'src/config/config';\n\nconst cache = new Keyv<TenantSettings>({ ttl: 3600000 }); // ttl: 60 minutes\n\nexport class TenantSettingsService {\n  constructor(private client: SupabaseClient) {}\n\n  async get(cacheBypass = false): Promise<TenantSettings> {\n    if (!cacheBypass) {\n      const cachedTenantSettings = await cache.get('tenant-settings');\n\n      if (cachedTenantSettings && isProductionEnvironment()) {\n        return cachedTenantSettings;\n      }\n    }\n\n    const { data } = await this.client\n      .from('tenant_settings')\n      .select(\n        `site_name,\n        site_description,\n        site_url,\n        message_sign_off,\n        email_from,\n        site_image,\n        no_messaging,\n        library_heading,\n        academy_resource,\n        profile_guidelines,\n        questions_guidelines,\n        supported_modules,\n        patreon_id,\n        color_primary,\n        color_primary_hover,\n        color_accent,\n        color_accent_hover,\n        show_impact,\n        create_research_roles,\n        ga_tracking_id,\n        pwa_icons`,\n      )\n      .single();\n\n    const settings = new TenantSettings({\n      siteName: data?.site_name || 'The Community Platform',\n      siteDescription: data?.site_description || 'The Community Platform',\n      siteUrl: data?.site_url || 'https://community.preciousplastic.com',\n      messageSignOff: data?.message_sign_off || 'One Army',\n      emailFrom: data?.email_from || 'hello@onearmy.earth',\n      siteImage:\n        data?.site_image || 'https://community.preciousplastic.com/assets/img/one-army-logo.png',\n      noMessaging: data?.no_messaging || false,\n      academyResource: data?.academy_resource,\n      libraryHeading: data?.library_heading,\n      patreonId: data?.patreon_id,\n      profileGuidelines: data?.profile_guidelines,\n      questionsGuidelines: data?.questions_guidelines,\n      supportedModules: data?.supported_modules,\n      colorPrimary: data?.color_primary,\n      colorPrimaryHover: data?.color_primary_hover,\n      colorAccent: data?.color_accent,\n      colorAccentHover: data?.color_accent_hover,\n      showImpact: data?.show_impact,\n      createResearchRoles: this.validateRoles(data?.create_research_roles),\n      gaTrackingId: data?.ga_tracking_id,\n      pwaIcons: (data?.pwa_icons as PWAIcons) ?? undefined,\n    });\n\n    cache.set('tenant-settings', settings);\n\n    return settings;\n  }\n\n  private validateRoles(\n    create_research_roles: string[] | undefined | null,\n  ): UserRole[] | undefined {\n    if (!create_research_roles || create_research_roles.length === 0) {\n      return undefined;\n    }\n\n    const validRoles = Object.values(UserRole);\n    const validated = create_research_roles.filter((role) =>\n      validRoles.includes(role as UserRole),\n    ) as UserRole[];\n\n    return validated.length > 0 ? validated : undefined;\n  }\n}\n"
  },
  {
    "path": "src/services/upgradeBadgeService.ts",
    "content": "import type { UpgradeBadge } from 'oa-shared';\nimport { logger } from 'src/logger';\n\nconst getUpgradeBadges = async () => {\n  try {\n    const response = await fetch(`/api/upgrade-badges`, {\n      cache: 'force-cache',\n      headers: {\n        'Cache-Control': 'max-age=1800', // 30 minutes\n      },\n    });\n    return (await response.json()) as UpgradeBadge[];\n  } catch (error) {\n    logger.error('Failed to fetch upgrade badges', { error });\n    return [];\n  }\n};\n\nexport const upgradeBadgeService = {\n  getUpgradeBadges,\n};\n"
  },
  {
    "path": "src/services/usefulService.ts",
    "content": "import type { ProfileListItem, UsefulContentType } from 'oa-shared';\n\nconst add = async (contentType: UsefulContentType, id: number) => {\n  return await fetch(`/api/useful/${contentType}/${id}`, {\n    method: 'POST',\n    body: JSON.stringify({}),\n  });\n};\n\nconst remove = async (contentType: UsefulContentType, id: number) => {\n  return await fetch(`/api/useful/${contentType}/${id}`, {\n    method: 'DELETE',\n  });\n};\n\nconst hasVoted = async (contentType: UsefulContentType, id: number) => {\n  try {\n    const response = await fetch(`/api/useful/${contentType}/${id}/voted`);\n\n    const { voted } = await response.json();\n\n    return !!voted;\n  } catch (error) {\n    console.error(error);\n    return false;\n  }\n};\n\nconst usefulVoters = async (\n  contentType: UsefulContentType,\n  id: number,\n): Promise<ProfileListItem[]> => {\n  try {\n    const response = await fetch(`/api/useful/${contentType}/${id}/users`);\n\n    const users = await response.json();\n\n    return users;\n  } catch (error) {\n    console.error(error);\n    return [];\n  }\n};\n\nexport const usefulService = {\n  add,\n  remove,\n  hasVoted,\n  usefulVoters,\n};\n"
  },
  {
    "path": "src/stores/Profile/profile.store.test.ts",
    "content": "import { UpgradeBadge } from 'oa-shared';\nimport { factoryImage, FactoryUser } from 'src/test/factories/User';\nimport { describe, expect, it } from 'vitest';\n\nimport { ProfileStore } from './profile.store';\n\nimport type { Profile, ProfileType } from 'oa-shared';\n\ndescribe('ProfileStore', () => {\n  const { isProfileComplete, getMissingFields } = new ProfileStore();\n  describe('upgradeBadgeForCurrentUser', () => {\n    const createMockUpgradeBadge = (badgeId: number, isSpace: boolean, label: string) =>\n      UpgradeBadge.fromDB({\n        id: badgeId,\n        tenant_id: 'test-tenant',\n        badge_id: badgeId,\n        is_space: isSpace,\n        action_label: label,\n        action_url: 'https://example.com',\n        badge: {\n          id: badgeId,\n          name: 'pro',\n          display_name: 'PRO',\n          image_url: 'https://example.com/badge.png',\n          action_url: 'https://example.com',\n          premium_tier: 1,\n        },\n      });\n\n    it('returns undefined when profile is not set', () => {\n      const store = new ProfileStore();\n      store.upgradeBadges = [createMockUpgradeBadge(1, false, 'Go PRO')];\n\n      expect(store.upgradeBadgeForCurrentUser).toBeUndefined();\n    });\n\n    it('returns undefined when upgradeBadges is not set', () => {\n      const store = new ProfileStore();\n      store.profile = { type: { isSpace: false }, badges: [] } as any;\n\n      expect(store.upgradeBadgeForCurrentUser).toBeUndefined();\n    });\n\n    it('returns undefined when user already has the badge', () => {\n      const store = new ProfileStore();\n      store.profile = {\n        type: { isSpace: false },\n        badges: [{ id: 1 }],\n      } as any;\n      store.upgradeBadges = [createMockUpgradeBadge(1, false, 'Go PRO')];\n\n      expect(store.upgradeBadgeForCurrentUser).toBeUndefined();\n    });\n\n    it('returns upgrade badge when workspace does not have the badge', () => {\n      const store = new ProfileStore();\n      store.profile = {\n        type: { isSpace: true },\n        badges: [],\n      } as any;\n      store.upgradeBadges = [createMockUpgradeBadge(1, true, 'Go PRO')];\n\n      const result = store.upgradeBadgeForCurrentUser;\n      expect(result).toBeDefined();\n      expect(result?.actionLabel).toBe('Go PRO');\n    });\n\n    it('returns undefined for members when only workspace badge exists', () => {\n      const store = new ProfileStore();\n      store.profile = {\n        type: { isSpace: false },\n        badges: [],\n      } as any;\n      store.upgradeBadges = [createMockUpgradeBadge(1, true, 'Go PRO')];\n\n      const result = store.upgradeBadgeForCurrentUser;\n      expect(result).toBeUndefined(); // member doesn't see workspace badge\n    });\n  });\n\n  describe('isProfileComplete', () => {\n    describe('member', () => {\n      it('returns true for a completed profile', () => {\n        const completeProfile: Partial<Profile> = {\n          about: 'A member',\n          displayName: 'Jeffo',\n          type: { id: 1, name: 'member' } as ProfileType,\n          photo: factoryImage,\n        };\n        const user = FactoryUser(completeProfile);\n\n        expect(isProfileComplete(user)).toBe(true);\n      });\n\n      describe('returns false if any core field is missing', () => {\n        it('no about', () => {\n          const missingAbout: Partial<Profile> = {\n            displayName: 'Jeffo',\n            type: { id: 1, name: 'member' } as ProfileType,\n            photo: factoryImage,\n          };\n          const user = FactoryUser(missingAbout);\n\n          expect(isProfileComplete(user)).toBe(false);\n        });\n        it('no displayName', () => {\n          const missingDisplayName: Partial<Profile> = {\n            about: 'A member',\n            displayName: undefined,\n            type: { id: 1, name: 'member' } as ProfileType,\n            photo: factoryImage,\n          };\n          const user = FactoryUser(missingDisplayName);\n\n          expect(isProfileComplete(user)).toBe(false);\n        });\n        it('no userImage', () => {\n          const missingUserImage: Partial<Profile> = {\n            about: 'A member',\n            displayName: 'Jeffo',\n            type: { id: 1, name: 'member' } as ProfileType,\n            photo: undefined,\n          };\n          const user = FactoryUser(missingUserImage);\n\n          expect(isProfileComplete(user)).toBe(false);\n        });\n      });\n    });\n\n    describe('space', () => {\n      it('returns true for a completed profile', () => {\n        const completeProfile: Partial<Profile> = {\n          about: 'An important space',\n          displayName: 'Jeffo',\n          type: { id: 1, name: 'community-builder' } as ProfileType,\n          coverImages: [factoryImage],\n        };\n        const user = FactoryUser(completeProfile);\n\n        expect(isProfileComplete(user)).toBe(true);\n      });\n\n      describe('returns false if any core field is missing', () => {\n        it('no about', () => {\n          const missingAbout: Partial<Profile> = {\n            displayName: 'Jeffo',\n            type: { id: 1, name: 'community-builder' } as ProfileType,\n            coverImages: [factoryImage],\n          };\n          const user = FactoryUser(missingAbout);\n\n          expect(isProfileComplete(user)).toBe(false);\n        });\n        it('no displayName', () => {\n          const missingDisplayName: Partial<Profile> = {\n            about: 'An important space',\n            displayName: undefined,\n            type: { id: 1, name: 'community-builder' } as ProfileType,\n            coverImages: [factoryImage],\n          };\n          const user = FactoryUser(missingDisplayName);\n\n          expect(isProfileComplete(user)).toBe(false);\n        });\n        it('no userImage', () => {\n          const missingUserImage: Partial<Profile> = {\n            about: 'An important space',\n            displayName: 'Jeffo',\n            type: { id: 1, name: 'community-builder' } as ProfileType,\n            coverImages: [],\n          };\n          const user = FactoryUser(missingUserImage);\n\n          expect(isProfileComplete(user)).toBe(false);\n        });\n      });\n    });\n    it('returns false if profile type missing', () => {\n      const missingProfileType: Partial<Profile> = {\n        about: 'An unknown...',\n        displayName: 'Jeffo',\n        type: undefined,\n        photo: factoryImage,\n      };\n      const user = FactoryUser(missingProfileType);\n\n      expect(isProfileComplete(user)).toBe(false);\n    });\n  });\n\n  describe('getMissingProfileFields', () => {\n    describe('member', () => {\n      it('returns empty array for complete profile', () => {\n        const completeProfile: Partial<Profile> = {\n          about: 'A member',\n          displayName: 'Jeffo',\n          type: { id: 1, name: 'member' } as ProfileType,\n          photo: factoryImage,\n        };\n        const user = FactoryUser(completeProfile);\n\n        expect(getMissingFields(user)).toEqual([]);\n      });\n\n      it('returns missing fields for incomplete profile', () => {\n        const incompleteProfile: Partial<Profile> = {\n          displayName: undefined,\n          about: undefined,\n          type: { id: 1, name: 'member' } as ProfileType,\n          photo: undefined,\n        };\n        const user = FactoryUser(incompleteProfile);\n\n        const missing = getMissingFields(user);\n        expect(missing).toContain('Display name');\n        expect(missing).toContain('About');\n        expect(missing).toContain('Profile photo');\n      });\n\n      it('returns only missing about', () => {\n        const profile: Partial<Profile> = {\n          displayName: 'Jeffo',\n          about: undefined,\n          type: { id: 1, name: 'member' } as ProfileType,\n          photo: factoryImage,\n        };\n        const user = FactoryUser(profile);\n\n        expect(getMissingFields(user)).toEqual(['About']);\n      });\n    });\n\n    describe('space', () => {\n      it('returns empty array for complete profile', () => {\n        const completeProfile: Partial<Profile> = {\n          about: 'An important space',\n          displayName: 'Jeffo',\n          type: { id: 1, name: 'community-builder' } as ProfileType,\n          coverImages: [factoryImage],\n        };\n        const user = FactoryUser(completeProfile);\n\n        expect(getMissingFields(user)).toEqual([]);\n      });\n\n      it('returns missing fields for incomplete profile', () => {\n        const incompleteProfile: Partial<Profile> = {\n          displayName: undefined,\n          about: undefined,\n          type: { id: 1, name: 'community-builder' } as ProfileType,\n          coverImages: [],\n        };\n        const user = FactoryUser(incompleteProfile);\n\n        const missing = getMissingFields(user);\n        expect(missing).toContain('Display name');\n        expect(missing).toContain('About');\n        expect(missing).toContain('Cover image');\n      });\n\n      it('returns only missing cover image', () => {\n        const profile: Partial<Profile> = {\n          about: 'An important space',\n          displayName: 'Jeffo',\n          type: { id: 1, name: 'community-builder' } as ProfileType,\n          coverImages: [],\n        };\n        const user = FactoryUser(profile);\n\n        expect(getMissingFields(user)).toEqual(['Cover image']);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "src/stores/Profile/profile.store.tsx",
    "content": "import { action, computed, makeObservable, observable, runInAction } from 'mobx';\nimport type { IUserImpact, Profile, ProfileType, UpgradeBadge, UserRole } from 'oa-shared';\nimport { createContext, useContext, useEffect } from 'react';\nimport { SessionContext } from 'src/pages/common/SessionContext';\nimport { profileService } from 'src/services/profileService';\nimport { profileTypesService } from 'src/services/profileTypesService';\nimport { upgradeBadgeService } from 'src/services/upgradeBadgeService';\n\nexport class ProfileStore {\n  profile?: Profile = undefined;\n  profileTypes?: ProfileType[] = undefined;\n  upgradeBadges?: UpgradeBadge[] = undefined;\n\n  refresh = async () => {\n    const profile = await profileService.get();\n    runInAction(() => {\n      // runInAction because of async method\n      this.profile = profile;\n    });\n  };\n\n  clear = () => {\n    this.profile = undefined;\n  };\n\n  update = (value: Profile) => {\n    this.profile = value;\n  };\n\n  initProfileTypes = async () => {\n    const profileTypes = await profileTypesService.getProfileTypes();\n\n    runInAction(() => {\n      // runInAction because of async method\n      this.profileTypes = profileTypes;\n    });\n  };\n\n  initUpgradeBadges = async () => {\n    const upgradeBadges = await upgradeBadgeService.getUpgradeBadges();\n\n    runInAction(() => {\n      this.upgradeBadges = upgradeBadges;\n    });\n  };\n\n  updateImpact = async (impact: IUserImpact) => {\n    this.profile!.impact = impact;\n  };\n\n  getProfileTypeByName = (name: string) => {\n    return this.profileTypes?.find((type) => type.name === name);\n  };\n\n  isUserAuthorized = (roleRequired?: UserRole | UserRole[]) => {\n    const userRoles = this.profile?.roles || [];\n\n    // If no role required just check if user is logged in\n    if (!roleRequired || roleRequired.length === 0) {\n      return this.profile ? true : false;\n    }\n\n    const rolesRequired = Array.isArray(roleRequired) ? roleRequired : [roleRequired];\n\n    // otherwise use logged in user profile values\n    if (this.profile && roleRequired) {\n      return userRoles.some((role) => rolesRequired.includes(role as UserRole));\n    }\n\n    return false;\n  };\n\n  constructor() {\n    makeObservable(this, {\n      profile: observable,\n      profileTypes: observable,\n      upgradeBadges: observable,\n      upgradeBadgeForCurrentUser: computed,\n      isComplete: computed,\n      missingFields: computed,\n      refresh: action,\n      clear: action,\n      update: action,\n      initProfileTypes: action,\n      initUpgradeBadges: action,\n      updateImpact: action,\n    });\n  }\n\n  get upgradeBadgeForCurrentUser() {\n    if (!this.profile || !this.upgradeBadges || !Array.isArray(this.upgradeBadges)) {\n      return undefined;\n    }\n\n    const isSpace = this.profile.type?.isSpace || false;\n    const upgradeBadge = this.upgradeBadges.find((badge) => badge.isSpace === isSpace);\n\n    const userBadgeIds = this.profile.badges?.map((badge) => badge.id) || [];\n    const hasUpgradeBadge = upgradeBadge ? userBadgeIds.includes(upgradeBadge.badgeId) : false;\n\n    return hasUpgradeBadge ? undefined : upgradeBadge;\n  }\n\n  get isComplete() {\n    if (!this.profile) {\n      return null;\n    }\n\n    return this.isProfileComplete(this.profile);\n  }\n\n  get missingFields() {\n    if (!this.profile) {\n      return null;\n    }\n\n    return this.getMissingFields(this.profile);\n  }\n\n  getMissingFields(profile: Partial<Profile>) {\n    const { about, coverImages, displayName, photo, username } = profile;\n    const missing: string[] = [];\n\n    if (!username) {\n      missing.push('Username');\n    }\n\n    if (!displayName) {\n      missing.push('Display name');\n    }\n\n    if (!about) {\n      missing.push('About');\n    }\n\n    const isMember = profile.type?.name === 'member';\n\n    if (isMember && !photo?.id) {\n      missing.push('Profile photo');\n    } else if (!isMember && (!coverImages || !coverImages[0]?.publicUrl)) {\n      missing.push('Cover image');\n    }\n\n    return missing;\n  }\n\n  isProfileComplete(profile: Partial<Profile>) {\n    const { about, coverImages, displayName, photo, username } = profile;\n\n    const isBasicInfoFilled = !!(username && about && displayName);\n\n    const isMember = profile.type?.name === 'member';\n    const isSpace = !isMember;\n\n    const isMemberFilled = isMember && !!photo?.id;\n    const isSpaceFilled = isSpace && !!coverImages && !!coverImages[0]?.publicUrl;\n\n    return isBasicInfoFilled && (isMemberFilled || isSpaceFilled);\n  }\n}\n\nconst profileStore = new ProfileStore();\n\nconst ProfileStoreContext = createContext<ProfileStore | null>(null);\n\nexport const ProfileStoreProvider = ({ children }: { children: React.ReactNode }) => {\n  const claims = useContext(SessionContext);\n\n  useEffect(() => {\n    if (!claims?.sub) {\n      profileStore.clear();\n      return;\n    }\n\n    profileStore.refresh();\n  }, [claims?.sub]);\n\n  useEffect(() => {\n    profileStore.initProfileTypes();\n    profileStore.initUpgradeBadges();\n  }, []);\n\n  return (\n    <ProfileStoreContext.Provider value={profileStore}>{children}</ProfileStoreContext.Provider>\n  );\n};\n\nexport const useProfileStore = () => {\n  const store = useContext(ProfileStoreContext);\n\n  if (!store) {\n    throw new Error('useProfileStore must be used within ProfileStoreProvider');\n  }\n\n  return store;\n};\n"
  },
  {
    "path": "src/stores/Subscription/subscription.store.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\nimport { subscribersService } from 'src/services/subscribersService';\nimport { SubscriptionStore } from './subscription.store';\n\nvi.mock('src/services/subscribersService');\nvi.mock('src/common/Analytics', () => ({\n  trackEvent: vi.fn(),\n}));\n\ndescribe('SubscriptionStore', () => {\n  let store: SubscriptionStore;\n\n  beforeEach(() => {\n    store = new SubscriptionStore();\n    vi.clearAllMocks();\n  });\n\n  describe('checkAndCacheSubscription', () => {\n    it('should fetch and cache subscription status', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockResolvedValue(true);\n\n      const result = await store.checkAndCacheSubscription('comments', 123);\n\n      expect(result).toBe(true);\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n      expect(subscribersService.isSubscribed).toHaveBeenCalledWith('comments', 123);\n      expect(subscribersService.isSubscribed).toHaveBeenCalledTimes(1);\n    });\n\n    it('should return cached value on subsequent calls', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockResolvedValue(true);\n\n      const result1 = await store.checkAndCacheSubscription('comments', 123);\n      const result2 = await store.checkAndCacheSubscription('comments', 123);\n\n      expect(result1).toBe(true);\n      expect(result2).toBe(true);\n      expect(subscribersService.isSubscribed).toHaveBeenCalledTimes(1);\n    });\n\n    it('should prevent duplicate API calls when called simultaneously', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(true), 100)));\n\n      const [result1, result2] = await Promise.all([\n        store.checkAndCacheSubscription('comments', 123),\n        store.checkAndCacheSubscription('comments', 123),\n      ]);\n\n      expect(result1).toBe(true);\n      expect(result2).toBe(true);\n      expect(subscribersService.isSubscribed).toHaveBeenCalledTimes(1);\n    });\n\n    it('should handle API errors gracefully', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockRejectedValue(new Error('API Error'));\n\n      const result = await store.checkAndCacheSubscription('comments', 123);\n\n      expect(result).toBe(false);\n      expect(store.isSubscribed('comments', 123)).toBe(false);\n    });\n\n    it('should cache different content types separately', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockResolvedValueOnce(true).mockResolvedValueOnce(false);\n\n      const result1 = await store.checkAndCacheSubscription('comments', 123);\n      const result2 = await store.checkAndCacheSubscription('research', 123);\n\n      expect(result1).toBe(true);\n      expect(result2).toBe(false);\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n      expect(store.isSubscribed('research', 123)).toBe(false);\n    });\n  });\n\n  describe('getSubscriptionState', () => {\n    it('should return undefined for uncached items', () => {\n      const state = store.isSubscribed('comments', 123);\n      expect(state).toBeUndefined();\n    });\n\n    it('should return cached subscription state', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockResolvedValue(true);\n\n      await store.checkAndCacheSubscription('comments', 123);\n      const state = store.isSubscribed('comments', 123);\n\n      expect(state).toBe(true);\n    });\n  });\n\n  describe('isLoading', () => {\n    it('should return false for uncached items', () => {\n      const loading = store.isLoading('comments', 123);\n      expect(loading).toBe(false);\n    });\n\n    it('should return true while fetching', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(true), 100)));\n\n      const promise = store.checkAndCacheSubscription('comments', 123);\n      expect(store.isLoading('comments', 123)).toBe(true);\n\n      await promise;\n      expect(store.isLoading('comments', 123)).toBe(false);\n    });\n  });\n\n  describe('subscribe', () => {\n    it('should subscribe and update cache optimistically', async () => {\n      vi.mocked(subscribersService.add).mockResolvedValue({ ok: true } as Response);\n\n      const success = await store.subscribe('comments', 123);\n\n      expect(success).toBe(true);\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n      expect(subscribersService.add).toHaveBeenCalledWith('comments', 123);\n    });\n\n    it('should rollback on failed subscribe', async () => {\n      vi.mocked(subscribersService.add).mockResolvedValue({ ok: false } as Response);\n\n      const success = await store.subscribe('comments', 123);\n\n      expect(success).toBe(false);\n      expect(store.isSubscribed('comments', 123)).toBe(false);\n    });\n\n    it('should handle API errors and rollback', async () => {\n      vi.mocked(subscribersService.add).mockRejectedValue(new Error('Network error'));\n\n      const success = await store.subscribe('comments', 123);\n\n      expect(success).toBe(false);\n      expect(store.isSubscribed('comments', 123)).toBe(false);\n    });\n  });\n\n  describe('unsubscribe', () => {\n    it('should unsubscribe and update cache optimistically', async () => {\n      vi.mocked(subscribersService.remove).mockResolvedValue({ ok: true } as Response);\n\n      // Set initial state as subscribed\n      await store.subscribe('comments', 123);\n      vi.mocked(subscribersService.add).mockResolvedValue({ ok: true } as Response);\n\n      const success = await store.unsubscribe('comments', 123);\n\n      expect(success).toBe(true);\n      expect(store.isSubscribed('comments', 123)).toBe(false);\n      expect(subscribersService.remove).toHaveBeenCalledWith('comments', 123);\n    });\n\n    it('should rollback on failed unsubscribe', async () => {\n      vi.mocked(subscribersService.remove).mockResolvedValue({ ok: false } as Response);\n\n      const success = await store.unsubscribe('comments', 123);\n\n      expect(success).toBe(false);\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n    });\n\n    it('should handle API errors and rollback', async () => {\n      vi.mocked(subscribersService.remove).mockRejectedValue(new Error('Network error'));\n\n      const success = await store.unsubscribe('comments', 123);\n\n      expect(success).toBe(false);\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n    });\n  });\n\n  describe('toggleSubscription', () => {\n    it('should subscribe when not currently subscribed', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockResolvedValue(false);\n      vi.mocked(subscribersService.add).mockResolvedValue({ ok: true } as Response);\n\n      await store.toggleSubscription('comments', 123);\n\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n    });\n\n    it('should unsubscribe when currently subscribed', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockResolvedValue(true);\n      vi.mocked(subscribersService.remove).mockResolvedValue({ ok: true } as Response);\n\n      await store.toggleSubscription('comments', 123);\n\n      expect(store.isSubscribed('comments', 123)).toBe(false);\n    });\n\n    it('should toggle cached subscription state', async () => {\n      vi.mocked(subscribersService.add).mockResolvedValue({ ok: true } as Response);\n      vi.mocked(subscribersService.remove).mockResolvedValue({ ok: true } as Response);\n\n      // Subscribe first\n      await store.subscribe('comments', 123);\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n\n      // Toggle should unsubscribe\n      await store.toggleSubscription('comments', 123);\n      expect(store.isSubscribed('comments', 123)).toBe(false);\n\n      // Toggle again should subscribe\n      await store.toggleSubscription('comments', 123);\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n    });\n  });\n\n  describe('clearCache', () => {\n    it('should clear all cached subscriptions', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockResolvedValue(true);\n\n      await store.checkAndCacheSubscription('comments', 123);\n      await store.checkAndCacheSubscription('research', 456);\n\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n      expect(store.isSubscribed('research', 456)).toBe(true);\n\n      store.clearCache();\n\n      expect(store.isSubscribed('comments', 123)).toBeUndefined();\n      expect(store.isSubscribed('research', 456)).toBeUndefined();\n    });\n  });\n\n  describe('preloadSubscriptions', () => {\n    it('should fetch multiple subscriptions in parallel', async () => {\n      vi.mocked(subscribersService.isSubscribed).mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true);\n\n      await store.preloadSubscriptions([\n        { contentType: 'comments', itemId: 123 },\n        { contentType: 'research', itemId: 456 },\n        { contentType: 'news', itemId: 789 },\n      ]);\n\n      expect(store.isSubscribed('comments', 123)).toBe(true);\n      expect(store.isSubscribed('research', 456)).toBe(false);\n      expect(store.isSubscribed('news', 789)).toBe(true);\n      expect(subscribersService.isSubscribed).toHaveBeenCalledTimes(3);\n    });\n  });\n});\n"
  },
  {
    "path": "src/stores/Subscription/subscription.store.tsx",
    "content": "import { action, makeObservable, observable, runInAction } from 'mobx';\nimport type { SubscribableContentTypes } from 'oa-shared';\nimport { createContext, useContext, useEffect } from 'react';\nimport { trackEvent } from 'src/common/Analytics';\nimport { subscribersService } from 'src/services/subscribersService';\nimport { useProfileStore } from '../Profile/profile.store';\n\ntype SubscriptionKey = `${SubscribableContentTypes}-${number}`;\n\ninterface SubscriptionState {\n  isSubscribed: boolean;\n  isLoading: boolean;\n}\n\nexport class SubscriptionStore {\n  subscriptions: Map<SubscriptionKey, SubscriptionState> = new Map();\n\n  constructor() {\n    makeObservable(this, {\n      subscriptions: observable,\n      checkAndCacheSubscription: action,\n      subscribe: action,\n      unsubscribe: action,\n      clearCache: action,\n    });\n  }\n\n  private getCacheKey(contentType: SubscribableContentTypes, itemId: number): SubscriptionKey {\n    return `${contentType}-${itemId}`;\n  }\n\n  isSubscribed(contentType: SubscribableContentTypes, itemId: number): boolean | undefined {\n    const key = this.getCacheKey(contentType, itemId);\n    const state = this.subscriptions.get(key);\n    return state?.isSubscribed;\n  }\n\n  isLoading(contentType: SubscribableContentTypes, itemId: number): boolean {\n    const key = this.getCacheKey(contentType, itemId);\n    return this.subscriptions.get(key)?.isLoading ?? false;\n  }\n\n  async checkAndCacheSubscription(\n    contentType: SubscribableContentTypes,\n    itemId: number,\n  ): Promise<boolean> {\n    const key = this.getCacheKey(contentType, itemId);\n    const existing = this.subscriptions.get(key);\n\n    if (existing && !existing.isLoading) {\n      return existing.isSubscribed;\n    }\n\n    if (existing?.isLoading) {\n      // Poll until loading is complete\n      return new Promise((resolve) => {\n        const checkInterval = setInterval(() => {\n          const state = this.subscriptions.get(key);\n          if (state && !state.isLoading) {\n            clearInterval(checkInterval);\n            resolve(state.isSubscribed);\n          }\n        }, 50);\n      });\n    }\n\n    this.subscriptions.set(key, { isSubscribed: false, isLoading: true });\n\n    try {\n      const isSubscribed = await subscribersService.isSubscribed(contentType, itemId);\n\n      runInAction(() => {\n        this.subscriptions.set(key, { isSubscribed, isLoading: false });\n      });\n\n      return isSubscribed;\n    } catch (error) {\n      console.error('Failed to check subscription:', error);\n      runInAction(() => {\n        this.subscriptions.set(key, { isSubscribed: false, isLoading: false });\n      });\n      return false;\n    }\n  }\n\n  async subscribe(contentType: SubscribableContentTypes, itemId: number): Promise<boolean> {\n    const key = this.getCacheKey(contentType, itemId);\n\n    // Optimistic update\n    this.subscriptions.set(key, {\n      isSubscribed: true,\n      isLoading: true,\n    });\n\n    try {\n      const response = await subscribersService.add(contentType, itemId);\n\n      if (!response.ok) {\n        throw new Error('Failed to subscribe');\n      }\n\n      runInAction(() => {\n        this.subscriptions.set(key, { isSubscribed: true, isLoading: false });\n      });\n\n      trackEvent({\n        category: contentType,\n        action: 'subscribed',\n        label: `${itemId}`,\n      });\n\n      return true;\n    } catch (error) {\n      console.error('Failed to subscribe:', error);\n\n      // Rollback optimistic update\n      runInAction(() => {\n        this.subscriptions.set(key, {\n          isSubscribed: false,\n          isLoading: false,\n        });\n      });\n\n      return false;\n    }\n  }\n\n  async unsubscribe(contentType: SubscribableContentTypes, itemId: number): Promise<boolean> {\n    const key = this.getCacheKey(contentType, itemId);\n\n    // Optimistic update\n    this.subscriptions.set(key, {\n      isSubscribed: false,\n      isLoading: true,\n    });\n\n    try {\n      const response = await subscribersService.remove(contentType, itemId);\n\n      if (!response.ok) {\n        throw new Error('Failed to unsubscribe');\n      }\n\n      runInAction(() => {\n        this.subscriptions.set(key, { isSubscribed: false, isLoading: false });\n      });\n\n      trackEvent({\n        category: contentType,\n        action: 'unsubscribed',\n        label: `${itemId}`,\n      });\n\n      return true;\n    } catch (error) {\n      console.error('Failed to unsubscribe:', error);\n\n      // Rollback optimistic update\n      runInAction(() => {\n        this.subscriptions.set(key, {\n          isSubscribed: true,\n          isLoading: false,\n        });\n      });\n\n      return false;\n    }\n  }\n\n  async toggleSubscription(\n    contentType: SubscribableContentTypes,\n    itemId: number,\n  ): Promise<boolean> {\n    const currentState = this.isSubscribed(contentType, itemId);\n\n    if (currentState === undefined) {\n      // Not loaded yet, check first\n      const isSubscribed = await this.checkAndCacheSubscription(contentType, itemId);\n      return isSubscribed\n        ? this.unsubscribe(contentType, itemId)\n        : this.subscribe(contentType, itemId);\n    }\n\n    return currentState\n      ? this.unsubscribe(contentType, itemId)\n      : this.subscribe(contentType, itemId);\n  }\n\n  clearCache() {\n    this.subscriptions.clear();\n  }\n\n  // TODO: have an endpoint to fetch multiple subscriptions\n  async preloadSubscriptions(\n    items: Array<{ contentType: SubscribableContentTypes; itemId: number }>,\n  ): Promise<void> {\n    await Promise.all(\n      items.map((item) => this.checkAndCacheSubscription(item.contentType, item.itemId)),\n    );\n  }\n}\n\n// Singleton instance\nconst subscriptionStore = new SubscriptionStore();\nconst SubscriptionStoreContext = createContext<SubscriptionStore | null>(null);\n\nexport const SubscriptionStoreProvider = ({ children }: { children: React.ReactNode }) => {\n  const { profile } = useProfileStore();\n\n  useEffect(() => {\n    // Clear cache when user logs out\n    if (!profile) {\n      subscriptionStore.clearCache();\n    }\n  }, [profile]);\n\n  return (\n    <SubscriptionStoreContext.Provider value={subscriptionStore}>\n      {children}\n    </SubscriptionStoreContext.Provider>\n  );\n};\n\nexport const useSubscriptionStore = () => {\n  const store = useContext(SubscriptionStoreContext);\n\n  if (!store) {\n    throw new Error('useSubscriptionStore must be used within SubscriptionStoreProvider');\n  }\n\n  return store;\n};\n"
  },
  {
    "path": "src/stores/Subscription/useSubscription.tsx",
    "content": "import type { SubscribableContentTypes } from 'oa-shared';\nimport { useEffect } from 'react';\nimport { useProfileStore } from '../Profile/profile.store';\nimport { useSubscriptionStore } from './subscription.store';\n\n/**\n * Custom hook to manage subscription state for a specific content item.\n * Automatically loads subscription status when user is logged in.\n *\n * @example\n * ```tsx\n * const { isSubscribed, toggle, isLoading } = useSubscription('comments', comment.id);\n *\n * return (\n *   <FollowButton\n *     isFollowing={isSubscribed ?? false}\n *     onFollowClick={toggle}\n *   />\n * );\n * ```\n */\nexport const useSubscription = (contentType: SubscribableContentTypes, itemId: number) => {\n  const subscriptionStore = useSubscriptionStore();\n  const { profile } = useProfileStore();\n\n  // Auto-load subscription status if user is logged in\n  useEffect(() => {\n    if (profile) {\n      subscriptionStore.checkAndCacheSubscription(contentType, itemId);\n    }\n  }, [profile, contentType, itemId, subscriptionStore]);\n\n  return {\n    isSubscribed: subscriptionStore.isSubscribed(contentType, itemId),\n    isLoading: subscriptionStore.isLoading(contentType, itemId),\n    subscribe: () => subscriptionStore.subscribe(contentType, itemId),\n    unsubscribe: () => subscriptionStore.unsubscribe(contentType, itemId),\n    toggle: () => subscriptionStore.toggleSubscription(contentType, itemId),\n  };\n};\n"
  },
  {
    "path": "src/stores/UsefulVote/useUsefulVote.tsx",
    "content": "import type { UsefulContentType } from 'oa-shared';\nimport { useContext, useEffect } from 'react';\nimport { SessionContext } from 'src/pages/common/SessionContext';\nimport { useUsefulVoteStore } from './usefulVote.store';\n\nexport function useUsefulVote(\n  contentType: UsefulContentType,\n  contentId: number,\n  initialUsefulCount: number,\n) {\n  const store = useUsefulVoteStore();\n  const claims = useContext(SessionContext);\n\n  const isLoggedIn = !!claims?.sub;\n\n  useEffect(() => {\n    store.initializeVote(contentType, contentId, initialUsefulCount, isLoggedIn);\n  }, [store, contentType, contentId, initialUsefulCount, isLoggedIn]);\n\n  const voteState = store.getVoteState(contentType, contentId);\n  const hasVoted = voteState?.hasVoted ?? false;\n  const usefulCount = voteState?.usefulCount ?? initialUsefulCount;\n  const isLoading = voteState?.isLoading ?? false;\n\n  const toggle = async () => {\n    if (!isLoggedIn) {\n      return;\n    }\n    await store.toggleVote(contentType, contentId);\n  };\n\n  return {\n    hasVoted,\n    usefulCount,\n    isLoading,\n    toggle,\n  };\n}\n"
  },
  {
    "path": "src/stores/UsefulVote/usefulVote.store.tsx",
    "content": "import { action, makeObservable, observable, runInAction } from 'mobx';\nimport type { UsefulContentType } from 'oa-shared';\nimport { createContext, useContext, useEffect } from 'react';\nimport { trackEvent } from 'src/common/Analytics';\nimport { usefulService } from 'src/services/usefulService';\nimport { useProfileStore } from '../Profile/profile.store';\n\ntype UsefulVoteKey = `${UsefulContentType}-${number}`;\n\ninterface UsefulVoteState {\n  hasVoted: boolean;\n  usefulCount: number;\n  isLoading: boolean;\n}\n\nexport class UsefulVoteStore {\n  votes: Map<UsefulVoteKey, UsefulVoteState> = new Map();\n\n  constructor() {\n    makeObservable(this, {\n      votes: observable,\n      initializeVote: action,\n      toggleVote: action,\n      clearCache: action,\n    });\n  }\n\n  private getCacheKey(contentType: UsefulContentType, contentId: number): UsefulVoteKey {\n    return `${contentType}-${contentId}`;\n  }\n\n  getVoteState(contentType: UsefulContentType, contentId: number): UsefulVoteState | undefined {\n    const key = this.getCacheKey(contentType, contentId);\n    return this.votes.get(key);\n  }\n\n  hasVoted(contentType: UsefulContentType, contentId: number): boolean {\n    const key = this.getCacheKey(contentType, contentId);\n    return this.votes.get(key)?.hasVoted ?? false;\n  }\n\n  getUsefulCount(contentType: UsefulContentType, contentId: number): number {\n    const key = this.getCacheKey(contentType, contentId);\n    return this.votes.get(key)?.usefulCount ?? 0;\n  }\n\n  isLoading(contentType: UsefulContentType, contentId: number): boolean {\n    const key = this.getCacheKey(contentType, contentId);\n    return this.votes.get(key)?.isLoading ?? false;\n  }\n\n  async initializeVote(\n    contentType: UsefulContentType,\n    contentId: number,\n    initialUsefulCount: number,\n    isLoggedIn: boolean,\n  ): Promise<void> {\n    const key = this.getCacheKey(contentType, contentId);\n    const existing = this.votes.get(key);\n\n    // If already initialized and not loading, don't reinitialize\n    if (existing && !existing.isLoading) {\n      return;\n    }\n\n    // If already loading, wait for it to complete\n    if (existing?.isLoading) {\n      return new Promise((resolve) => {\n        const checkInterval = setInterval(() => {\n          const state = this.votes.get(key);\n          if (state && !state.isLoading) {\n            clearInterval(checkInterval);\n            resolve();\n          }\n        }, 50);\n      });\n    }\n\n    // Set initial state with loading\n    this.votes.set(key, {\n      hasVoted: false,\n      usefulCount: initialUsefulCount,\n      isLoading: true,\n    });\n\n    // Check if user has voted (backend will return false if not logged in)\n    try {\n      const hasVoted = isLoggedIn ? await usefulService.hasVoted(contentType, contentId) : false;\n\n      runInAction(() => {\n        const currentState = this.votes.get(key);\n        if (currentState) {\n          this.votes.set(key, {\n            ...currentState,\n            hasVoted,\n            isLoading: false,\n          });\n        }\n      });\n    } catch (error) {\n      console.error('Failed to check vote status:', error);\n      runInAction(() => {\n        const currentState = this.votes.get(key);\n        if (currentState) {\n          this.votes.set(key, {\n            ...currentState,\n            isLoading: false,\n          });\n        }\n      });\n    }\n  }\n\n  async toggleVote(contentType: UsefulContentType, contentId: number): Promise<boolean> {\n    const key = this.getCacheKey(contentType, contentId);\n    const currentState = this.votes.get(key);\n\n    if (!currentState) {\n      console.error('Vote state not initialized');\n      return false;\n    }\n\n    const newHasVoted = !currentState.hasVoted;\n    const newCount = newHasVoted ? currentState.usefulCount + 1 : currentState.usefulCount - 1;\n\n    // Optimistic update\n    this.votes.set(key, {\n      hasVoted: newHasVoted,\n      usefulCount: newCount,\n      isLoading: true,\n    });\n\n    try {\n      if (newHasVoted) {\n        await usefulService.add(contentType, contentId);\n      } else {\n        await usefulService.remove(contentType, contentId);\n      }\n\n      runInAction(() => {\n        this.votes.set(key, {\n          hasVoted: newHasVoted,\n          usefulCount: newCount,\n          isLoading: false,\n        });\n      });\n\n      // Track analytics\n      trackEvent({\n        category: contentType,\n        action: newHasVoted ? 'useful' : 'usefulRemoved',\n        label: `${contentId}`,\n      });\n\n      return true;\n    } catch (error) {\n      console.error('Failed to toggle vote:', error);\n\n      // Rollback optimistic update\n      runInAction(() => {\n        this.votes.set(key, {\n          hasVoted: currentState.hasVoted,\n          usefulCount: currentState.usefulCount,\n          isLoading: false,\n        });\n      });\n\n      return false;\n    }\n  }\n\n  clearCache() {\n    this.votes.clear();\n  }\n}\n\n// Singleton instance\nconst usefulVoteStore = new UsefulVoteStore();\nconst UsefulVoteStoreContext = createContext<UsefulVoteStore | null>(null);\n\nexport const UsefulVoteStoreProvider = ({ children }: { children: React.ReactNode }) => {\n  const { profile } = useProfileStore();\n\n  useEffect(() => {\n    // Clear cache when user logs out\n    if (!profile) {\n      usefulVoteStore.clearCache();\n    }\n  }, [profile]);\n\n  return (\n    <UsefulVoteStoreContext.Provider value={usefulVoteStore}>\n      {children}\n    </UsefulVoteStoreContext.Provider>\n  );\n};\n\nexport const useUsefulVoteStore = () => {\n  const store = useContext(UsefulVoteStoreContext);\n\n  if (!store) {\n    throw new Error('useUsefulVoteStore must be used within UsefulVoteStoreProvider');\n  }\n\n  return store;\n};\n"
  },
  {
    "path": "src/styles/context.ts",
    "content": "import { createContext } from 'react';\n\nexport type ServerStyleContextData = {\n  key: string;\n  ids: string[];\n  css: string;\n};\n\nexport const ServerStyleContext = createContext<ServerStyleContextData[] | null>(null);\n\nexport type ClientStyleContextData = {\n  reset: () => void;\n};\n\nexport const ClientStyleContext = createContext<ClientStyleContextData | null>(null);\n"
  },
  {
    "path": "src/styles/createEmotionCache.ts",
    "content": "import createCache from '@emotion/cache';\n\nexport const createEmotionCache = () => createCache({ key: 'css' });\n"
  },
  {
    "path": "src/styles/leaflet.css",
    "content": "/* required styles */\n\n.leaflet-pane,\n.leaflet-tile,\n.leaflet-marker-icon,\n.leaflet-marker-shadow,\n.leaflet-tile-container,\n.leaflet-pane > svg,\n.leaflet-pane > canvas,\n.leaflet-zoom-box,\n.leaflet-image-layer,\n.leaflet-layer {\n  position: absolute;\n  left: 0;\n  top: 0;\n}\n.leaflet-container {\n  overflow: hidden;\n}\n\n/* Match bg color with map */\n.markercluster-map.leaflet-container {\n  background-color: #d4dadc !important;\n}\n\n.leaflet-tile,\n.leaflet-marker-icon,\n.leaflet-marker-shadow {\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  user-select: none;\n  -webkit-user-drag: none;\n}\n/* Prevents IE11 from highlighting tiles in blue */\n.leaflet-tile::selection {\n  background: transparent;\n}\n/* Safari renders non-retina tile on retina better with this, but Chrome is worse */\n.leaflet-safari .leaflet-tile {\n  image-rendering: -webkit-optimize-contrast;\n}\n/* hack that prevents hw layers \"stretching\" when loading new tiles */\n.leaflet-safari .leaflet-tile-container {\n  width: 1600px;\n  height: 1600px;\n  -webkit-transform-origin: 0 0;\n}\n.leaflet-marker-icon,\n.leaflet-marker-shadow {\n  display: block;\n}\n/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */\n/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */\n.leaflet-container .leaflet-overlay-pane svg {\n  max-width: none !important;\n  max-height: none !important;\n}\n.leaflet-container .leaflet-marker-pane img,\n.leaflet-container .leaflet-shadow-pane img,\n.leaflet-container .leaflet-tile-pane img,\n.leaflet-container img.leaflet-image-layer,\n.leaflet-container .leaflet-tile {\n  max-width: none !important;\n  max-height: none !important;\n  width: auto;\n  padding: 0;\n}\n\n.leaflet-container img.leaflet-tile {\n  /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */\n  mix-blend-mode: plus-lighter;\n}\n\n.leaflet-container.leaflet-touch-zoom {\n  -ms-touch-action: pan-x pan-y;\n  touch-action: pan-x pan-y;\n}\n.leaflet-container.leaflet-touch-drag {\n  -ms-touch-action: pinch-zoom;\n  /* Fallback for FF which doesn't support pinch-zoom */\n  touch-action: none;\n  touch-action: pinch-zoom;\n}\n.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {\n  -ms-touch-action: none;\n  touch-action: none;\n}\n.leaflet-container {\n  -webkit-tap-highlight-color: transparent;\n}\n.leaflet-container a {\n  -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);\n}\n.leaflet-tile {\n  filter: inherit;\n  visibility: hidden;\n}\n.leaflet-tile-loaded {\n  visibility: inherit;\n}\n.leaflet-zoom-box {\n  width: 0;\n  height: 0;\n  -moz-box-sizing: border-box;\n  box-sizing: border-box;\n  z-index: 800;\n}\n/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */\n.leaflet-overlay-pane svg {\n  -moz-user-select: none;\n}\n\n.leaflet-pane {\n  z-index: 400;\n}\n\n.leaflet-tile-pane {\n  z-index: 200;\n}\n.leaflet-overlay-pane {\n  z-index: 400;\n}\n.leaflet-shadow-pane {\n  z-index: 500;\n}\n.leaflet-marker-pane {\n  z-index: 600;\n}\n.leaflet-tooltip-pane {\n  z-index: 650;\n}\n.leaflet-popup-pane {\n  z-index: 700;\n}\n\n.leaflet-map-pane canvas {\n  z-index: 100;\n}\n.leaflet-map-pane svg {\n  z-index: 200;\n}\n\n.leaflet-vml-shape {\n  width: 1px;\n  height: 1px;\n}\n.lvml {\n  behavior: url(#default#VML);\n  display: inline-block;\n  position: absolute;\n}\n\n/* control positioning */\n\n.leaflet-control {\n  position: relative;\n  z-index: 800;\n  pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */\n  pointer-events: auto;\n}\n.leaflet-top,\n.leaflet-bottom {\n  position: absolute;\n  z-index: 1000;\n  pointer-events: none;\n}\n.leaflet-top {\n  top: 0;\n}\n.leaflet-right {\n  right: 0;\n}\n.leaflet-bottom {\n  bottom: 0;\n}\n.leaflet-left {\n  left: 0;\n}\n.leaflet-control {\n  float: left;\n  clear: both;\n}\n.leaflet-right .leaflet-control {\n  float: right;\n}\n.leaflet-top .leaflet-control {\n  margin-top: 10px;\n}\n.leaflet-bottom .leaflet-control {\n  margin-bottom: 10px;\n}\n.leaflet-left .leaflet-control {\n  margin-left: 10px;\n}\n.leaflet-right .leaflet-control {\n  margin-right: 10px;\n}\n\n/* zoom and fade animations */\n\n.leaflet-fade-anim .leaflet-popup {\n  opacity: 0;\n  -webkit-transition: opacity 0.2s linear;\n  -moz-transition: opacity 0.2s linear;\n  transition: opacity 0.2s linear;\n}\n.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {\n  opacity: 1;\n}\n.leaflet-zoom-animated {\n  -webkit-transform-origin: 0 0;\n  -ms-transform-origin: 0 0;\n  transform-origin: 0 0;\n}\nsvg.leaflet-zoom-animated {\n  will-change: transform;\n}\n\n.leaflet-zoom-anim .leaflet-zoom-animated {\n  -webkit-transition: -webkit-transform 0.25s cubic-bezier(0, 0, 0.25, 1);\n  -moz-transition: -moz-transform 0.25s cubic-bezier(0, 0, 0.25, 1);\n  transition: transform 0.25s cubic-bezier(0, 0, 0.25, 1);\n}\n.leaflet-zoom-anim .leaflet-tile,\n.leaflet-pan-anim .leaflet-tile {\n  -webkit-transition: none;\n  -moz-transition: none;\n  transition: none;\n}\n\n.leaflet-zoom-anim .leaflet-zoom-hide {\n  visibility: hidden;\n}\n\n/* cursors */\n\n.leaflet-interactive {\n  cursor: pointer;\n}\n.leaflet-grab {\n  cursor: -webkit-grab;\n  cursor: -moz-grab;\n  cursor: grab;\n}\n.leaflet-crosshair,\n.leaflet-crosshair .leaflet-interactive {\n  cursor: crosshair;\n}\n.leaflet-popup-pane,\n.leaflet-control {\n  cursor: auto;\n}\n.leaflet-dragging .leaflet-grab,\n.leaflet-dragging .leaflet-grab .leaflet-interactive,\n.leaflet-dragging .leaflet-marker-draggable {\n  cursor: move;\n  cursor: -webkit-grabbing;\n  cursor: -moz-grabbing;\n  cursor: grabbing;\n}\n\n/* marker & overlays interactivity */\n.leaflet-marker-icon,\n.leaflet-marker-shadow,\n.leaflet-image-layer,\n.leaflet-pane > svg path,\n.leaflet-tile-container {\n  pointer-events: none;\n}\n\n.leaflet-marker-icon.leaflet-interactive,\n.leaflet-image-layer.leaflet-interactive,\n.leaflet-pane > svg path.leaflet-interactive,\nsvg.leaflet-image-layer.leaflet-interactive path {\n  pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */\n  pointer-events: auto;\n}\n\n/* visual tweaks */\n\n.leaflet-container {\n  background: #ddd;\n  outline-offset: 1px;\n}\n.leaflet-container a {\n  color: #0078a8;\n}\n.leaflet-zoom-box {\n  border: 2px dotted #38f;\n  background: rgba(255, 255, 255, 0.5);\n}\n\n/* general typography */\n.leaflet-container {\n  font-family: 'Helvetica Neue', Arial, Helvetica, sans-serif;\n  font-size: 12px;\n  font-size: 0.75rem;\n  line-height: 1.5;\n}\n\n/* general toolbar styles */\n\n.leaflet-bar {\n  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);\n  border-radius: 4px;\n}\n.leaflet-bar a {\n  background-color: #fff;\n  border-bottom: 1px solid #ccc;\n  width: 26px;\n  height: 26px;\n  line-height: 26px;\n  display: block;\n  text-align: center;\n  text-decoration: none;\n  color: black;\n  padding: 10px;\n}\n.leaflet-bar a,\n.leaflet-control-layers-toggle {\n  background-position: 50% 50%;\n  background-repeat: no-repeat;\n  display: block;\n}\n.leaflet-bar a:hover,\n.leaflet-bar a:focus {\n  background-color: #f4f4f4;\n}\n.leaflet-bar a:first-child {\n  border-top-left-radius: 4px;\n  border-top-right-radius: 4px;\n}\n.leaflet-bar a:last-child {\n  border-bottom-left-radius: 4px;\n  border-bottom-right-radius: 4px;\n  border-bottom: none;\n}\n.leaflet-bar a.leaflet-disabled {\n  cursor: default;\n  background-color: #f4f4f4;\n  color: #bbb;\n}\n\n.leaflet-touch .leaflet-bar a {\n  width: 35px;\n  height: 35px;\n  line-height: 10px;\n}\n.leaflet-touch .leaflet-bar a:first-child {\n  border-top-left-radius: 2px;\n  border-top-right-radius: 2px;\n}\n.leaflet-touch .leaflet-bar a:last-child {\n  border-bottom-left-radius: 2px;\n  border-bottom-right-radius: 2px;\n}\n\n/* zoom control */\n\n.leaflet-control-zoom {\n  font:\n    bold 18px 'Lucida Console',\n    Monaco,\n    monospace;\n}\n\n.leaflet-control-zoom-in {\n  border-top-left-radius: 10px !important;\n  border-top-right-radius: 10px !important;\n  border-bottom: 2px solid #000000 !important;\n  text-indent: -3px;\n}\n\n.leaflet-control-zoom-out {\n  border-bottom-left-radius: 10px !important;\n  border-bottom-right-radius: 10px !important;\n  text-indent: -3px;\n}\n\n.leaflet-touch .leaflet-control-zoom-in,\n.leaflet-touch .leaflet-control-zoom-out {\n  font-size: 30px;\n  padding: 12px;\n  padding-top: 9px;\n}\n\n.leaflet-control-zoom.leaflet-bar.leaflet-control {\n  margin: 20px;\n  border: 2px solid;\n  border-radius: 10px;\n  border-bottom: 2px solid #000000;\n}\n\n/* layers control */\n\n.leaflet-control-layers {\n  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);\n  background: #fff;\n  border-radius: 5px;\n}\n.leaflet-control-layers-toggle {\n  background-image: url(images/layers.png);\n  width: 36px;\n  height: 36px;\n}\n.leaflet-retina .leaflet-control-layers-toggle {\n  background-image: url(images/layers-2x.png);\n  background-size: 26px 26px;\n}\n.leaflet-touch .leaflet-control-layers-toggle {\n  width: 44px;\n  height: 44px;\n}\n.leaflet-control-layers .leaflet-control-layers-list,\n.leaflet-control-layers-expanded .leaflet-control-layers-toggle {\n  display: none;\n}\n.leaflet-control-layers-expanded .leaflet-control-layers-list {\n  display: block;\n  position: relative;\n}\n.leaflet-control-layers-expanded {\n  padding: 6px 10px 6px 6px;\n  color: #333;\n  background: #fff;\n}\n.leaflet-control-layers-scrollbar {\n  overflow-y: scroll;\n  overflow-x: hidden;\n  padding-right: 5px;\n}\n.leaflet-control-layers-selector {\n  margin-top: 2px;\n  position: relative;\n  top: 1px;\n}\n.leaflet-control-layers label {\n  display: block;\n  font-size: 13px;\n  font-size: 1.08333em;\n}\n.leaflet-control-layers-separator {\n  height: 0;\n  border-top: 1px solid #ddd;\n  margin: 5px -10px 5px -6px;\n}\n\n/* Default icon URLs */\n.leaflet-default-icon-path {\n  /* used only in path-guessing heuristic, see L.Icon.Default */\n  background-image: url(images/marker-icon.png);\n}\n\n/* attribution and scale controls */\n\n.leaflet-container .leaflet-control-attribution {\n  background: #fff;\n  background: rgba(255, 255, 255, 0.8);\n  margin: 0;\n}\n.leaflet-control-attribution,\n.leaflet-control-scale-line {\n  padding: 0 5px;\n  color: #333;\n  line-height: 1.4;\n}\n.leaflet-control-attribution a {\n  text-decoration: none;\n}\n.leaflet-control-attribution a:hover,\n.leaflet-control-attribution a:focus {\n  text-decoration: underline;\n}\n.leaflet-attribution-flag {\n  display: inline !important;\n  vertical-align: baseline !important;\n  width: 1em;\n  height: 0.6669em;\n}\n.leaflet-left .leaflet-control-scale {\n  margin-left: 5px;\n}\n.leaflet-bottom .leaflet-control-scale {\n  margin-bottom: 5px;\n}\n.leaflet-control-scale-line {\n  border: 2px solid #777;\n  border-top: none;\n  line-height: 1.1;\n  padding: 2px 5px 1px;\n  white-space: nowrap;\n  -moz-box-sizing: border-box;\n  box-sizing: border-box;\n  background: rgba(255, 255, 255, 0.8);\n  text-shadow: 1px 1px #fff;\n}\n.leaflet-control-scale-line:not(:first-child) {\n  border-top: 2px solid #777;\n  border-bottom: none;\n  margin-top: -2px;\n}\n.leaflet-control-scale-line:not(:first-child):not(:last-child) {\n  border-bottom: 2px solid #777;\n}\n\n.leaflet-touch .leaflet-control-attribution,\n.leaflet-touch .leaflet-control-layers,\n.leaflet-touch .leaflet-bar {\n  box-shadow: none;\n}\n.leaflet-touch .leaflet-control-layers,\n.leaflet-touch .leaflet-bar {\n  border: 2px solid rgba(0, 0, 0, 0.2);\n  background-clip: padding-box;\n}\n\n/* popup */\n\n.leaflet-popup {\n  position: absolute;\n  text-align: center;\n  margin-bottom: 20px;\n}\n.leaflet-popup-content-wrapper {\n  padding: 1px;\n  text-align: left;\n  border-radius: 12px;\n}\n.leaflet-popup-content {\n  margin: 13px 24px 13px 20px;\n  line-height: 1.3;\n  font-size: 13px;\n  font-size: 1.08333em;\n  min-height: 1px;\n}\n.leaflet-popup-content p {\n  margin: 17px 0;\n  margin: 1.3em 0;\n}\n.leaflet-popup-tip-container {\n  width: 40px;\n  height: 20px;\n  position: absolute;\n  left: 50%;\n  margin-top: -1px;\n  margin-left: -20px;\n  overflow: hidden;\n  pointer-events: none;\n}\n.leaflet-popup-tip {\n  width: 17px;\n  height: 17px;\n  padding: 1px;\n\n  margin: -10px auto 0;\n  pointer-events: auto;\n\n  -webkit-transform: rotate(45deg);\n  -moz-transform: rotate(45deg);\n  -ms-transform: rotate(45deg);\n  transform: rotate(45deg);\n}\n.leaflet-popup-content-wrapper,\n.leaflet-popup-tip {\n  background: white;\n  color: #333;\n  box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4);\n}\n.leaflet-container a.leaflet-popup-close-button {\n  position: absolute;\n  top: 0;\n  right: 0;\n  border: none;\n  text-align: center;\n  width: 24px;\n  height: 24px;\n  font:\n    16px/24px Tahoma,\n    Verdana,\n    sans-serif;\n  color: #757575;\n  text-decoration: none;\n  background: transparent;\n}\n.leaflet-container a.leaflet-popup-close-button:hover,\n.leaflet-container a.leaflet-popup-close-button:focus {\n  color: #585858;\n}\n.leaflet-popup-scrolled {\n  overflow: auto;\n}\n\n.leaflet-oldie .leaflet-popup-content-wrapper {\n  -ms-zoom: 1;\n}\n.leaflet-oldie .leaflet-popup-tip {\n  width: 24px;\n  margin: 0 auto;\n\n  -ms-filter: 'progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)';\n  filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);\n}\n\n.leaflet-oldie .leaflet-control-zoom,\n.leaflet-oldie .leaflet-control-layers,\n.leaflet-oldie .leaflet-popup-content-wrapper,\n.leaflet-oldie .leaflet-popup-tip {\n  border: 1px solid #999;\n}\n\n/* div icon */\n\n.leaflet-div-icon {\n  background: #fff;\n  border: 1px solid #666;\n}\n\n/* Tooltip */\n/* Base styles for the element that has a tooltip */\n.leaflet-tooltip {\n  position: absolute;\n  padding: 6px;\n  background-color: #fff;\n  border: 1px solid #fff;\n  border-radius: 3px;\n  color: #222;\n  white-space: nowrap;\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n  pointer-events: none;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);\n}\n.leaflet-tooltip.leaflet-interactive {\n  cursor: pointer;\n  pointer-events: auto;\n}\n.leaflet-tooltip-top:before,\n.leaflet-tooltip-bottom:before,\n.leaflet-tooltip-left:before,\n.leaflet-tooltip-right:before {\n  position: absolute;\n  pointer-events: none;\n  border: 6px solid transparent;\n  background: transparent;\n  content: '';\n}\n\n/* Directions */\n\n.leaflet-tooltip-bottom {\n  margin-top: 6px;\n}\n.leaflet-tooltip-top {\n  margin-top: -6px;\n}\n.leaflet-tooltip-bottom:before,\n.leaflet-tooltip-top:before {\n  left: 50%;\n  margin-left: -6px;\n}\n.leaflet-tooltip-top:before {\n  bottom: 0;\n  margin-bottom: -12px;\n  border-top-color: #fff;\n}\n.leaflet-tooltip-bottom:before {\n  top: 0;\n  margin-top: -12px;\n  margin-left: -6px;\n  border-bottom-color: #fff;\n}\n.leaflet-tooltip-left {\n  margin-left: -6px;\n}\n.leaflet-tooltip-right {\n  margin-left: 6px;\n}\n.leaflet-tooltip-left:before,\n.leaflet-tooltip-right:before {\n  top: 50%;\n  margin-top: -6px;\n}\n.leaflet-tooltip-left:before {\n  right: 0;\n  margin-right: -12px;\n  border-left-color: #fff;\n}\n.leaflet-tooltip-right:before {\n  left: 0;\n  margin-left: -12px;\n  border-right-color: #fff;\n}\n\n/* Printing */\n\n@media print {\n  /* Prevent printers from removing background-images of controls. */\n  .leaflet-control {\n    -webkit-print-color-adjust: exact;\n    print-color-adjust: exact;\n  }\n}\n"
  },
  {
    "path": "src/test/components/SettingsFormProvider.tsx",
    "content": "import arrayMutators from 'final-form-arrays';\nimport { Form } from 'react-final-form';\nimport { vi } from 'vitest';\n\nimport { FactoryUser } from '../factories/User';\n\nexport const SettingsFormProvider = ({ children }) => {\n  const user = FactoryUser();\n\n  const formProps = {\n    formValues: user,\n    onSubmit: vi.fn(),\n    mutators: { ...arrayMutators },\n    component: () => children,\n  };\n\n  return <Form {...formProps} />;\n};\n"
  },
  {
    "path": "src/test/factories/Category.ts",
    "content": "import type { Category } from 'oa-shared';\n\nexport const FactoryCategory: Category = {\n  createdAt: new Date('2018-11-29T12:56:47.901Z'),\n  modifiedAt: null,\n  id: 3465,\n  name: 'Moulds',\n  type: 'questions',\n};\n"
  },
  {
    "path": "src/test/factories/Comment.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport type { Comment, DiscussionContentType } from 'oa-shared';\n\nexport const FactoryComment = (commentOverloads: Partial<Comment> = {}): Comment => ({\n  id: faker.number.int(),\n  createdAt: faker.date.past(),\n  modifiedAt: faker.date.past(),\n  createdBy: {\n    id: faker.number.int(),\n    displayName: faker.person.firstName(),\n    badges: [\n      {\n        id: 1,\n        name: 'pro',\n        displayName: 'PRO',\n        imageUrl: faker.image.avatar(),\n      },\n      {\n        id: 2,\n        name: 'supporter',\n        displayName: 'Supporter',\n        actionUrl: faker.internet.url(),\n        imageUrl: faker.image.avatar(),\n      },\n    ],\n    username: faker.internet.username(),\n    photo: {\n      id: faker.string.uuid(),\n      publicUrl: faker.image.avatar(),\n    },\n    country: faker.location.countryCode(),\n  },\n  parentId: faker.number.int(),\n  comment: faker.lorem.paragraph(),\n  deleted: faker.datatype.boolean(),\n  sourceId: faker.number.int(),\n  sourceType: faker.helpers.arrayElement<DiscussionContentType>([\n    'news',\n    'projects',\n    'questions',\n    'research_updates',\n  ]),\n  voteCount: faker.number.int({ min: 0, max: 100 }),\n  hasVoted: faker.datatype.boolean(),\n  ...commentOverloads,\n});\n"
  },
  {
    "path": "src/test/factories/Library.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport type {\n  DifficultyLevel,\n  Moderation,\n  Project,\n  ProjectFormData,\n  ProjectStep,\n  ProjectStepFormData,\n} from 'oa-shared';\n\nexport const FactoryProjectFormData = (\n  overloads: Partial<ProjectFormData> = {},\n): ProjectFormData => ({\n  title: faker.lorem.words(4),\n  description: faker.lorem.paragraph(),\n  category: {\n    label: faker.lorem.word(),\n    value: faker.number.int().toString(),\n  },\n  tags: [faker.number.int(), faker.number.int()],\n  difficultyLevel: faker.helpers.arrayElement<DifficultyLevel>([\n    'easy',\n    'medium',\n    'hard',\n    'very-hard',\n  ]),\n  time: '< 1 hour',\n  coverImage: {\n    id: faker.string.uuid(),\n    path: faker.image.url(),\n    fullPath: faker.image.url(),\n    publicUrl: faker.image.url(),\n  },\n  files: [],\n  fileLink: null,\n  steps: [\n    {\n      id: null,\n      title: faker.lorem.text(),\n      description: faker.lorem.paragraphs(2),\n      images: [],\n      videoUrl: null,\n    },\n  ],\n  ...overloads,\n});\n\nexport const FactoryProjectStepFormData = (\n  overloads: Partial<ProjectStepFormData> = {},\n): ProjectStepFormData => ({\n  id: null,\n  title: faker.lorem.text(),\n  description: faker.lorem.paragraphs(2),\n  images: [],\n  videoUrl: null,\n  ...overloads,\n});\n\nexport const FactoryLibraryItem = (itemOverloads: Partial<Project> = {}): Project => ({\n  files: [],\n  hasFileLink: faker.datatype.boolean(),\n  difficultyLevel: faker.helpers.arrayElement<DifficultyLevel>([\n    'easy',\n    'medium',\n    'hard',\n    'very-hard',\n  ]),\n  time: '< 1 hour',\n  slug: faker.lorem.slug(),\n  moderation: faker.helpers.arrayElement<Moderation>([\n    'awaiting-moderation',\n    'improvements-needed',\n    'accepted',\n    'rejected',\n  ]),\n  title: faker.lorem.words(4),\n  description: faker.lorem.paragraph(),\n  category: {\n    id: faker.number.int(),\n    modifiedAt: faker.date.past(),\n    createdAt: faker.date.past(),\n    name: faker.lorem.word(),\n    type: 'projects',\n  },\n  id: faker.number.int(),\n  isDraft: false,\n  modifiedAt: faker.date.past(),\n  createdAt: faker.date.past(),\n  deleted: faker.datatype.boolean(),\n  steps: [],\n  previousSlugs: [],\n  coverImage: null,\n  totalViews: faker.number.int(),\n  usefulCount: faker.number.int(),\n  subscriberCount: faker.number.int(),\n  fileDownloadCount: faker.number.int(),\n  commentCount: 0,\n  author: null,\n  tags: [],\n  publishedAt: faker.date.past(),\n  ...itemOverloads,\n});\n\nexport const FactoryLibraryItemStep = (itemOverloads: Partial<ProjectStep> = {}): ProjectStep => ({\n  id: faker.number.int(),\n  projectId: faker.number.int(),\n  images: [\n    {\n      publicUrl: faker.internet.url(),\n      id: faker.string.uuid(),\n    },\n  ],\n  title: faker.lorem.text(),\n  description: faker.lorem.paragraphs(2),\n  videoUrl: faker.internet.url(),\n  order: faker.number.int(),\n  ...itemOverloads,\n});\n\nexport const FactoryLibraryItemDraft = (itemOverloads: Partial<Project> = {}): Project => ({\n  id: faker.number.int(),\n  isDraft: false,\n  modifiedAt: faker.date.past(),\n  createdAt: faker.date.past(),\n  author: {\n    id: faker.number.int(),\n    displayName: faker.person.firstName(),\n    badges: [\n      {\n        id: 1,\n        name: 'pro',\n        displayName: 'PRO',\n        imageUrl: faker.image.avatar(),\n      },\n      {\n        id: 2,\n        name: 'supporter',\n        displayName: 'Supporter',\n        actionUrl: faker.internet.url(),\n        imageUrl: faker.image.avatar(),\n      },\n    ],\n    photo: {\n      id: faker.string.uuid(),\n      publicUrl: faker.image.avatar(),\n    },\n    username: faker.internet.username(),\n  },\n  deleted: false,\n  files: [],\n  slug: 'quick-draft',\n  moderation: 'awaiting-moderation',\n  title: 'Quick draft',\n  steps: [],\n  previousSlugs: [],\n  tags: [],\n  category: null,\n  coverImage: null,\n  description: faker.lorem.sentence(),\n  difficultyLevel: 'easy',\n  hasFileLink: false,\n  fileDownloadCount: faker.number.int(),\n  commentCount: faker.number.int(),\n  subscriberCount: faker.number.int(),\n  totalViews: faker.number.int(),\n  usefulCount: faker.number.int(),\n  publishedAt: faker.date.past(),\n  ...itemOverloads,\n});\n"
  },
  {
    "path": "src/test/factories/MapPin.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport type { MapPin, Moderation } from 'oa-shared';\n\nexport const FactoryMapPin = (userOverloads: Partial<MapPin> = {}): Partial<MapPin> => ({\n  id: faker.number.int(),\n  moderation: faker.helpers.arrayElement<Moderation>([\n    'awaiting-moderation',\n    'improvements-needed',\n    'rejected',\n    'accepted',\n  ]),\n  lng: faker.location.longitude(),\n  lat: faker.location.latitude(),\n  ...userOverloads,\n});\n"
  },
  {
    "path": "src/test/factories/News.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport type { News, NewsFormData } from 'oa-shared';\n\nexport const FactoryNewsFormData = (overloads: Partial<NewsFormData> = {}): NewsFormData => ({\n  body: faker.lorem.paragraph(),\n  category: {\n    label: faker.lorem.words(1),\n    value: faker.number.int().toString(),\n  },\n  heroImage: {\n    id: faker.string.uuid(),\n    path: faker.image.url(),\n    fullPath: faker.image.url(),\n    publicUrl: faker.image.url(),\n  },\n  isDraft: false,\n  profileBadge: null,\n  tags: [faker.number.int(), faker.number.int()],\n  title: faker.lorem.sentence(),\n  ...overloads,\n});\n\nexport const FactoryNewsItem = (newsOverloads: Partial<News> = {}): News => ({\n  body: faker.lorem.paragraph(),\n  bodyHtml: faker.lorem.paragraph(),\n  createdAt: faker.date.past(),\n  deleted: faker.datatype.boolean(),\n  id: faker.number.int(),\n  isDraft: false,\n  modifiedAt: faker.date.past(),\n  previousSlugs: [],\n  summary: null,\n  slug: faker.lorem.slug(),\n  title: faker.lorem.sentence(),\n  tags: [\n    {\n      createdAt: new Date(),\n      id: faker.number.int(),\n      name: faker.lorem.words(1),\n      modifiedAt: null,\n    },\n    {\n      createdAt: new Date(),\n      id: faker.number.int(),\n      name: faker.lorem.words(1),\n      modifiedAt: null,\n    },\n  ],\n  author: {\n    id: faker.number.int(),\n    country: faker.location.countryCode(),\n    displayName: faker.internet.username(),\n    badges: [\n      {\n        id: 1,\n        name: 'pro',\n        displayName: 'PRO',\n        imageUrl: faker.image.avatar(),\n      },\n      {\n        id: 2,\n        name: 'supporter',\n        displayName: 'Supporter',\n        actionUrl: faker.internet.url(),\n        imageUrl: faker.image.avatar(),\n      },\n    ],\n    photo: {\n      id: faker.string.uuid(),\n      publicUrl: faker.image.avatar(),\n    },\n    username: faker.internet.username(),\n  },\n  category: {\n    createdAt: new Date(),\n    modifiedAt: null,\n    id: faker.number.int(),\n    name: faker.lorem.words(1),\n    type: 'news',\n  },\n  heroImage: { id: '2349-23480-34', publicUrl: '' },\n  subscriberCount: faker.number.int(),\n  commentCount: faker.number.int(),\n  totalViews: faker.number.int(),\n  usefulCount: faker.number.int(),\n  profileBadge: null,\n  publishedAt: faker.date.past(),\n  ...newsOverloads,\n});\n"
  },
  {
    "path": "src/test/factories/Question.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport type { Question } from 'oa-shared';\n\nexport const FactoryQuestionItem = (questionOverloads: Partial<Question> = {}): Question => ({\n  id: faker.number.int(),\n  isDraft: false,\n  createdAt: faker.date.past(),\n  deleted: faker.datatype.boolean(),\n  modifiedAt: faker.date.past(),\n  title: faker.lorem.sentence(),\n  description: faker.lorem.paragraph(),\n  slug: faker.lorem.slug(),\n  previousSlugs: [],\n  tags: [\n    {\n      createdAt: new Date(),\n      id: faker.number.int(),\n      modifiedAt: null,\n      name: faker.lorem.words(1),\n    },\n    {\n      createdAt: new Date(),\n      id: faker.number.int(),\n      modifiedAt: null,\n      name: faker.lorem.words(1),\n    },\n  ],\n  author: {\n    id: faker.number.int(),\n    country: faker.location.countryCode(),\n    displayName: faker.internet.username(),\n    badges: [\n      {\n        id: 1,\n        name: 'pro',\n        displayName: 'PRO',\n        imageUrl: faker.image.avatar(),\n      },\n      {\n        id: 2,\n        name: 'supporter',\n        displayName: 'Supporter',\n        actionUrl: faker.internet.url(),\n        imageUrl: faker.image.avatar(),\n      },\n    ],\n    photo: {\n      id: faker.string.uuid(),\n      publicUrl: faker.image.avatar(),\n    },\n    username: faker.internet.username(),\n  },\n  category: {\n    createdAt: new Date(),\n    modifiedAt: null,\n    id: faker.number.int(),\n    name: faker.lorem.words(1),\n    type: 'questions',\n  },\n  images: [],\n  subscriberCount: faker.number.int(),\n  commentCount: faker.number.int(),\n  totalViews: faker.number.int(),\n  usefulCount: faker.number.int(),\n  publishedAt: faker.date.past(),\n  ...questionOverloads,\n});\n"
  },
  {
    "path": "src/test/factories/ResearchItem.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport type {\n  DBResearchItem,\n  DBResearchUpdate,\n  ResearchFormData,\n  ResearchItem,\n  ResearchUpdate,\n} from 'oa-shared';\n\nexport const FactoryResearchItemUpdate = (\n  researchItemUpdateOverloads: Partial<ResearchUpdate> = {},\n): ResearchUpdate => ({\n  author: null,\n  commentCount: 0,\n  createdAt: faker.date.past(),\n  deleted: false,\n  description: faker.lorem.sentences(2),\n  id: faker.number.int(),\n  images: [],\n  files: [],\n  fileDownloadCount: faker.number.int(),\n  hasFileLink: false,\n  modifiedAt: faker.date.past(),\n  researchId: faker.number.int(100),\n  title: faker.lorem.words(),\n  videoUrl: null,\n  isDraft: false,\n  research: undefined,\n  publishedAt: faker.date.past(),\n  ...researchItemUpdateOverloads,\n});\n\nexport const FactoryDBResearchItemUpdate = (\n  researchDBItemUpdateOverloads: Partial<DBResearchUpdate> = {},\n): DBResearchUpdate => ({\n  created_by: faker.number.int(100),\n  comment_count: 0,\n  created_at: faker.date.past(),\n  deleted: false,\n  description: faker.lorem.sentences(2),\n  id: faker.number.int(),\n  images: [],\n  files: [],\n  file_download_count: faker.number.int(),\n  file_link: '',\n  modified_at: faker.date.past(),\n  research_id: faker.number.int(100),\n  title: faker.lorem.words(),\n  video_url: null,\n  is_draft: false,\n  published_at: faker.date.past(),\n  ...researchDBItemUpdateOverloads,\n});\n\nexport const FactoryResearchItem = (\n  researchItemOverloads: Partial<ResearchItem> = {},\n): ResearchItem => ({\n  id: faker.number.int(),\n  isDraft: false,\n  author: {\n    id: faker.number.int(),\n    displayName: faker.internet.username(),\n    photo: {\n      id: faker.string.uuid(),\n      publicUrl: faker.image.avatar(),\n    },\n    username: faker.internet.username(),\n  },\n  modifiedAt: faker.date.past(),\n  createdAt: faker.date.past(),\n  deleted: faker.datatype.boolean(),\n  description: faker.lorem.paragraphs(),\n  title: faker.lorem.words(),\n  slug: faker.lorem.slug(),\n  previousSlugs: [],\n  collaboratorsUsernames: [],\n  updates: [FactoryResearchItemUpdate()],\n  tags: [],\n  totalViews: faker.number.int(),\n  collaborators: [],\n  subscriberCount: faker.number.int(),\n  commentCount: 0,\n  category: null,\n  image: null,\n  updateCount: 0,\n  status: 'in-progress',\n  usefulCount: 2,\n  ...researchItemOverloads,\n  publishedAt: researchItemOverloads.publishedAt ?? null,\n});\n\nexport const FactoryDBResearchItem = (\n  researchItemOverloads: Partial<DBResearchItem> = {},\n): DBResearchItem => ({\n  id: faker.number.int(),\n  is_draft: false,\n  created_by: faker.number.int(),\n  modified_at: faker.date.past(),\n  created_at: faker.date.past(),\n  deleted: faker.datatype.boolean(),\n  description: faker.lorem.paragraphs(),\n  title: faker.lorem.words(),\n  slug: faker.lorem.slug(),\n  previous_slugs: [],\n  collaborators: [],\n  updates: [FactoryDBResearchItemUpdate()],\n  tags: [],\n  total_views: faker.number.int(),\n  subscriber_count: faker.number.int(),\n  comment_count: 0,\n  category: null,\n  image: null,\n  update_count: 0,\n  status: 'in-progress',\n  useful_count: 2,\n  published_at: faker.date.past(),\n  ...researchItemOverloads,\n});\n\nexport const FactoryResearchItemFormInput = (\n  researchItemOverloads: Partial<ResearchFormData> = {},\n): ResearchFormData => ({\n  title: faker.lorem.words(),\n  description: faker.lorem.paragraphs(),\n  coverImage: null,\n  category: researchItemOverloads.category || null,\n  collaborators: researchItemOverloads.collaborators || null,\n  tags: researchItemOverloads.tags || [],\n});\n"
  },
  {
    "path": "src/test/factories/User.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport type { MediaWithPublicUrl, Profile } from 'oa-shared';\n\nexport const factoryImage: MediaWithPublicUrl = {\n  id: '123',\n  path: '',\n  fullPath: '',\n  publicUrl:\n    'https://firebasestorage.googleapis.com/v0/b/onearmyworld.appspot.com/o/uploads%2Fv2_howtos%2Fme5Bq0wq5FdoJUY8gELN%2FBope-brick-5.jpg?alt=media&token=b29153ce-58fd-4c28-ac87-82f0b2f7c54c',\n};\n\nexport const FactoryUser = (userOverloads: Partial<Profile> = {}): Partial<Profile> => ({\n  id: faker.number.int(),\n  createdAt: faker.date.past(),\n  type: {\n    id: faker.number.int(),\n    name: faker.word.noun(),\n    displayName: faker.word.noun(),\n    description: faker.word.noun(),\n    imageUrl: faker.image.avatar(),\n    smallImageUrl: faker.image.avatar(),\n    mapPinName: faker.word.noun(),\n    order: faker.number.int(),\n    isSpace: false,\n  },\n  username: faker.internet.username(),\n  displayName: faker.person.fullName(),\n  badges: [\n    {\n      id: 1,\n      name: 'pro',\n      displayName: 'PRO',\n      imageUrl: faker.image.avatar(),\n      premiumTier: 1,\n    },\n    {\n      id: 2,\n      name: 'supporter',\n      displayName: 'Supporter',\n      actionUrl: faker.internet.url(),\n      imageUrl: faker.image.avatar(),\n    },\n  ],\n  website: faker.internet.url(),\n  country: faker.location.countryCode(),\n  coverImages: [] as any[],\n  ...userOverloads,\n});\n"
  },
  {
    "path": "src/test/factories/dbNotification.ts",
    "content": "import type { DBNotification } from 'oa-shared';\n\nexport const factoryDBNotification = (\n  userOverloads: Partial<DBNotification> = {},\n): DBNotification => ({\n  id: 1,\n  title: 'Amazing news!',\n  action_type: 'newComment',\n  content_id: 1,\n  content_type: 'comments',\n  created_at: new Date('2024-01-01T00:00:00Z'),\n  is_read: false,\n  modified_at: null,\n  owned_by: {} as any,\n  owned_by_id: 2,\n  source_content_type: 'news',\n  source_content_id: 1,\n  triggered_by: {} as any,\n  triggered_by_id: 1,\n  tenant_id: 'precious-plastic',\n  ...userOverloads,\n});\n"
  },
  {
    "path": "src/test/factories/notificationsPreferences.ts",
    "content": "import type { DBNotificationsPreferences } from 'oa-shared';\n\nexport const factoryNotificationsPreferences = (\n  userOverloads: Partial<DBNotificationsPreferences> = {},\n): DBNotificationsPreferences =>\n  ({\n    id: 1,\n    user_id: 123,\n    comments: true,\n    replies: false,\n    research_updates: true,\n    is_unsubscribed: false,\n    ...userOverloads,\n  }) as DBNotificationsPreferences;\n"
  },
  {
    "path": "src/test/factories/profile.ts",
    "content": "import { faker } from '@faker-js/faker';\n\nimport type { DBProfile } from 'oa-shared';\n\nexport const FactoryDBProfile = (dbProfileOverloads: Partial<DBProfile> = {}): DBProfile => ({\n  id: faker.number.int(),\n  username: faker.internet.username(),\n  display_name: faker.internet.username(),\n  photo: {\n    id: faker.string.uuid(),\n    path: faker.image.avatar(),\n    fullPath: faker.image.avatar(),\n  },\n  country: '',\n  roles: [],\n  about: '',\n  auth_id: '',\n  cover_images: null,\n  created_at: faker.date.past(),\n  impact: null,\n  type: {\n    id: faker.number.int(),\n    name: faker.word.noun(),\n    display_name: faker.word.noun(),\n    description: faker.word.noun(),\n    image_url: faker.image.avatar(),\n    small_image_url: faker.image.avatar(),\n    map_pin_name: faker.word.noun(),\n    order: faker.number.int(),\n    is_space: false,\n  },\n  pin: undefined,\n  visitor_policy: null,\n  is_blocked_from_messaging: null,\n  is_contactable: false,\n  last_active: faker.date.past(),\n  website: faker.internet.url(),\n  total_views: 0,\n  profile_type: faker.number.int(),\n  donations_enabled: false,\n  ...dbProfileOverloads,\n});\n"
  },
  {
    "path": "src/test/factories/supabaseNotification.ts",
    "content": "import type { BasicAuthorDetails } from 'oa-shared';\nimport { Notification } from 'oa-shared';\nimport { FactoryComment } from './Comment';\n\nexport const factorySupabaseNotification = (\n  notificationOverloads: Partial<Notification> = {},\n): Notification => {\n  return new Notification({\n    id: 1,\n    title: 'Nice Project',\n    actionType: 'newComment',\n    contentType: 'comments',\n    contentId: 100,\n    content: FactoryComment(),\n    createdAt: new Date('2024-01-01'),\n    modifiedAt: new Date('2024-01-02'),\n    ownedById: 1,\n    isRead: false,\n    sourceContentType: 'projects',\n    sourceContentId: 300,\n    triggeredBy: factoryTriggeredBy(),\n    ...notificationOverloads,\n  });\n};\n\nexport const factoryTriggeredBy = (triggeredByOverloads = {}): BasicAuthorDetails => {\n  return {\n    id: 1,\n    username: 'daveTheHakkens',\n    photo: {\n      id: '',\n      path: 'https://url.com/image.png',\n      fullPath: 'https://url.com/image.png',\n      publicUrl: 'https://url.com/image.png',\n    },\n    ...triggeredByOverloads,\n  };\n};\n"
  },
  {
    "path": "src/test/models/profile.test.ts",
    "content": "import { NotificationDisplay } from 'oa-shared';\nimport { describe, expect, it } from 'vitest';\n\nimport { FactoryComment } from '../factories/Comment';\nimport { FactoryResearchItemUpdate } from '../factories/ResearchItem';\nimport { factorySupabaseNotification, factoryTriggeredBy } from '../factories/supabaseNotification';\n\ndescribe('NotificationDisplay', () => {\n  describe('fromNotification', () => {\n    describe('when a new comment', () => {\n      it('on news', () => {\n        const notification = factorySupabaseNotification({\n          actionType: 'newComment',\n          contentId: 101,\n          contentType: 'comments',\n          title: 'New thing coming',\n          content: FactoryComment({ id: 101, comment: 'Great work' }),\n          triggeredBy: factoryTriggeredBy({\n            username: 'dave0',\n          }),\n        });\n\n        const notificationDisplay = NotificationDisplay.fromNotification(notification);\n\n        expect(notificationDisplay.link).toBe('/redirect?id=101&ct=comments');\n        expect(notificationDisplay.body).toBe('Great work');\n        expect(notificationDisplay.title).toBe('left a comment on New thing coming');\n        expect(notificationDisplay.triggeredBy).toBe('dave0');\n        expect(notificationDisplay.email).toStrictEqual({\n          body: 'Great work',\n          buttonLabel: 'See the full discussion',\n          preview: 'dave0 has left a new comment',\n          subject: 'New comment on New thing coming',\n        });\n      });\n\n      it('on library', () => {\n        const notification = factorySupabaseNotification({\n          actionType: 'newComment',\n          contentId: 102,\n          contentType: 'comments',\n          title: 'Best Guide',\n          content: FactoryComment({ id: 102, comment: 'Amazing' }),\n          triggeredBy: factoryTriggeredBy({\n            username: 'JEFF123',\n          }),\n        });\n\n        const notificationDisplay = NotificationDisplay.fromNotification(notification);\n\n        expect(notificationDisplay.link).toBe('/redirect?id=102&ct=comments');\n        expect(notificationDisplay.body).toBe('Amazing');\n        expect(notificationDisplay.title).toBe('left a comment on Best Guide');\n        expect(notificationDisplay.triggeredBy).toBe('JEFF123');\n        expect(notificationDisplay.email).toStrictEqual({\n          body: 'Amazing',\n          buttonLabel: 'See the full discussion',\n          preview: 'JEFF123 has left a new comment',\n          subject: 'New comment on Best Guide',\n        });\n      });\n\n      it('on questions', () => {\n        const notification = factorySupabaseNotification({\n          actionType: 'newComment',\n          contentId: 103,\n          contentType: 'comments',\n          title: 'Where to start?',\n          content: FactoryComment({ id: 103, comment: \"I'm not sure.\" }),\n          triggeredBy: factoryTriggeredBy({\n            username: 'Ben',\n          }),\n        });\n\n        const notificationDisplay = NotificationDisplay.fromNotification(notification);\n\n        expect(notificationDisplay.link).toBe('/redirect?id=103&ct=comments');\n        expect(notificationDisplay.body).toBe(\"I'm not sure.\");\n        expect(notificationDisplay.title).toBe('left a comment on Where to start?');\n        expect(notificationDisplay.triggeredBy).toBe('Ben');\n        expect(notificationDisplay.email).toStrictEqual({\n          body: \"I'm not sure.\",\n          buttonLabel: 'See the full discussion',\n          preview: 'Ben has left a new comment',\n          subject: 'New comment on Where to start?',\n        });\n      });\n\n      it('on research update', () => {\n        const notification = factorySupabaseNotification({\n          actionType: 'newComment',\n          contentId: 104,\n          contentType: 'comments',\n          title: 'New Buildings: Digging',\n          content: FactoryComment({ id: 104, comment: \"I'm not sure.\" }),\n          triggeredBy: factoryTriggeredBy({\n            username: 'Mario',\n          }),\n        });\n\n        const notificationDisplay = NotificationDisplay.fromNotification(notification);\n\n        expect(notificationDisplay.link).toBe('/redirect?id=104&ct=comments');\n        expect(notificationDisplay.body).toBe(\"I'm not sure.\");\n        expect(notificationDisplay.title).toBe('left a comment on New Buildings: Digging');\n        expect(notificationDisplay.triggeredBy).toBe('Mario');\n        expect(notificationDisplay.email).toStrictEqual({\n          body: \"I'm not sure.\",\n          buttonLabel: 'See the full discussion',\n          preview: 'Mario has left a new comment',\n          subject: 'New comment on New Buildings: Digging',\n        });\n      });\n    });\n\n    describe('when a new reply', () => {\n      it('on news', () => {\n        const notification = factorySupabaseNotification({\n          actionType: 'newReply',\n          contentId: 101,\n          contentType: 'comments',\n          content: FactoryComment({ id: 101, comment: 'Great work' }),\n          triggeredBy: factoryTriggeredBy({\n            username: 'dave0',\n          }),\n        });\n\n        const notificationDisplay = NotificationDisplay.fromNotification(notification);\n\n        expect(notificationDisplay.link).toBe('/redirect?id=101&ct=comments');\n        expect(notificationDisplay.body).toBe('Great work');\n        expect(notificationDisplay.title).toBe('left a reply');\n        expect(notificationDisplay.triggeredBy).toBe('dave0');\n        expect(notificationDisplay.email).toStrictEqual({\n          body: 'Great work',\n          buttonLabel: 'See the full discussion',\n          preview: 'dave0 has left a new reply',\n          subject: 'You have a new comment reply!',\n        });\n      });\n\n      it('on library', () => {\n        const notification = factorySupabaseNotification({\n          actionType: 'newReply',\n          contentId: 102,\n          contentType: 'comments',\n          content: FactoryComment({ id: 102, comment: 'Amazing' }),\n          triggeredBy: factoryTriggeredBy({\n            username: 'JEFF123',\n          }),\n        });\n\n        const notificationDisplay = NotificationDisplay.fromNotification(notification);\n\n        expect(notificationDisplay.link).toBe('/redirect?id=102&ct=comments');\n        expect(notificationDisplay.body).toBe('Amazing');\n        expect(notificationDisplay.title).toBe('left a reply');\n        expect(notificationDisplay.triggeredBy).toBe('JEFF123');\n        expect(notificationDisplay.email).toStrictEqual({\n          body: 'Amazing',\n          buttonLabel: 'See the full discussion',\n          preview: 'JEFF123 has left a new reply',\n          subject: 'You have a new comment reply!',\n        });\n      });\n\n      it('on questions', () => {\n        const notification = factorySupabaseNotification({\n          actionType: 'newReply',\n          contentId: 103,\n          contentType: 'comments',\n          content: FactoryComment({ id: 103, comment: \"I'm not sure.\" }),\n          triggeredBy: factoryTriggeredBy({\n            username: 'Ben',\n          }),\n        });\n\n        const notificationDisplay = NotificationDisplay.fromNotification(notification);\n\n        expect(notificationDisplay.link).toBe('/redirect?id=103&ct=comments');\n        expect(notificationDisplay.body).toBe(\"I'm not sure.\");\n        expect(notificationDisplay.title).toBe('left a reply');\n        expect(notificationDisplay.triggeredBy).toBe('Ben');\n        expect(notificationDisplay.email).toStrictEqual({\n          body: \"I'm not sure.\",\n          buttonLabel: 'See the full discussion',\n          preview: 'Ben has left a new reply',\n          subject: 'You have a new comment reply!',\n        });\n      });\n\n      it('on research update', () => {\n        const notification = factorySupabaseNotification({\n          actionType: 'newReply',\n          contentId: 104,\n          contentType: 'comments',\n          content: FactoryComment({ id: 104, comment: \"I'm not sure.\" }),\n          triggeredBy: factoryTriggeredBy({\n            username: 'Mario',\n          }),\n        });\n\n        const notificationDisplay = NotificationDisplay.fromNotification(notification);\n\n        expect(notificationDisplay.link).toBe('/redirect?id=104&ct=comments');\n        expect(notificationDisplay.body).toBe(\"I'm not sure.\");\n        expect(notificationDisplay.title).toBe('left a reply');\n        expect(notificationDisplay.triggeredBy).toBe('Mario');\n        expect(notificationDisplay.email).toStrictEqual({\n          body: \"I'm not sure.\",\n          buttonLabel: 'See the full discussion',\n          preview: 'Mario has left a new reply',\n          subject: 'You have a new comment reply!',\n        });\n      });\n    });\n\n    describe('when new content', () => {\n      it('research update', () => {\n        const notification = factorySupabaseNotification({\n          actionType: 'newContent',\n          contentId: 654,\n          contentType: 'research_updates',\n          content: FactoryResearchItemUpdate({\n            id: 654,\n            title: 'Foundations',\n            description: 'We put some tiles down.',\n          }),\n          triggeredBy: factoryTriggeredBy({\n            username: 'Julie',\n          }),\n          title: 'Second Building',\n        });\n        const notificationDisplay = NotificationDisplay.fromNotification(notification);\n\n        expect(notificationDisplay.link).toBe('/redirect?id=654&ct=research_updates');\n        expect(notificationDisplay.body).toBe('Foundations');\n        expect(notificationDisplay.title).toBe('published a new update on Second Building');\n        expect(notificationDisplay.triggeredBy).toBe('Julie');\n        expect(notificationDisplay.email).toStrictEqual({\n          body: 'Foundations:\\n\\nWe put some tiles down.',\n          buttonLabel: 'Join the discussion',\n          preview: 'New research update on Second Building',\n          subject: 'New update on Second Building',\n        });\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "src/test/routes/api.notifications-preferences.test.ts",
    "content": "import { action, loader } from 'src/routes/api.notifications-preferences';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { createMockSupabaseClient } from '../utils/supabaseClientMock';\n\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\n\nvi.mock('src/repository/supabase.server', () => ({\n  createSupabaseServerClient: vi.fn(),\n}));\n\nconst { createSupabaseServerClient } = vi.mocked(await import('src/repository/supabase.server'));\n\nconst createMockLoaderArgs = (request: Request): LoaderFunctionArgs => ({\n  request,\n  params: {},\n  context: {},\n  unstable_pattern: '',\n});\n\nconst createMockActionArgs = (request: Request): ActionFunctionArgs => ({\n  request,\n  params: {},\n  context: {},\n  unstable_pattern: '',\n});\n\ndescribe('loader', () => {\n  let mockClient: ReturnType<typeof createMockSupabaseClient>;\n  let mockRequest: Request;\n\n  beforeEach(() => {\n    mockClient = createMockSupabaseClient();\n    mockRequest = new Request('http://localhost/api/notifications-preferences');\n    createSupabaseServerClient.mockReturnValue({\n      client: mockClient.client,\n      headers: new Headers(),\n    });\n  });\n\n  it('returns user preferences when found', async () => {\n    const mockClaims = { sub: 'user123' };\n    const mockPreferences = {\n      comments: false,\n      replies: true,\n      research_updates: false,\n      is_unsubscribed: true,\n    };\n\n    mockClient.mocks.auth.getClaims.mockResolvedValue({\n      data: { claims: mockClaims },\n    });\n    mockClient.mocks.single.mockResolvedValue({ data: mockPreferences });\n\n    const response = await loader(createMockLoaderArgs(mockRequest));\n    const result = await response.json();\n\n    expect(response.status).toBe(200);\n    expect(result).toEqual({ preferences: mockPreferences });\n    expect(mockClient.mocks.from).toHaveBeenCalledWith('notifications_preferences');\n    expect(mockClient.mocks.select).toHaveBeenCalledWith('*, profiles!inner(id)');\n    expect(mockClient.mocks.eq).toHaveBeenCalledWith('profiles.auth_id', 'user123');\n    expect(mockClient.mocks.single).toHaveBeenCalled();\n  });\n\n  it('returns default preferences when no data found', async () => {\n    const mockClaims = { sub: 'user123' };\n    const defaultPreferences = {\n      comments: true,\n      replies: true,\n      research_updates: true,\n      is_unsubscribed: false,\n    };\n\n    mockClient.mocks.auth.getClaims.mockResolvedValue({\n      data: { claims: mockClaims },\n    });\n    mockClient.mocks.single.mockResolvedValue({ data: null });\n\n    const response = await loader(createMockLoaderArgs(mockRequest));\n    const result = await response.json();\n\n    expect(response.status).toBe(200);\n    expect(result).toEqual({ preferences: defaultPreferences });\n  });\n\n  it('returns 401 when user is not authenticated', async () => {\n    mockClient.mocks.auth.getClaims.mockResolvedValue({\n      data: { claims: null },\n    });\n\n    const response = await loader(createMockLoaderArgs(mockRequest));\n\n    expect(response.status).toBe(401);\n  });\n});\n\ndescribe('action', () => {\n  let mockClient: ReturnType<typeof createMockSupabaseClient>;\n  let mockRequest: Request;\n\n  beforeEach(() => {\n    mockClient = createMockSupabaseClient();\n    createSupabaseServerClient.mockReturnValue({\n      client: mockClient.client,\n      headers: new Headers(),\n    });\n    vi.stubEnv('TENANT_ID', 'test-tenant');\n  });\n\n  const createFormData = (data: Record<string, string>) => {\n    const formData = new FormData();\n    Object.entries(data).forEach(([key, value]) => {\n      formData.append(key, value);\n    });\n    return formData;\n  };\n\n  it('updates existing preferences when id is provided', async () => {\n    const mockUser = { id: 'user123' };\n    const mockClaims = { sub: 'user123' };\n    const formData = createFormData({\n      id: '1',\n      comments: 'false',\n      replies: 'true',\n      research_updates: 'false',\n      is_unsubscribed: 'true',\n    });\n\n    mockRequest = new Request('http://localhost/api/notifications-preferences', {\n      method: 'POST',\n      body: formData,\n    });\n\n    mockClient.mocks.auth.getClaims.mockResolvedValue({\n      data: { claims: mockClaims },\n    });\n    mockClient.mocks.select.mockResolvedValue({ data: mockUser });\n\n    const response = await action(createMockActionArgs(mockRequest));\n\n    expect(response.status).toBe(200);\n    expect(mockClient.mocks.from).toHaveBeenCalledWith('notifications_preferences');\n    expect(mockClient.mocks.update).toHaveBeenCalledWith({\n      comments: false,\n      replies: true,\n      research_updates: false,\n      is_unsubscribed: true,\n    });\n    expect(mockClient.mocks.eq).toHaveBeenCalledWith('id', 1);\n    expect(mockClient.mocks.select).toHaveBeenCalled();\n  });\n\n  it('creates new preferences when id is not provided', async () => {\n    const mockClaims = { sub: 'user123' };\n    const mockProfile = { id: 456, auth_id: 'user123' };\n    const formData = createFormData({\n      comments: 'true',\n      replies: 'false',\n      research_updates: 'true',\n      is_unsubscribed: 'false',\n    });\n\n    mockRequest = new Request('http://localhost/api/notifications-preferences', {\n      method: 'POST',\n      body: formData,\n    });\n\n    mockClient.mocks.auth.getClaims.mockResolvedValue({\n      data: { claims: mockClaims },\n    });\n    mockClient.mocks.single.mockResolvedValue({ data: mockProfile });\n\n    const response = await action(createMockActionArgs(mockRequest));\n\n    expect(response.status).toBe(200);\n    expect(mockClient.mocks.from).toHaveBeenCalledWith('profiles');\n    expect(mockClient.mocks.select).toHaveBeenCalledWith('id, auth_id');\n    expect(mockClient.mocks.eq).toHaveBeenCalledWith('auth_id', 'user123');\n    expect(mockClient.mocks.insert).toHaveBeenCalledWith({\n      user_id: 456,\n      comments: true,\n      replies: false,\n      research_updates: true,\n      is_unsubscribed: false,\n      tenant_id: 'test-tenant',\n    });\n  });\n\n  it('returns 401 when user is not authenticated', async () => {\n    const formData = createFormData({\n      comments: 'true',\n      replies: 'true',\n      research_updates: 'true',\n      is_unsubscribed: 'false',\n    });\n\n    mockRequest = new Request('http://localhost/api/notifications-preferences', {\n      method: 'POST',\n      body: formData,\n    });\n\n    mockClient.mocks.auth.getClaims.mockResolvedValue({\n      data: { claims: null },\n    });\n\n    const response = await action(createMockActionArgs(mockRequest));\n\n    expect(response.status).toBe(401);\n  });\n\n  it('returns 500 for non-POST requests', async () => {\n    const mockClaims = { sub: 'user123' };\n    mockRequest = new Request('http://localhost/api/notifications-preferences', {\n      method: 'GET',\n    });\n\n    mockClient.mocks.auth.getClaims.mockResolvedValue({\n      data: { claims: mockClaims },\n    });\n\n    const response = await action(createMockActionArgs(mockRequest));\n\n    expect(response.status).toBe(500);\n  });\n\n  it('returns 401 when user profile is not found during creation', async () => {\n    const mockClaims = { sub: 'user123' };\n    const formData = createFormData({\n      comments: 'true',\n      replies: 'true',\n      research_updates: 'true',\n      is_unsubscribed: 'false',\n    });\n\n    mockRequest = new Request('http://localhost/api/notifications-preferences', {\n      method: 'POST',\n      body: formData,\n    });\n\n    mockClient.mocks.auth.getClaims.mockResolvedValue({\n      data: { claims: mockClaims },\n    });\n    mockClient.mocks.single.mockResolvedValue({ data: null });\n\n    const response = await action(createMockActionArgs(mockRequest));\n\n    expect(response.status).toBe(401);\n    expect(response.statusText).toBe('User not found');\n  });\n\n  it('handles errors gracefully', async () => {\n    const mockClaims = { sub: 'user123' };\n    const formData = createFormData({\n      comments: 'true',\n      replies: 'true',\n      research_updates: 'true',\n      is_unsubscribed: 'false',\n    });\n\n    mockRequest = new Request('http://localhost/api/notifications-preferences', {\n      method: 'POST',\n      body: formData,\n    });\n\n    mockClient.mocks.auth.getClaims.mockResolvedValue({\n      data: { claims: mockClaims },\n    });\n    mockClient.mocks.single.mockRejectedValue(new Error('Database error'));\n\n    const response = await action(createMockActionArgs(mockRequest));\n\n    expect(response.status).toBe(500);\n    const result = await response.json();\n    expect(result).toHaveProperty('error');\n  });\n});\n"
  },
  {
    "path": "src/test/services/broadcastCoordinationService.server.ts",
    "content": "import { BroadcastCoordinationServiceServer } from 'src/services/broadcastCoordinationService.server';\nimport { discordServiceServer } from 'src/services/discordService.server';\nimport { NotificationsSupabaseServiceServer } from 'src/services/notificationsSupabaseService.server';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { FactoryDBProfile } from '../factories/profile';\nimport {\n  FactoryDBResearchItem,\n  FactoryDBResearchItemUpdate,\n  FactoryResearchItemUpdate,\n} from '../factories/ResearchItem';\n\nvi.mock('src/services/notificationsService.server');\nvi.mock('src/services/notificationSupabaseService.server');\nvi.mock('src/services/discordService.server');\n\ndescribe('broadcastCoordinationServiceServer', () => {\n  describe('researchUpdate', () => {\n    const mockProfile = FactoryDBProfile();\n    const mockClient = {} as any;\n    const mockRequest = new Request('http://example.com');\n    const mockResearch = FactoryDBResearchItem({\n      is_draft: false,\n    });\n\n    beforeEach(() => {\n      vi.clearAllMocks();\n      vi.spyOn(\n        NotificationsSupabaseServiceServer.prototype,\n        'createNotificationsResearchUpdate',\n      ).mockImplementation(vi.fn());\n    });\n\n    describe('when research update has draft research', () => {\n      it('does not send any notifications when researchUpdate.research.is_draft is true', () => {\n        const researchUpdate = FactoryResearchItemUpdate({\n          isDraft: false,\n          research: { ...mockResearch, is_draft: true },\n        });\n\n        const broadcastCoordinationServiceServer = new BroadcastCoordinationServiceServer(\n          mockClient,\n        );\n\n        broadcastCoordinationServiceServer.researchUpdate(researchUpdate, mockProfile, mockRequest);\n\n        expect(\n          NotificationsSupabaseServiceServer.prototype.createNotificationsResearchUpdate,\n        ).not.toHaveBeenCalled();\n        expect(discordServiceServer.postWebhookRequest).not.toHaveBeenCalled();\n      });\n    });\n\n    describe('when research update itself is draft', () => {\n      it('does not send any notifications when researchUpdate.isDraft is true', () => {\n        const researchUpdate = FactoryResearchItemUpdate({\n          isDraft: true,\n          research: mockResearch,\n        });\n\n        new BroadcastCoordinationServiceServer(mockClient).researchUpdate(\n          researchUpdate,\n          mockProfile,\n          mockRequest,\n        );\n\n        expect(\n          NotificationsSupabaseServiceServer.prototype.createNotificationsResearchUpdate,\n        ).not.toHaveBeenCalled();\n        expect(discordServiceServer.postWebhookRequest).not.toHaveBeenCalled();\n      });\n    });\n\n    describe('when transitioning from non-draft to non-draft', () => {\n      it('does not send any notifications when researchUpdate.isDraft is false and oldResearchUpdate.is_draft is false', () => {\n        const researchUpdate = FactoryResearchItemUpdate({\n          isDraft: false,\n          research: mockResearch,\n        });\n        const oldResearchUpdate = FactoryDBResearchItemUpdate({\n          is_draft: false,\n        });\n\n        new BroadcastCoordinationServiceServer(mockClient).researchUpdate(\n          researchUpdate,\n          mockProfile,\n          mockRequest,\n          oldResearchUpdate,\n        );\n\n        expect(\n          NotificationsSupabaseServiceServer.prototype.createNotificationsResearchUpdate,\n        ).not.toHaveBeenCalled();\n        expect(discordServiceServer.postWebhookRequest).not.toHaveBeenCalled();\n      });\n    });\n\n    describe('when transitioning from draft to published', () => {\n      it('sends all notifications when researchUpdate.isDraft is false and oldResearchUpdate.is_draft is true', () => {\n        const researchUpdate = FactoryResearchItemUpdate({\n          isDraft: false,\n          research: mockResearch,\n        });\n        const oldResearchUpdate = FactoryDBResearchItemUpdate({\n          is_draft: true,\n        });\n\n        new BroadcastCoordinationServiceServer(mockClient).researchUpdate(\n          researchUpdate,\n          mockProfile,\n          mockRequest,\n          oldResearchUpdate,\n        );\n\n        expect(\n          NotificationsSupabaseServiceServer.prototype.createNotificationsResearchUpdate,\n        ).toHaveBeenCalledWith(mockResearch, researchUpdate, mockProfile);\n        expect(discordServiceServer.postWebhookRequest).toHaveBeenCalled();\n      });\n\n      it('sends all notifications when researchUpdate.isDraft is false and oldResearchUpdate is not provided', () => {\n        const researchUpdate = FactoryResearchItemUpdate({\n          isDraft: false,\n          research: mockResearch,\n        });\n\n        new BroadcastCoordinationServiceServer(mockClient).researchUpdate(\n          researchUpdate,\n          mockProfile,\n          mockRequest,\n        );\n\n        expect(\n          NotificationsSupabaseServiceServer.prototype.createNotificationsResearchUpdate,\n        ).toHaveBeenCalledWith(mockResearch, researchUpdate, mockProfile);\n        expect(discordServiceServer.postWebhookRequest).toHaveBeenCalled();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "src/test/services/notificationsPreferencesService.test.ts",
    "content": "import { notificationsPreferencesService } from 'src/services/notificationsPreferencesService';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst mockFetch = vi.fn();\nglobal.fetch = mockFetch;\n\nconst mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});\n\ndescribe('notificationsPreferencesService', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  describe('getPreferences', () => {\n    it('returns preferences when fetch succeeds', async () => {\n      const mockPreferences = { id: 1, comments: true, replies: false };\n      mockFetch.mockResolvedValue({\n        json: () => Promise.resolve({ preferences: mockPreferences }),\n      });\n\n      const result = await notificationsPreferencesService.getPreferences();\n\n      expect(mockFetch).toHaveBeenCalledWith('/api/notifications-preferences');\n      expect(result).toEqual(mockPreferences);\n    });\n\n    it('returns null when fetch fails', async () => {\n      mockFetch.mockRejectedValue(new Error('Network error'));\n\n      const result = await notificationsPreferencesService.getPreferences();\n\n      expect(result).toBeNull();\n      expect(mockConsoleError).toHaveBeenCalledWith(expect.any(Error));\n    });\n\n    it('returns null when json parsing fails', async () => {\n      mockFetch.mockResolvedValue({\n        json: () => Promise.reject(new Error('Invalid JSON')),\n      });\n\n      const result = await notificationsPreferencesService.getPreferences();\n\n      expect(result).toBeNull();\n      expect(mockConsoleError).toHaveBeenCalledWith(expect.any(Error));\n    });\n  });\n\n  describe('setPreferences', () => {\n    it('sends correct FormData when id is provided', async () => {\n      const mockResponse = { ok: true };\n      mockFetch.mockResolvedValue(mockResponse);\n\n      const formData = {\n        id: 123,\n        comments: true,\n        replies: false,\n        research_updates: true,\n      };\n\n      const result = await notificationsPreferencesService.setPreferences(formData);\n\n      expect(mockFetch).toHaveBeenCalledWith('/api/notifications-preferences', {\n        method: 'POST',\n        body: expect.any(FormData),\n      });\n\n      const [, options] = mockFetch.mock.calls[0];\n      const body = options.body as FormData;\n\n      expect(body.get('id')).toBe('123');\n      expect(body.get('comments')).toBe('true');\n      expect(body.get('replies')).toBe('false');\n      expect(body.get('research_updates')).toBe('true');\n      expect(body.get('is_unsubscribed')).toBe('false');\n      expect(result).toBe(mockResponse);\n    });\n\n    it('sends correct FormData when id is not provided', async () => {\n      const mockResponse = { ok: true };\n      mockFetch.mockResolvedValue(mockResponse);\n\n      const formData = {\n        comments: false,\n        replies: true,\n        research_updates: false,\n      };\n\n      await notificationsPreferencesService.setPreferences(formData);\n      const [, options] = mockFetch.mock.calls[0];\n      const body = options.body as FormData;\n\n      expect(body.get('id')).toBeNull();\n      expect(body.get('comments')).toBe('false');\n      expect(body.get('replies')).toBe('true');\n      expect(body.get('research_updates')).toBe('false');\n      expect(body.get('is_unsubscribed')).toBe('false');\n    });\n\n    it('sends correct FormData when id is undefined', async () => {\n      mockFetch.mockResolvedValue({ ok: true });\n\n      const formData = {\n        id: undefined,\n        comments: true,\n        replies: true,\n        research_updates: true,\n      };\n\n      await notificationsPreferencesService.setPreferences(formData);\n      const [, options] = mockFetch.mock.calls[0];\n      const body = options.body as FormData;\n\n      expect(body.get('id')).toBeNull();\n    });\n  });\n\n  describe('setUnsubscribe', () => {\n    it('sends correct FormData with all preferences disabled when id is provided', async () => {\n      const mockResponse = { ok: true };\n      mockFetch.mockResolvedValue(mockResponse);\n\n      const result = await notificationsPreferencesService.setUnsubscribe(456);\n\n      expect(mockFetch).toHaveBeenCalledWith('/api/notifications-preferences', {\n        method: 'POST',\n        body: expect.any(FormData),\n      });\n\n      const [, options] = mockFetch.mock.calls[0];\n      const body = options.body as FormData;\n\n      expect(body.get('id')).toBe('456');\n      expect(body.get('comments')).toBe('false');\n      expect(body.get('replies')).toBe('false');\n      expect(body.get('research_updates')).toBe('false');\n      expect(body.get('is_unsubscribed')).toBe('true');\n      expect(result).toBe(mockResponse);\n    });\n\n    it('sends correct FormData without id when id is undefined', async () => {\n      mockFetch.mockResolvedValue({ ok: true });\n\n      await notificationsPreferencesService.setUnsubscribe(undefined);\n\n      const [, options] = mockFetch.mock.calls[0];\n      const body = options.body as FormData;\n\n      expect(body.get('id')).toBeNull();\n      expect(body.get('comments')).toBe('false');\n      expect(body.get('replies')).toBe('false');\n      expect(body.get('research_updates')).toBe('false');\n      expect(body.get('is_unsubscribed')).toBe('true');\n    });\n  });\n});\n"
  },
  {
    "path": "src/test/services/notificationsPreferencesViaEmailService.test.ts",
    "content": "import { notificationsPreferencesViaEmailService } from 'src/services/notificationsPreferencesViaEmailService';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst createFetchResponse = (data: any, ok = true, status = 200) => ({\n  ok,\n  status,\n  json: async () => data,\n});\n\ndescribe('notificationsPreferencesViaEmailService', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    global.fetch = vi.fn();\n  });\n\n  describe('getPreferences', () => {\n    it('should return preferences when API call succeeds', async () => {\n      const mockPreferences = {\n        is_contactable: true,\n        preferences: {\n          id: 1,\n          user_id: 123,\n          comments: true,\n          replies: false,\n          research_updates: true,\n          is_unsubscribed: false,\n        },\n      };\n\n      global.fetch = vi.fn().mockResolvedValue(createFetchResponse(mockPreferences));\n\n      const result = await notificationsPreferencesViaEmailService.getPreferences('user123');\n\n      expect(fetch).toHaveBeenCalledWith('/api/notifications-preferences-via-email/user123');\n      expect(result).toEqual(mockPreferences);\n    });\n\n    it('should return null when API returns HTTP error', async () => {\n      global.fetch = vi.fn().mockResolvedValue(createFetchResponse({}, false, 404));\n\n      const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\n      const result = await notificationsPreferencesViaEmailService.getPreferences('user123');\n\n      expect(result).toBeNull();\n      expect(consoleSpy).toHaveBeenCalledWith('HTTP error! status: 404');\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should return null when network error occurs', async () => {\n      global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));\n\n      const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\n      const result = await notificationsPreferencesViaEmailService.getPreferences('user123');\n\n      expect(result).toBeNull();\n      expect(consoleSpy).toHaveBeenCalledWith(new Error('Network error'));\n\n      consoleSpy.mockRestore();\n    });\n\n    it('should return null when JSON parsing fails', async () => {\n      global.fetch = vi.fn().mockResolvedValue({\n        ok: true,\n        json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),\n      });\n\n      const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\n      const result = await notificationsPreferencesViaEmailService.getPreferences('user123');\n\n      expect(result).toBeNull();\n      expect(consoleSpy).toHaveBeenCalledWith(new Error('Invalid JSON'));\n\n      consoleSpy.mockRestore();\n    });\n  });\n\n  describe('setPreferences', () => {\n    it('should send POST request with form data for preference updates', async () => {\n      const mockResponse = createFetchResponse({ success: true });\n      global.fetch = vi.fn().mockResolvedValue(mockResponse);\n\n      const testData = {\n        comments: true,\n        replies: false,\n        research_updates: true,\n        userCode: 'user123',\n      };\n\n      const result = await notificationsPreferencesViaEmailService.setPreferences(testData);\n\n      expect(fetch).toHaveBeenCalledWith(\n        '/api/notifications-preferences-via-email/user123',\n        expect.objectContaining({\n          method: 'POST',\n          body: expect.any(FormData),\n        }),\n      );\n\n      const formData = (fetch as any).mock.calls[0][1].body;\n      expect(formData.get('comments')).toBe('true');\n      expect(formData.get('replies')).toBe('false');\n      expect(formData.get('research_updates')).toBe('true');\n      expect(formData.get('is_unsubscribed')).toBe('false');\n      expect(result).toBe(mockResponse);\n    });\n\n    it('should handle boolean false values correctly in form data', async () => {\n      const mockResponse = createFetchResponse({ success: true });\n      global.fetch = vi.fn().mockResolvedValue(mockResponse);\n\n      const testData = {\n        comments: false,\n        replies: false,\n        research_updates: false,\n        userCode: 'user123',\n      };\n\n      await notificationsPreferencesViaEmailService.setPreferences(testData);\n\n      const formData = (fetch as any).mock.calls[0][1].body;\n      expect(formData.get('comments')).toBe('false');\n      expect(formData.get('replies')).toBe('false');\n      expect(formData.get('research_updates')).toBe('false');\n    });\n  });\n\n  describe('setUnsubscribe', () => {\n    it('should send POST request with unsubscribe data including id when provided', async () => {\n      const mockResponse = createFetchResponse({ success: true });\n      global.fetch = vi.fn().mockResolvedValue(mockResponse);\n\n      const result = await notificationsPreferencesViaEmailService.setUnsubscribe('user123', 456);\n\n      expect(fetch).toHaveBeenCalledWith(\n        '/api/notifications-preferences-via-email/user123',\n        expect.objectContaining({\n          method: 'POST',\n          body: expect.any(FormData),\n        }),\n      );\n\n      const formData = (fetch as any).mock.calls[0][1].body;\n      expect(formData.get('id')).toBe('456');\n      expect(formData.get('comments')).toBe('false');\n      expect(formData.get('replies')).toBe('false');\n      expect(formData.get('research_updates')).toBe('false');\n      expect(formData.get('is_unsubscribed')).toBe('true');\n      expect(result).toBe(mockResponse);\n    });\n\n    it('should send POST request without id when not provided', async () => {\n      const mockResponse = createFetchResponse({ success: true });\n      global.fetch = vi.fn().mockResolvedValue(mockResponse);\n\n      await notificationsPreferencesViaEmailService.setUnsubscribe('user123');\n\n      const formData = (fetch as any).mock.calls[0][1].body;\n      expect(formData.get('id')).toBeNull();\n      expect(formData.get('comments')).toBe('false');\n      expect(formData.get('replies')).toBe('false');\n      expect(formData.get('research_updates')).toBe('false');\n      expect(formData.get('is_unsubscribed')).toBe('true');\n    });\n\n    it('should handle undefined id parameter', async () => {\n      const mockResponse = createFetchResponse({ success: true });\n      global.fetch = vi.fn().mockResolvedValue(mockResponse);\n\n      await notificationsPreferencesViaEmailService.setUnsubscribe('user123', undefined);\n\n      const formData = (fetch as any).mock.calls[0][1].body;\n      expect(formData.get('id')).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "src/test/services/subscribersService.server.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { SubscribersServiceServer } from '../../services/subscribersService.server';\nimport {\n  FactoryDBResearchItem,\n  FactoryResearchItem,\n  FactoryResearchItemUpdate,\n} from '../factories/ResearchItem';\nimport { createMockSupabaseClient } from '../utils/supabaseClientMock';\n\nimport type { DBResearchItem, ResearchItem, ResearchUpdate } from 'oa-shared';\n\ndescribe('subscribersServiceServer', () => {\n  beforeEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  describe('addResearchSubscribers', () => {\n    it('calls add once when no collaborators are present', async () => {\n      const { client } = createMockSupabaseClient();\n\n      const research: ResearchItem = FactoryResearchItem({\n        id: 123,\n        collaboratorsUsernames: [],\n      });\n      const profileId = 456;\n\n      const service = new SubscribersServiceServer(client);\n      const mockAdd = vi.spyOn(service, 'add').mockImplementation(vi.fn());\n\n      await service.addResearchSubscribers(\n        research,\n        profileId,\n      );\n\n      expect(mockAdd).toHaveBeenCalledTimes(1);\n      expect(mockAdd).toHaveBeenCalledWith('research', 123, 456);\n    });\n\n    it('calls add right number of times for unique collaborators', async () => {\n      const { client, mocks } = createMockSupabaseClient();\n      mocks.single.mockResolvedValueOnce({ data: { id: 55 } });\n      mocks.single.mockResolvedValueOnce({ data: { id: 55 } });\n      mocks.single.mockResolvedValueOnce({ data: { id: 122 } });\n\n      const research: ResearchItem = FactoryResearchItem({\n        id: 123,\n        collaboratorsUsernames: ['ben', 'ben', 'jeff'],\n      });\n      const profileId = 456;\n\n      const service = new SubscribersServiceServer(client);\n      const mockAdd = vi.spyOn(service, 'add').mockImplementation(vi.fn());\n\n      await service.addResearchSubscribers(\n        research,\n        profileId,\n      );\n\n      expect(mockAdd).toHaveBeenCalledTimes(3);\n      expect(mockAdd).toHaveBeenCalledWith('research', 123, 456);\n    });\n  });\n\n  describe('addResearchUpdateSubscribers', () => {\n    it('calls add twice when no collaborators are present', async () => {\n      const { client } = createMockSupabaseClient();\n\n      const update: ResearchUpdate = FactoryResearchItemUpdate({\n        id: 789,\n        research: FactoryDBResearchItem({\n          created_by: 88,\n          collaborators: [],\n        }),\n      });\n      const profileId = 456;\n\n      const service = new SubscribersServiceServer(client);\n      const mockAdd = vi.spyOn(service, 'add').mockImplementation(vi.fn());\n\n      await service.addResearchUpdateSubscribers(\n        update,\n        profileId,\n      );\n\n      expect(mockAdd).toHaveBeenCalledTimes(2);\n      expect(mockAdd).toHaveBeenCalledWith('research_updates', 789, 456);\n      expect(mockAdd).toHaveBeenCalledWith('research_updates', 789, 88);\n    });\n  });\n\n  describe('updateResearchSubscribers', () => {\n    it('does not call add when no new collaborators are present', async () => {\n      const { client } = createMockSupabaseClient();\n\n      const oldResearch: DBResearchItem = FactoryDBResearchItem({\n        id: 789,\n        collaborators: ['luke', 'leia'],\n      });\n\n      const newResearch: ResearchItem = FactoryResearchItem({\n        id: 789,\n        collaboratorsUsernames: ['luke', 'leia'],\n      });\n\n      const service = new SubscribersServiceServer(client);\n      const mockAdd = vi.spyOn(service, 'add').mockImplementation(vi.fn());\n\n      await service.updateResearchSubscribers(\n        oldResearch,\n        newResearch,\n      );\n\n      expect(mockAdd).toHaveBeenCalledTimes(0);\n    });\n\n    it('calls add for each new unique collaborator', async () => {\n      const { client, mocks } = createMockSupabaseClient();\n\n      mocks.single.mockResolvedValueOnce({ data: { id: 99 } });\n      mocks.single.mockResolvedValueOnce({ data: { id: 100 } });\n\n      const oldResearch: DBResearchItem = FactoryDBResearchItem({\n        id: 789,\n        collaborators: ['luke', 'leia'],\n      });\n\n      const newResearch: ResearchItem = FactoryResearchItem({\n        id: 789,\n        collaboratorsUsernames: ['luke', 'leia', 'han', 'chewie'],\n      });\n\n      const service = new SubscribersServiceServer(client);\n      const mockAdd = vi.spyOn(service, 'add').mockImplementation(vi.fn());\n\n      await service.updateResearchSubscribers(\n        oldResearch,\n        newResearch,\n      );\n\n      expect(mockAdd).toHaveBeenCalledTimes(2);\n      expect(mockAdd).toHaveBeenCalledWith('research', 789, 99);\n      expect(mockAdd).toHaveBeenCalledWith('research', 789, 100);\n    });\n  });\n});\n"
  },
  {
    "path": "src/test/setup.ts",
    "content": "import { cleanup } from '@testing-library/react';\nimport { afterEach } from 'vitest';\n\n// Mock HTMLDialogElement methods (not available in jsdom)\nHTMLDialogElement.prototype.showModal = function () {\n  this.open = true;\n};\n\nHTMLDialogElement.prototype.close = function () {\n  this.open = false;\n};\n\nif (!globalThis.defined) {\n  globalThis.defined = true;\n}\n\n// runs a cleanup after each test case (e.g. clearing jsdom)\nafterEach(() => {\n  cleanup();\n});\n\nglobalThis.resetBeforeEachTest = true;\n"
  },
  {
    "path": "src/test/utils/supabaseClientMock.ts",
    "content": "import type { SupabaseClient } from '@supabase/supabase-js';\nimport { vi } from 'vitest';\n\nexport const createMockSupabaseClient = () => {\n  const mockSingle = vi.fn();\n  const mockMaybeSingle = vi.fn();\n  const mockEq = vi.fn();\n  const mockSelect = vi.fn();\n  const mockFrom = vi.fn();\n  const mockRpc = vi.fn();\n  const mockFunctionsInvoke = vi.fn();\n  const mockUpdate = vi.fn();\n  const mockInsert = vi.fn();\n  const mockDelete = vi.fn();\n  const mockLimit = vi.fn();\n  const mockGetClaims = vi.fn();\n\n  const mockClient = {\n    auth: {\n      getClaims: mockGetClaims,\n    },\n    from: mockFrom,\n    rpc: mockRpc,\n    functions: {\n      invoke: mockFunctionsInvoke,\n    },\n  } as unknown as SupabaseClient;\n\n  const createQueryBuilder = () => ({\n    select: mockSelect,\n    eq: mockEq,\n    single: mockSingle,\n    maybeSingle: mockMaybeSingle,\n    update: mockUpdate,\n    insert: mockInsert,\n    delete: mockDelete,\n    limit: mockLimit,\n  });\n\n  mockFrom.mockImplementation(() => createQueryBuilder());\n  mockSelect.mockImplementation(() => createQueryBuilder());\n  mockEq.mockImplementation(() => createQueryBuilder());\n  mockUpdate.mockImplementation(() => createQueryBuilder());\n  mockInsert.mockImplementation(() => createQueryBuilder());\n  mockDelete.mockImplementation(() => createQueryBuilder());\n  mockLimit.mockImplementation(() => createQueryBuilder());\n\n  return {\n    client: mockClient,\n    mocks: {\n      auth: {\n        getClaims: mockGetClaims,\n      },\n      from: mockFrom,\n      select: mockSelect,\n      eq: mockEq,\n      single: mockSingle,\n      maybeSingle: mockMaybeSingle,\n      update: mockUpdate,\n      insert: mockInsert,\n      delete: mockDelete,\n      limit: mockLimit,\n      rpc: mockRpc,\n      functionsInvoke: mockFunctionsInvoke,\n    },\n  };\n};\n"
  },
  {
    "path": "src/types/emotion.d.ts",
    "content": "import '@emotion/react';\n\nimport type { PlatformTheme } from 'oa-themes';\n\ndeclare module '@emotion/react' {\n  export interface Theme extends PlatformTheme {}\n}\n\ndeclare module '@theme-ui/css' {\n  export interface Theme extends PlatformTheme {}\n}\n"
  },
  {
    "path": "src/utils/comparisons.ts",
    "content": "import { isEqual } from 'lodash';\n\nimport type { ISelectedTags, Tag } from 'oa-shared';\n\n/** Functions used to give as callback to the isEqual prop of form fields.\n *  The isEqual callback is used to determine if a field is dirty.\n */\nexport const COMPARISONS = {\n  textInput: (a: string, b: string): boolean => {\n    if (!a && !b) {\n      return true;\n    }\n    return a === b;\n  },\n  tags: (a: ISelectedTags, b: ISelectedTags): boolean => {\n    return isEqual(a, b);\n  },\n  tagsSupabase: (a: Tag, b: Tag): boolean => {\n    if (!a && !b) return true; // both null = equal\n    return !!a && a?.id === b?.id;\n  },\n  image: (a, b): boolean => {\n    // When there was no image and there still isn't one it's not dirty\n    if (!a && !b) {\n      return true;\n    }\n    // when there was an image and it's no longer there or the other way around it's dirty\n    if ((!a && b) || (!b && a)) {\n      return false;\n    }\n    // When a new image is selected the type is IConvertedFileMeta which has a\n    // blob property.\n    if ((a && a.blob) || (b && b.blob)) {\n      return false;\n    }\n    // When the image didn't change it's not dirty\n    return a.fullPath === b.fullPath && a.size === b.size;\n  },\n  step: (a, b): boolean => {\n    if (!a && !b) {\n      return true;\n    }\n    if (!a || !b) {\n      return false;\n    }\n    // Only check the number of steps because the individual components of a step will\n    // also mark the form as dirty\n    return a.length === b.length;\n  },\n};\n"
  },
  {
    "path": "src/utils/contentType.utils.ts",
    "content": "export function resolveType(type: string) {\n  if (type === 'research_update') {\n    return 'research_updates';\n  }\n  if (type === 'project') {\n    return 'projects';\n  }\n\n  return type;\n}\n"
  },
  {
    "path": "src/utils/filters.ts",
    "content": "import { startOfToday, startOfTomorrow, startOfWeek, startOfYesterday } from 'date-fns';\n\nimport type { ISODateString } from 'oa-shared';\n\n/*\n    Manual implementation of filters commonly used through the app\n    In the future this could possibly be replaced by more comprehensive libraries\n*/\n\n/************************************************************************\n *             Array Methods\n ***********************************************************************/\n/**\n * Test whether a one array contains all string values of another array\n * @param arr1 The array that will be tested, e.g [\"a\",\"b\",\"c\"]\n * @param arr2 The values to test, e.g. [\"a\",\"c\"]\n */\nexport const includesAll = (arr1: string[], arr2: string[]) => {\n  return arr1.every((val) => arr2.includes(val));\n};\n\n/************************************************************************\n *              Date Methods\n ***********************************************************************/\nconst olderThan = (chosenDate: dateType, compareToDate: dateType) => {\n  const d1 = _formatDate(chosenDate);\n  const d2 = _formatDate(compareToDate);\n  return d1 < d2;\n};\nconst newerThan = (chosenDate: dateType, compareToDate: dateType) => {\n  const d1 = _formatDate(chosenDate);\n  const d2 = _formatDate(compareToDate);\n  return d1 > d2;\n};\n\n/************************************************************************\n *              Exports\n ***********************************************************************/\nexport default { olderThan, newerThan };\n\n/************************************************************************\n *              Helper Methods\n ***********************************************************************/\n\n// Take date in various formats and return as a Date object\nconst _formatDate = (date: dateType): Date => {\n  const d: any = date;\n  // case date object\n  return relativeDates.includes(d)\n    ? _datestringToDate(date as RelativeDateString)\n    : new Date(d as ISODateString);\n};\n\n// convert standard named dates (e.g. yesterday, lastweek, lastmonth) to date objects\nconst _datestringToDate = (str: RelativeDateString) => {\n  switch (str) {\n    case 'yesterday':\n      return startOfYesterday();\n    case 'tomorrow':\n      return startOfTomorrow();\n    case 'thisweek':\n      return startOfWeek(new Date());\n    case 'today':\n      return startOfToday();\n  }\n};\n\n/************************************************************************\n *             Interfaces\n ***********************************************************************/\n// dates come in lots of different formats in the app, here's a general catch-all\ntype dateType = ISODateString | RelativeDateString;\n// some custom strings used to describe named dates\ntype RelativeDateString = 'yesterday' | 'tomorrow' | 'thisweek' | 'today';\n\nconst relativeDates: RelativeDateString[] = ['thisweek', 'today', 'tomorrow', 'yesterday'];\n"
  },
  {
    "path": "src/utils/fireConfetti.ts",
    "content": "import confetti from 'canvas-confetti';\n\nconst defaultConfig = {\n  particleCount: 5,\n  spread: 120,\n  colors: ['#FDCE4E', '#E9475A', '#21B7EB'],\n  // The overlay in some content creation has 4000 z-index\n  zIndex: 4001,\n};\n\n/**\n * Util that triggers a confetti animation, respecting user's reduced motion preferences.\n * @param options Optional override settings for confetti appearance and behavior.\n */\nexport const fireConfetti = (options: confetti.Options = {}) => {\n  if (\n    typeof window !== 'undefined' &&\n    window.matchMedia('(prefers-reduced-motion: reduce)').matches\n  )\n    return;\n\n  const config: confetti.Options = { ...defaultConfig, ...options };\n\n  const end = Date.now() + 500;\n\n  (function frame() {\n    confetti({\n      ...config,\n      origin: { x: 0 },\n      angle: 60,\n    });\n    confetti({\n      ...config,\n      origin: { x: 1 },\n      angle: 120,\n    });\n\n    if (Date.now() < end) {\n      requestAnimationFrame(frame);\n    }\n  })();\n};\n"
  },
  {
    "path": "src/utils/formatImageListForGallery.ts",
    "content": "import type { Image } from 'oa-shared';\n\nexport const formatImagesForGallery = (imageList: Image[], altPrefix?: string) => {\n  if (!imageList) {\n    return [];\n  }\n\n  return imageList\n    .filter(Boolean)\n    .filter((i: Image) => !!i?.publicUrl)\n    .map((image: Image, index: number) => ({\n      downloadUrl: image.publicUrl,\n      thumbnailUrl: image.publicUrl,\n      alt: `${altPrefix ? altPrefix + ' ' : ''}Gallery image ${index + 1}`,\n    }));\n};\n"
  },
  {
    "path": "src/utils/getLocationData.ts",
    "content": "import type { ILatLng, MapPinFormData } from 'oa-shared';\n\n/** Retrieve OSM data for a specific lat-lon */\nexport const getLocationData = async (latlng: ILatLng): Promise<MapPinFormData> => {\n  const { lat, lng } = latlng;\n  const response = await (\n    await fetch(\n      `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=en`,\n      {\n        headers: new Headers({\n          'User-Agent': 'onearmy.earth Community Platform (https://platform.onearmy.earth)',\n        }),\n      },\n    )\n  ).json();\n\n  let name = response.address.city || response.address.town || response.address.village || '';\n\n  if (name) {\n    name += `, ${response.address.country}`;\n  } else {\n    name = response.address.country;\n  }\n\n  const location: MapPinFormData = {\n    country: response.address.country || '',\n    countryCode: response.address.country_code || '',\n    administrative: response.address.county || '',\n    lat: lat,\n    lng: lng,\n    postCode: response.address.postcode || '',\n    name: name,\n  };\n\n  return location;\n};\n"
  },
  {
    "path": "src/utils/getSummaryFromMarkdown.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { getSummaryFromMarkdown } from './getSummaryFromMarkdown';\n\ndescribe('getSummaryFromMarkdown', () => {\n  it('removes markdown elements', () => {\n    const simpleHeading = `**A** B\n\n* C\n\n> D\n\nE\n`;\n    expect(getSummaryFromMarkdown(simpleHeading)).toEqual('A B D');\n  });\n});\n"
  },
  {
    "path": "src/utils/getSummaryFromMarkdown.ts",
    "content": "import { marked } from 'marked';\n\nexport const getSummaryFromMarkdown = (text: string) => {\n  // Works for headings, paragraphs, etc.\n  // Doesn't work for at list items and maybe more.\n\n  if (!text) {\n    return null;\n  }\n  // has to go to any as unhelpfully typed\n  const linesWithTokens = marked\n    .lexer(text)\n    .filter(({ tokens }: any) => !!tokens && tokens.length > 0);\n  const linesWithText = linesWithTokens.map((line: any) => line.tokens).flat();\n\n  const flattenedLines = linesWithText\n    .slice(0, 3)\n    .map((token) => token['text'] && token['text'].trim());\n\n  return flattenedLines\n    .join(' ')\n    .replaceAll('*', '')\n    .replaceAll('_', '')\n    .replaceAll('~~', '')\n    .replaceAll('`', '')\n    .replaceAll('<u>', '')\n    .replaceAll('</u>', '')\n    .replaceAll('<code>', '')\n    .replaceAll('</code>', '');\n};\n"
  },
  {
    "path": "src/utils/helpers.test.ts",
    "content": "import { UserRole } from 'oa-shared';\nimport { FactoryUser } from 'src/test/factories/User';\nimport { describe, expect, it } from 'vitest';\n\nimport {\n  arrayToJson,\n  capitalizeFirstLetter,\n  formatLowerNoSpecial,\n  hasAdminRights,\n  isContactable,\n  isUserBlockedFromMessaging,\n  isUserContactable,\n  numberWithCommas,\n  stripSpecialCharacters,\n} from './helpers';\n\ndescribe('src/utils/helpers', () => {\n  it('stripSpecialCharacters should remove special characters and replace spaces with dashes', () => {\n    expect(stripSpecialCharacters('He%llo w@o$rl^d!')).toBe('Hello-world');\n  });\n\n  it('formatLowerNoSpecial should return lowercase without special characters', () => {\n    expect(formatLowerNoSpecial('He%llo w@o$rl^d!')).toBe('hello-world');\n  });\n\n  it('arrayToJson should convert array to JSON object', () => {\n    expect(arrayToJson([{ id: 'abc', val: 'hello' }], 'id')).toEqual({\n      abc: { id: 'abc', val: 'hello' },\n    });\n  });\n\n  it('capitalizeFirstLetter should return string with first letter capitalized', () => {\n    expect(capitalizeFirstLetter('hello world')).toBe('Hello world');\n  });\n\n  describe('hasAdminRights', () => {\n    it('should return false when user is not provided', () => {\n      expect(hasAdminRights()).toBe(false);\n    });\n\n    it('should return false when user does not have any roles', () => {\n      const user = FactoryUser({ roles: [] });\n      expect(hasAdminRights(user)).toBe(false);\n    });\n\n    it('should return false when user does not have admin or super-admin roles', () => {\n      const user = FactoryUser({ roles: [UserRole.BETA_TESTER] });\n      expect(hasAdminRights(user)).toBe(false);\n    });\n\n    it('should return true when user has admin role', () => {\n      const user = FactoryUser({ roles: [UserRole.ADMIN] });\n      expect(hasAdminRights(user)).toBe(true);\n    });\n  });\n\n  describe('isUserBlockedFromMessaging', () => {\n    it('should return true when a user is blocked', () => {\n      const user = FactoryUser({ isBlockedFromMessaging: true });\n      expect(isUserBlockedFromMessaging(user)).toBe(true);\n    });\n\n    it(\"should return null when a user isn't present\", () => {\n      expect(isUserBlockedFromMessaging(null)).toBe(null);\n    });\n\n    it(\"should return true when a user isn't blocked\", () => {\n      const user = FactoryUser({ isBlockedFromMessaging: false });\n      expect(isUserBlockedFromMessaging(user)).toBe(false);\n    });\n  });\n\n  describe('isUserContactable', () => {\n    it('should default to true when field empty on user', () => {\n      const user = FactoryUser({ isContactable: undefined });\n      expect(isUserContactable(user)).toBe(true);\n    });\n\n    it('should return true when a user is contactable', () => {\n      const user = FactoryUser({ isContactable: true });\n      expect(isUserContactable(user)).toBe(true);\n    });\n\n    it(\"should return false when a user isn't contactable\", () => {\n      const user = FactoryUser({ isContactable: false });\n      expect(isUserContactable(user)).toBe(false);\n    });\n  });\n\n  describe('isContactable', () => {\n    it('should default to true when field undefined', () => {\n      expect(isContactable(null)).toBe(true);\n    });\n\n    it('should return true when given true', () => {\n      const user = FactoryUser({ isContactable: true });\n      expect(isContactable(user.isContactable as boolean)).toBe(true);\n    });\n\n    it('should return false when given false', () => {\n      expect(isContactable(false)).toBe(false);\n    });\n  });\n\n  describe('numberWithCommas', () => {\n    it('adds a comma between every three digits', () => {\n      const expectation = '1,000';\n      expect(numberWithCommas(1000)).toEqual(expectation);\n    });\n  });\n});\n"
  },
  {
    "path": "src/utils/helpers.ts",
    "content": "import type { DBProfile, IModeration, Profile } from 'oa-shared';\nimport { UserRole } from 'oa-shared';\nimport { DEFAULT_PUBLIC_CONTACT_PREFERENCE } from 'src/pages/UserSettings/constants';\n\nconst specialCharactersPattern = /[^a-zA-Z0-9_-]/gi;\n\n// remove special characters from string, also replacing spaces with dashes\nexport const stripSpecialCharacters = (text: string) => {\n  return text ? text.split(' ').join('-').replace(specialCharactersPattern, '') : '';\n};\n\n// get special characters from string using the same pattern as stripSpecialCharacters\nexport const getSpecialCharacters = (text: string): string[] =>\n  Array.from(text.matchAll(specialCharactersPattern)).map((x) => x[0]);\n\n// convert to lower case and remove any special characters\nexport const formatLowerNoSpecial = (text: string) => {\n  return stripSpecialCharacters(text).toLowerCase();\n};\n\n// take an array of objects and convert to an single object, using a unique key\n// that already exists in the array element, i.e.\n// [{id:'abc',val:'hello'},{id:'def',val:'world'}] = > {abc:{id:abc,val:'hello}, def:{id:'def',val:'world'}}\nexport const arrayToJson = (arr: any[], keyField: string) => {\n  const json = {};\n  arr.forEach((el) => {\n    if (Object.hasOwn(el, keyField)) {\n      const key = el[keyField];\n      json[key] = el;\n    }\n  });\n  return json;\n};\n\nexport const numberWithCommas = (number: number) => {\n  return new Intl.NumberFormat('en-US').format(number);\n};\n\n// Take a string and capitalises the first letter\n// hello world => Hello world\nexport const capitalizeFirstLetter = (str: string) => {\n  if (!str || typeof str !== 'string') return '';\n  return str.charAt(0).toUpperCase() + str.slice(1);\n};\n\n/************************************************************************\n *              Date Methods\n ***********************************************************************/\nexport const getMonth = (d: Date, monthType: 'long' | 'short' = 'long') => {\n  // use ECMAScript Internationalization API to return month\n  return `${d.toLocaleString('en-us', { month: monthType })}`;\n};\nexport const getDay = (d: Date) => {\n  return `${d.getDate()}`;\n};\n\nexport const hasAdminRights = (user?: DBProfile | Partial<Profile>) => {\n  if (!user) {\n    return false;\n  }\n  const roles = user.roles && Array.isArray(user.roles) ? user.roles : [];\n\n  return roles.includes(UserRole.ADMIN);\n};\n\nexport const needsModeration = (doc: IModeration, user?: Profile) => {\n  if (!hasAdminRights(user)) {\n    return false;\n  }\n  return doc.moderation !== 'accepted';\n};\n\nexport const isUserBlockedFromMessaging = (user: Partial<Profile> | null | undefined) => {\n  if (!user) {\n    return null;\n  }\n  return user.isBlockedFromMessaging;\n};\n\nexport const isUserContactable = (user: Partial<Profile>) => {\n  if (typeof user.isContactable === 'boolean') {\n    return isContactable(user.isContactable);\n  }\n\n  return isContactable(null);\n};\n\nexport const isContactable = (preference: boolean | null) => {\n  return typeof preference === 'boolean' ? preference : DEFAULT_PUBLIC_CONTACT_PREFERENCE;\n};\n\nexport const buildStatisticsLabel = ({\n  stat,\n  statUnit,\n  usePlural,\n}: {\n  stat: number | undefined;\n  statUnit: string;\n  usePlural: boolean;\n}): string => {\n  if (stat === 1 || !usePlural) {\n    return `${statUnit}`;\n  }\n\n  return `${statUnit}s`;\n};\n"
  },
  {
    "path": "src/utils/httpException.ts",
    "content": "import { HTTPException } from 'hono/http-exception';\nimport { ContentfulStatusCode } from 'hono/utils/http-status';\n\n/**\n * Create a structured HTTP exception with error details\n * Similar to RFC 7807 Problem Details but using Hono's HTTPException\n */\nfunction createHTTPException(\n  status: ContentfulStatusCode,\n  message: string,\n  details?: Record<string, any>,\n) {\n  return new HTTPException(status, {\n    message,\n    res: Response.json(\n      {\n        error: message,\n        status,\n        ...details,\n      },\n      { status },\n    ),\n  });\n}\n\nexport function validationError(message: string, field?: string) {\n  return createHTTPException(400, message, { field });\n}\n\nexport function unauthorizedError() {\n  return createHTTPException(401, 'Unauthorized');\n}\n\nexport function methodNotAllowedError() {\n  return createHTTPException(405, 'Method not allowed');\n}\n\nexport function notFoundError(resource: string) {\n  return createHTTPException(404, `${resource} not found`);\n}\n\nexport function forbiddenError(message = 'Forbidden') {\n  return createHTTPException(403, message);\n}\n\nexport function conflictError(message: string) {\n  return createHTTPException(409, message);\n}\n\nexport function tooManyRequestsError(message: string) {\n  return createHTTPException(429, message);\n}\n"
  },
  {
    "path": "src/utils/mentions.utils.ts",
    "content": "export const changeUserReferenceToPlainText = (text: string = '') => {\n  if (typeof text !== 'string') {\n    return '';\n  }\n  return text\n    .replace(/@([A-Za-z0-9_-]+)/, '@​$1')\n    .replace(/@@\\{([A-Za-z0-9_-]+):([a-z0-9_-]+)}/g, '@$2');\n};\n"
  },
  {
    "path": "src/utils/redirect.server.ts",
    "content": "export const getReturnUrl = (request: Request, fallbackPath: string = '/') => {\n  const url = new URL(request.url);\n  const params = new URLSearchParams(url.search);\n\n  return params.has('returnUrl')\n    ? decodeURIComponent(params.get('returnUrl') as string)\n    : fallbackPath;\n};\n"
  },
  {
    "path": "src/utils/searchHelper.test.ts",
    "content": "import '@testing-library/jest-dom/vitest';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { getKeywords } from './searchHelper';\n\ndescribe('searchHelper', () => {\n  it('should return all lowercase words of a string', () => {\n    // act\n    const words = getKeywords('Test1 teSt2 teST3');\n\n    // assert\n    expect(words).toEqual(['test1', 'test2', 'test3']);\n  });\n\n  it('should not return duplicate words', () => {\n    // act\n    const words = getKeywords('test1 test1');\n\n    // assert\n    expect(words).toEqual(['test1']);\n  });\n\n  it('should filter stopwords', () => {\n    // act\n    const words = getKeywords('i am test1 and themselves');\n\n    // assert\n    expect(words).toEqual(['test1']);\n  });\n\n  it('should normalize diatrics and filter stopwords', () => {\n    // act\n    const words = getKeywords('I ám test1 and thémselves');\n\n    // assert\n    expect(words).toEqual(['test1']);\n  });\n\n  it('should return normalized words', () => {\n    // act\n    const words = getKeywords('Tésting wórds açaí');\n\n    // assert\n    expect(words).toEqual(['testing', 'words', 'acai']);\n  });\n\n  it('should remove special characters', () => {\n    // act\n    const words = getKeywords('Good morning!');\n\n    // assert\n    expect(words).toEqual(['good', 'morning']);\n  });\n});\n"
  },
  {
    "path": "src/utils/searchHelper.ts",
    "content": "import { stopwords } from './stopwords';\n\nexport const getKeywords = (text: string) => {\n  const words = text\n    .normalize('NFD')\n    .replace(/\\p{Diacritic}/gu, '')\n    .replace(/[^\\w\\s]/gi, '')\n    .toLowerCase()\n    .trim()\n    .split(' '); // normalize and lowercase\n  const filteredWords = words.filter((word) => !stopwords.has(word)); // filter stopwords\n  const uniqueWords = new Set(filteredWords); // avoid duplicates\n  uniqueWords.delete(''); // remove empty space\n  return Array.from(uniqueWords); // return as an array\n};\n"
  },
  {
    "path": "src/utils/seo.utils.ts",
    "content": "import type { MetaFunction } from 'react-router';\n\n// from: https://gist.github.com/ryanflorence/ec1849c6d690cfbffcb408ecd633e069\n// This function makes it easy to set meta tags in nested routes.\n// It will override matching parent meta tags.\nexport const mergeMeta = <T>(leafMetaFn: MetaFunction<T>): MetaFunction<T> => {\n  return (arg) => {\n    const leafMeta = leafMetaFn(arg);\n\n    return arg.matches.reduceRight((acc, match) => {\n      for (const parentMeta of match.meta) {\n        const index = acc!.findIndex(\n          (meta) =>\n            ('name' in meta && 'name' in parentMeta && meta.name === parentMeta.name) ||\n            ('property' in meta &&\n              'property' in parentMeta &&\n              meta.property === parentMeta.property) ||\n            ('title' in meta && 'title' in parentMeta),\n        );\n        if (index == -1) {\n          // Parent meta not found in acc, so add it\n          acc!.push(parentMeta);\n        }\n      }\n      return acc;\n    }, leafMeta);\n  };\n};\n\ninterface GenerateTagsOptions {\n  title: string;\n  description?: string;\n  imageUrl?: string;\n  type?: string;\n  siteName?: string;\n}\n\nexport const generateTags = (\n  title: string,\n  description?: string,\n  imageUrl?: string,\n  options?: Pick<GenerateTagsOptions, 'type' | 'siteName'>,\n) => {\n  const tags = [\n    { title: title },\n    {\n      property: 'og:title',\n      content: title,\n    },\n    {\n      property: 'og:type',\n      content: options?.type || 'website',\n    },\n    {\n      name: 'twitter:title',\n      content: title,\n    },\n    {\n      name: 'twitter:card',\n      content: imageUrl ? 'summary_large_image' : 'summary',\n    },\n  ];\n\n  if (options?.siteName) {\n    tags.push({\n      property: 'og:site_name',\n      content: options.siteName,\n    });\n  }\n\n  if (description) {\n    tags.push({ name: 'description', content: description });\n\n    tags.push({\n      property: 'og:description',\n      content: description,\n    });\n    tags.push({\n      name: 'twitter:description',\n      content: description,\n    });\n  }\n\n  if (imageUrl) {\n    tags.push({\n      property: 'og:image',\n      content: imageUrl,\n    });\n    tags.push({\n      name: 'twitter:image',\n      content: imageUrl,\n    });\n  }\n\n  return tags;\n};\n"
  },
  {
    "path": "src/utils/sessionStorage.ts",
    "content": "export const retrieveSessionStorageArray = (key: string) => {\n  const viewsArray: string | null = sessionStorage.getItem(key);\n  if (typeof viewsArray === 'string') {\n    return JSON.parse(viewsArray);\n  } else {\n    return [];\n  }\n};\n\nexport const addIDToSessionStorageArray = (key: string, value: string) => {\n  const sessionStorageArray = retrieveSessionStorageArray(key);\n  sessionStorageArray.push(value);\n  sessionStorage.setItem(key, JSON.stringify(sessionStorageArray));\n};\n"
  },
  {
    "path": "src/utils/slug.ts",
    "content": "import { formatLowerNoSpecial } from './helpers';\n\nexport const convertToSlug = (text: string) => {\n  return formatLowerNoSpecial(text);\n};\n"
  },
  {
    "path": "src/utils/statistics.tsx",
    "content": "import type { IStatistic } from 'oa-components';\nimport { ProfileList } from 'oa-components';\nimport type { ContentType, ProfileListItem } from 'oa-shared';\nimport { usefulService } from 'src/services/usefulService';\nimport { buildStatisticsLabel } from './helpers';\n\nexport function createUsefulStatistic(\n  contentType: ContentType,\n  contentId: number,\n  usefulCount: number,\n  includeVotersList: boolean = false,\n): IStatistic {\n  return {\n    icon: 'star',\n    stat: usefulCount,\n    label: buildStatisticsLabel({\n      stat: usefulCount,\n      statUnit: 'useful',\n      usePlural: false,\n    }),\n    ...(includeVotersList && {\n      onOpen: async () => {\n        try {\n          return await usefulService.usefulVoters(contentType, contentId);\n        } catch (error) {\n          console.error('Failed to load useful voters:', error);\n          return [];\n        }\n      },\n      modalComponent: (profiles: ProfileListItem[]) => (\n        <ProfileList header=\"Others that found it useful\" profiles={profiles} />\n      ),\n    }),\n  };\n}\n"
  },
  {
    "path": "src/utils/stopwords.ts",
    "content": "// NLTK's list of english stopwords\n// https://gist.github.com/sebleier/554280\n\nexport const stopwords = new Set([\n  'i',\n  'me',\n  'my',\n  'myself',\n  'we',\n  'our',\n  'ours',\n  'ourselves',\n  'you',\n  'your',\n  'yours',\n  'yourself',\n  'yourselves',\n  'he',\n  'him',\n  'his',\n  'himself',\n  'she',\n  'her',\n  'hers',\n  'herself',\n  'it',\n  'its',\n  'itself',\n  'they',\n  'them',\n  'their',\n  'theirs',\n  'themselves',\n  'what',\n  'which',\n  'who',\n  'whom',\n  'this',\n  'that',\n  'these',\n  'those',\n  'am',\n  'is',\n  'are',\n  'was',\n  'were',\n  'be',\n  'been',\n  'being',\n  'have',\n  'has',\n  'had',\n  'having',\n  'do',\n  'does',\n  'did',\n  'doing',\n  'a',\n  'an',\n  'the',\n  'and',\n  'but',\n  'if',\n  'or',\n  'because',\n  'as',\n  'until',\n  'while',\n  'of',\n  'at',\n  'by',\n  'for',\n  'with',\n  'about',\n  'against',\n  'between',\n  'into',\n  'through',\n  'during',\n  'before',\n  'after',\n  'above',\n  'below',\n  'to',\n  'from',\n  'up',\n  'down',\n  'in',\n  'out',\n  'on',\n  'off',\n  'over',\n  'under',\n  'again',\n  'further',\n  'then',\n  'once',\n  'here',\n  'there',\n  'when',\n  'where',\n  'why',\n  'how',\n  'all',\n  'any',\n  'both',\n  'each',\n  'few',\n  'more',\n  'most',\n  'other',\n  'some',\n  'such',\n  'no',\n  'nor',\n  'not',\n  'only',\n  'own',\n  'same',\n  'so',\n  'than',\n  'too',\n  'very',\n  's',\n  't',\n  'can',\n  'will',\n  'just',\n  'don',\n  'should',\n  'now',\n]);\n"
  },
  {
    "path": "src/utils/storage.ts",
    "content": "export const SUPPORTED_IMAGE_TYPES = [\n  'image/png',\n  'image/jpeg',\n  'image/webp',\n  'image/gif',\n  'image/svg+xml',\n  'image/bmp',\n];\n\nexport function validateImage(image: File | null) {\n  const error =\n    image?.type && !SUPPORTED_IMAGE_TYPES.includes(image.type)\n      ? new Error(`Unsupported image extension: ${image.type}`)\n      : null;\n  const valid: boolean = !error;\n\n  return { valid, error };\n}\n\nexport function validateImages(images: File[]) {\n  const errors: Error[] = [];\n\n  for (const image of images) {\n    const { error } = validateImage(image);\n\n    if (error) {\n      errors.push(error);\n    }\n\n    continue;\n  }\n\n  return { valid: errors.length === 0, errors };\n}\n"
  },
  {
    "path": "src/utils/tokens.server.ts",
    "content": "import pkg from 'jsonwebtoken';\n\nconst key = process.env.TOKEN_SECRET as string;\n\nconst generate = (profileId: number, profileCreatedAt: string) => {\n  return pkg.sign(\n    {\n      profileId,\n      profileCreatedAt,\n    },\n    key,\n  );\n};\n\nconst verify = (code: string): string | pkg.JwtPayload => {\n  return pkg.verify(code, key);\n};\n\nexport const tokens = { generate, verify };\n"
  },
  {
    "path": "src/utils/urlHelper.ts",
    "content": "export const isUrl = (url: string) => {\n  try {\n    new URL(url);\n    return true;\n  } catch (_) {\n    return false;\n  }\n};\n"
  },
  {
    "path": "src/utils/urls.ts",
    "content": "export const BAZAR_URL = 'https://bazar.preciousplastic.com/';\nexport const GLOBAL_SITE_URL = 'https://preciousplastic.com/';\n"
  },
  {
    "path": "src/utils/validators.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest';\n\nimport {\n  composeValidators,\n  draftValidationWrapper,\n  endsWithQuestionMark,\n  minValue,\n  noSpecialCharacters,\n} from './validators';\n\n// Mock out module store to limit impact of circular dependency\nvi.mock('src/stores/common/module.store');\n\nimport { QUESTION_MIN_TITLE_LENGTH } from 'src/pages/Question/constants';\n\ndescribe('draftValidationWrapper', () => {\n  it('forwards to the validator when draft save is not allowed', () => {\n    const allowDraftSave = false;\n    const value = 'title';\n    const validator = vi.fn();\n\n    draftValidationWrapper(value, { allowDraftSave }, validator);\n\n    expect(validator).toHaveBeenCalledWith(value, { allowDraftSave });\n  });\n\n  it('returns undefined when draft save is allowed', () => {\n    const allowDraftSave = true;\n    const validator = vi.fn();\n\n    draftValidationWrapper('title', { allowDraftSave }, validator);\n\n    expect(validator).toHaveBeenCalledTimes(0);\n  });\n});\n\ndescribe('noSpecialCharacters', () => {\n  it('returns undefined when no special characters are present', () => {\n    const result = noSpecialCharacters('validUsername');\n\n    expect(result).toBeUndefined();\n  });\n\n  it('returns proper message for email values', () => {\n    const result = noSpecialCharacters('someones@email.com');\n\n    expect(result).toContain('Only letters and numbers are allowed');\n  });\n});\n\ndescribe('endsWithQuestionMark', () => {\n  const errorMessage = 'Needs to end with a question mark';\n  it('returns proper message when there is no question mark', () => {\n    const result = endsWithQuestionMark();\n    expect(result('this is my question')).toContain(errorMessage);\n  });\n\n  it('returns proper message when there the question mark is not at the end', () => {\n    const result = endsWithQuestionMark();\n    expect(result('this is my? question')).toContain(errorMessage);\n  });\n});\n\ndescribe('composeValidators', () => {\n  it('should join multiple error messages', async () => {\n    const composedValidatorsFunc = composeValidators(\n      minValue(QUESTION_MIN_TITLE_LENGTH),\n      endsWithQuestionMark(),\n    );\n    const messages = composedValidatorsFunc('this is', {});\n    expect(messages).toContain('Needs to end with a question mark');\n    expect(messages).toContain(`Should be more than ${QUESTION_MIN_TITLE_LENGTH} characters`);\n  });\n});\n"
  },
  {
    "path": "src/utils/validators.ts",
    "content": "import { FieldValidator } from 'final-form';\nimport { getSpecialCharacters, stripSpecialCharacters } from './helpers';\nimport { isUrl } from './urlHelper';\n\n/****************************************************************************\n *            General Validation Methods\n * **************************************************************************/\n\nconst required = (value: any, allValues?: any, meta?: any): string | undefined => {\n  return value ? undefined : 'This field is required';\n};\n\nconst noSpecialCharacters = (value: string): string | undefined => {\n  const specialCharacters = value ? getSpecialCharacters(value) : '';\n  return specialCharacters.length > 0 ? 'Only letters and numbers are allowed' : undefined;\n};\n\nconst maxValue =\n  (max: number) =>\n  (value: string): string | undefined => {\n    const strippedString = stripSpecialCharacters(value);\n\n    return strippedString.length > max ? `Should be less or equal to ${max} characters` : undefined;\n  };\n\nconst minValue =\n  (min: number) =>\n  (value: string): string | undefined => {\n    const strippedString = stripSpecialCharacters(value);\n\n    return strippedString.length < min ? `Should be more than ${min} characters` : undefined;\n  };\n\nconst endsWithQuestionMark =\n  () =>\n  (value: string): string | undefined => {\n    const lastCharacter = value ? value.slice(-1) : '';\n    return lastCharacter !== '?' ? 'Needs to end with a question mark' : undefined;\n  };\n\nconst composeValidators = (...validators: FieldValidator<any>[]): FieldValidator<any> => {\n  return (value, allValues, meta) => {\n    const allResponse = validators.map((validator) => validator(value, allValues, meta));\n\n    return allResponse.reduce(\n      (message, value) => (typeof value === 'string' ? (message += value + '. ') : message),\n      '',\n    );\n  };\n};\n\nconst validateUrl = (value: string) => {\n  if (value) {\n    return isUrl(value) ? undefined : 'Invalid url';\n  }\n};\n\nconst validateUrlAcceptEmpty = (value: string) => {\n  if (value) {\n    return isUrl(value) ? undefined : 'Invalid url';\n  }\n};\n\nconst validateEmail = (value: string) => {\n  if (value) {\n    return isEmail(value) ? undefined : 'Invalid email';\n  }\n  return 'Required';\n};\n\nconst isEmail = (email: string) => {\n  // From this stackoverflow thread https://stackoverflow.com/a/46181\n  const re =\n    /^(([^<>()[\\]\\\\.,;:\\s@\"]+(\\.[^<>()[\\]\\\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$/;\n  return re.test(String(email).toLowerCase());\n};\n\nconst draftValidationWrapper = (\n  value: string,\n  allValues: any,\n  validator: FieldValidator<string>,\n) => {\n  return allValues.allowDraftSave ? undefined : validator(value, allValues);\n};\n\n/****************************************************************************\n *            FORM MUTATORS\n * **************************************************************************/\n\nconst addProtocolMutator = ([name], state, { changeValue }) => {\n  changeValue(state, name, (val: string) => ensureExternalUrl(val));\n};\n/**\n * Used for user input links, ensure url has http/https protocol as required for external linking,\n * E.g. https://instagram.com/my-username\n */\nconst ensureExternalUrl = (url: string) =>\n  typeof url === 'string' && url.indexOf('://') === -1 ? `https://${url}` : url;\n\nexport {\n  validateUrl,\n  validateUrlAcceptEmpty,\n  validateEmail,\n  draftValidationWrapper,\n  required,\n  addProtocolMutator,\n  ensureExternalUrl,\n  maxValue,\n  minValue,\n  composeValidators,\n  noSpecialCharacters,\n  endsWithQuestionMark,\n};\n"
  },
  {
    "path": "supabase/.gitignore",
    "content": "# Supabase\n.branches\n.temp\n.env\nsigning_key.json\n\n# Supabase\n.branches\n.temp\n\n# dotenvx\n.env.keys\n.env.local\n.env.*.local\n"
  },
  {
    "path": "supabase/README.md",
    "content": "# Supabase server\n\nYou're here for either the migrations or functions.\n\n## Functions\n\nRequire a couple of env variables in .env to run locally:\n```\nRESEND_API_KEY=<from resend>\nSEND_EMAIL_HOOK_SECRET=<anything>\n```\n\nThen run (in the project root):\n```\nsupabase functions serve --env-file ./supabase/.env --no-verify-jwt\n```\n\nTo deploy:\n```\nsupabase functions deploy send-email --no-verify-jwt\n```\n\nExample CURL to hit send-email with:\n```\ncurl --request POST 'http://127.0.0.1:54321/functions/v1/send-email' \\ --header 'Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>' \\  --header 'Content-Type: application/json' \\  --data '{ \"email_data\": { \"email_action_type\": \"moderation_notification\", \"redirect_to\": \"http://community.preciousplastic.com/\", \"notification\": { \"title\": { \"triggeredBy\": \"precious-plastic\", \"middle\": \"has requested changes\", \"parentTitle\": \"Will AI kill us all?\", \"parentSlug\": \"research-slug\" }, \"body\": \"The title is a bit much\", \"slug\": \"research-slug/edit\" } }, \"user\": {\"email\": \"<your email address>\"}}'\n```\n"
  },
  {
    "path": "supabase/config.toml",
    "content": "# A string used to distinguish different Supabase projects on the same host. Defaults to the\n# working directory name when running `supabase init`.\nproject_id = \"community-platform\"\n\n[api]\nenabled = true\n# Port to use for the API URL.\nport = 54321\n# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API\n# endpoints. `public` is always included.\nschemas = [\"public\", \"graphql_public\"]\n# Extra schemas to add to the search_path of every request. `public` is always included.\nextra_search_path = [\"public\", \"extensions\"]\n# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size\n# for accidental or malicious requests.\nmax_rows = 1000\n\n[api.tls]\nenabled = false\n\n[db]\n# Port to use for the local database URL.\nport = 54322\n# Port used by db diff command to initialize the shadow database.\nshadow_port = 54320\n# The database major version to use. This has to be the same as your remote database's. Run `SHOW\n# server_version;` on the remote database to check.\nmajor_version = 15\n\n[db.pooler]\nenabled = false\n# Port to use for the local connection pooler.\nport = 54329\n# Specifies when a server connection can be reused by other clients.\n# Configure one of the supported pooler modes: `transaction`, `session`.\npool_mode = \"transaction\"\n# How many server connections to allow per user/database pair.\ndefault_pool_size = 20\n# Maximum number of client connections allowed.\nmax_client_conn = 100\n\n[db.seed]\n# If enabled, seeds the database after migrations during a db reset.\nenabled = true\n# Specifies an ordered list of seed files to load during db reset.\n# Supports glob patterns relative to supabase directory. For example:\n# sql_paths = ['./seeds/*.sql', '../project-src/seeds/*-load-testing.sql']\nsql_paths = ['./seed.sql']\n\n[db.migrations]\nschema_paths = [\n  \"./schemas/common.sql\",\n  \"./schemas/profiles.sql\",\n  \"./schemas/*.sql\",\n]\n\n[realtime]\nenabled = true\n# Bind realtime via either IPv4 or IPv6. (default: IPv4)\n# ip_version = \"IPv6\"\n# The maximum length in bytes of HTTP request headers. (default: 4096)\n# max_header_length = 4096\n\n[studio]\nenabled = true\n# Port to use for Supabase Studio.\nport = 54323\n# External URL of the API server that frontend connects to.\napi_url = \"http://127.0.0.1\"\n# OpenAI API Key to use for Supabase AI in the Supabase Studio.\nopenai_api_key = \"env(OPENAI_API_KEY)\"\n\n# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they\n# are monitored, and you can view the emails that would have been sent from the web interface.\n[inbucket]\nenabled = true\n# Port to use for the email testing server web interface.\nport = 54324\n# Uncomment to expose additional ports for testing user applications that send emails.\n# smtp_port = 54325\n# pop3_port = 54326\n\n[storage]\nenabled = true\n# The maximum file size allowed (e.g. \"5MB\", \"500KB\").\nfile_size_limit = \"50MiB\"\n\n[storage.image_transformation]\nenabled = true\n\n# Uncomment to configure local storage buckets\n# [storage.buckets.images]\n# public = false\n# file_size_limit = \"50MiB\"\n# allowed_mime_types = [\"image/png\", \"image/jpeg\"]\n# objects_path = \"./images\"\n\n[auth]\nenabled = true\n# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used\n# in emails.\nsite_url = \"https://community.preciousplastic.com\"\n# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.\nadditional_redirect_urls = [\"http://127.0.0.1:3000/**\",\"http://localhost:3000/**\",\"https://community-platform-pr-*.fly.dev/**\",\"https://community.preciousplastic.com/**\",\"https://community.fixing.fashion/**\",\"https://community.projectkamp.com/**\"]\n# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).\njwt_expiry = 3600\n# If disabled, the refresh token will never expire.\nenable_refresh_token_rotation = true\n# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.\n# Requires enable_refresh_token_rotation = true.\nrefresh_token_reuse_interval = 10\n# Allow/disallow new user signups to your project.\nenable_signup = true\n# Allow/disallow anonymous sign-ins to your project.\nenable_anonymous_sign_ins = false\n# Allow/disallow testing manual linking of accounts\nenable_manual_linking = false\n#signing_keys_path = \"./signing_key.json\"\n\n[auth.email]\n# Allow/disallow new user signups via email to your project.\nenable_signup = true\n# If enabled, a user will be required to confirm any email change on both the old, and new email\n# addresses. If disabled, only the new email is required to confirm.\ndouble_confirm_changes = false\n# If enabled, users need to confirm their email address before signing in.\nenable_confirmations = false\n# If enabled, users will need to reauthenticate or have logged in recently to change their password.\nsecure_password_change = false\n# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.\nmax_frequency = \"1s\"\n# Number of characters used in the email OTP.\notp_length = 6\n# Number of seconds before the email OTP expires (defaults to 1 hour).\notp_expiry = 3600\n\n# Use a production-ready SMTP server\n# [auth.email.smtp]\n# host = \"smtp.sendgrid.net\"\n# port = 587\n# user = \"apikey\"\n# pass = \"env(SENDGRID_API_KEY)\"\n# admin_email = \"admin@email.com\"\n# sender_name = \"Admin\"\n\n# Uncomment to customize email template\n# [auth.email.template.invite]\n# subject = \"You have been invited\"\n# content_path = \"./supabase/templates/invite.html\"\n\n[auth.sms]\n# Allow/disallow new user signups via SMS to your project.\nenable_signup = false\n# If enabled, users need to confirm their phone number before signing in.\nenable_confirmations = false\n# Template for sending OTP to users\ntemplate = \"Your code is {{ .Code }} .\"\n# Controls the minimum amount of time that must pass before sending another sms otp.\nmax_frequency = \"5s\"\n\n# Use pre-defined map of phone number to OTP for testing.\n# [auth.sms.test_otp]\n# 4152127777 = \"123456\"\n\n# Configure logged in session timeouts.\n# [auth.sessions]\n# Force log out after the specified duration.\n# timebox = \"24h\"\n# Force log out if the user has been inactive longer than the specified duration.\n# inactivity_timeout = \"8h\"\n\n# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.\n# [auth.hook.custom_access_token]\n# enabled = true\n# uri = \"pg-functions://<database>/<schema>/<hook_name>\"\n\n# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.\n[auth.sms.twilio]\nenabled = false\naccount_sid = \"\"\nmessage_service_sid = \"\"\n# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:\nauth_token = \"env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)\"\n\n[auth.mfa]\n# Control how many MFA factors can be enrolled at once per user.\nmax_enrolled_factors = 10\n\n# Control use of MFA via App Authenticator (TOTP)\n[auth.mfa.totp]\nenroll_enabled = true\nverify_enabled = true\n\n# Configure Multi-factor-authentication via Phone Messaging\n# [auth.mfa.phone]\n# enroll_enabled = true\n# verify_enabled = true\n# otp_length = 6\n# template = \"Your code is {{ .Code }} .\"\n# max_frequency = \"10s\"\n\n# Configure Multi-factor-authentication via WebAuthn\n# [auth.mfa.web_authn]\n# enroll_enabled = true\n# verify_enabled = true\n\n# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,\n# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,\n# `twitter`, `slack`, `spotify`, `workos`, `zoom`.\n[auth.external.apple]\nenabled = false\nclient_id = \"\"\n# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:\nsecret = \"env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)\"\n# Overrides the default auth redirectUrl.\nredirect_uri = \"\"\n# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,\n# or any other third-party OIDC providers.\nurl = \"\"\n# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.\nskip_nonce_check = false\n\n# Use Firebase Auth as a third-party provider alongside Supabase Auth.\n[auth.third_party.firebase]\nenabled = false\n# project_id = \"my-firebase-project\"\n\n# Use Auth0 as a third-party provider alongside Supabase Auth.\n[auth.third_party.auth0]\nenabled = false\n# tenant = \"my-auth0-tenant\"\n# tenant_region = \"us\"\n\n# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.\n[auth.third_party.aws_cognito]\nenabled = false\n# user_pool_id = \"my-user-pool-id\"\n# user_pool_region = \"us-east-1\"\n\n[edge_runtime]\nenabled = true\n# Configure one of the supported request policies: `oneshot`, `per_worker`.\n# Use `oneshot` for hot reload, or `per_worker` for load testing.\npolicy = \"oneshot\"\ninspector_port = 8083\n\n[analytics]\nenabled = true\nport = 54327\n# Configure one of the supported backends: `postgres`, `bigquery`.\nbackend = \"postgres\"\n\n# Experimental features may be deprecated any time\n[experimental]\n# Configures Postgres storage engine to use OrioleDB (S3)\norioledb_version = \"\"\n# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com\ns3_host = \"env(S3_HOST)\"\n# Configures S3 bucket region, eg. us-east-1\ns3_region = \"env(S3_REGION)\"\n# Configures AWS_ACCESS_KEY_ID for S3 bucket\ns3_access_key = \"env(S3_ACCESS_KEY)\"\n# Configures AWS_SECRET_ACCESS_KEY for S3 bucket\ns3_secret_key = \"env(S3_SECRET_KEY)\"\n"
  },
  {
    "path": "supabase/functions/send-email/.npmrc",
    "content": "# Configuration for private npm package dependencies\n# For more information on using private registries with Edge Functions, see:\n# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/components/box-text.tsx",
    "content": "import React from 'react';\nimport { Section } from '@react-email/components';\n\nconst section = {\n  backgroundColor: '#f4f4f4',\n  borderRadius: '10px',\n  lineHeight: 1.66,\n  marginBottom: '15px',\n  padding: '20px',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const BoxText = ({ children }: IProps) => <Section style={section}>{children}</Section>;\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/components/button.tsx",
    "content": "import React from 'react';\nimport { Button as ButtonComp, Section } from '@react-email/components';\n\nconst button = {\n  backgroundColor: '#E2EDF7',\n  borderRadius: '15px',\n  border: '2px solid #27272c',\n  color: '#27272c',\n  fontSize: '16px',\n  padding: '19px 30px',\n  textDecoration: 'none',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n  href: string;\n}\n\nexport const Button = ({ children, href }: IProps) => (\n  <Section style={{ textAlign: 'center' }}>\n    <ButtonComp style={button} href={href} target=\"_blank\">\n      {children}\n    </ButtonComp>\n  </Section>\n);\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/components/footer.tsx",
    "content": "import React from 'react';\nimport { Section, Text } from '@react-email/components';\n\nconst text = {\n  alignText: 'center',\n  color: '#27272c',\n  fontSize: '14px',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const Footer = ({ children }: IProps) => (\n  <Section style={{ textAlign: 'center' }}>\n    <Text style={text}>{children}</Text>\n  </Section>\n);\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/components/header.tsx",
    "content": "import React from 'react';\nimport { Section } from '@react-email/components';\n\nconst section = {\n  padding: '10px',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const Header = ({ children }: IProps) => <Section style={section}>{children}</Section>;\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/components/heading.tsx",
    "content": "import React from 'react';\nimport { Heading as HeadingComp } from '@react-email/components';\n\nconst h1 = {\n  color: '#2e2e2e',\n  lineHeight: 1.2,\n  fontWeight: 'normal',\n  fontFamily: '\"Helvetica\", serif',\n  marginBottom: '12px',\n  padding: '0',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const Heading = ({ children }: IProps) => (\n  <HeadingComp style={h1} as=\"h1\">\n    {children}\n  </HeadingComp>\n);\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/components/hero.tsx",
    "content": "import React from 'react';\nimport { Text } from '@react-email/components';\n\nconst heroText = {\n  color: '#27272c',\n  fontSize: '14px',\n  fontWeight: 'bold',\n  marginBottom: '6px',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const Hero = ({ children }: IProps) => <Text style={heroText}>{children}</Text>;\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/components/parent-box.tsx",
    "content": "import React from 'react';\nimport { Container, Section } from '@react-email/components';\n\nconst section = {\n  backgroundColor: '#e2edf7',\n  border: '2px solid #2e2e2e',\n  borderRadius: '99px',\n  color: '#2e2e2e',\n  display: 'block',\n  marginBottom: '15px',\n  padding: '12px',\n  whiteSpace: 'nowrap',\n  overflow: 'hidden',\n  textOverflow: 'ellipsis',\n  maxWidth: '600px',\n};\n\nconst bar = {\n  borderLeft: '2px solid #2e2e2e',\n  borderRight: '2px solid #fff',\n  height: '50px',\n  marginBottom: '15px',\n  width: '2px',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const ParentBox = ({ children }: IProps) => (\n  <>\n    <Section style={section}>{children}</Section>\n    <Container style={bar} />\n  </>\n);\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/components/plain-text.tsx",
    "content": "import React from 'react';\nimport { Text } from '@react-email/components';\n\nconst text = {\n  color: '#27272c',\n  fontSize: '14px',\n  whiteSpace: 'pre-line',\n};\n\ninterface IProps {\n  children: React.ReactNode;\n}\n\nexport const PlainText = ({ children }: IProps) => <Text style={text}>{children}</Text>;\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/email-change-new.tsx",
    "content": "import React from 'react';\n\nimport { Layout } from './layout.tsx';\nimport { Button } from './components/button.tsx';\nimport { Heading } from './components/heading.tsx';\nimport { Hero } from './components/hero.tsx';\nimport { PlainText } from './components/plain-text.tsx';\n\nimport type { TenantSettings } from 'oa-shared';\n\nconst copy = {\n  h1: (username: string) => `Hey ${username}!`,\n  click: `Confirm email address change`,\n  change: (newEmail: string) => `You're changing to use ${newEmail}`,\n  intro: \"So you've got a fancy new email address?\",\n  preview: \"You've got a new email address!?\",\n};\n\ninterface SignUpEmailProps {\n  email_action_type: string;\n  newEmail: string;\n  supabaseUrl: string;\n  redirect_to: string;\n  settings: TenantSettings;\n  token_hash: string;\n  username: string;\n}\n\nexport const EmailChangeNewEmail = (props: SignUpEmailProps) => {\n  const { username, newEmail, supabaseUrl, email_action_type, redirect_to, settings, token_hash } =\n    props;\n\n  const href = `${supabaseUrl}/auth/v1/verify?token=${token_hash}&type=${email_action_type}&redirect_to=${redirect_to}`;\n\n  return (\n    <Layout emailType=\"service\" preview={copy.preview} settings={settings}>\n      <Heading>{copy.h1(username)}</Heading>\n      <Hero>{copy.intro}</Hero>\n      <PlainText>{copy.change(newEmail)}</PlainText>\n\n      <Button href={href}>{copy.click}</Button>\n    </Layout>\n  );\n};\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/layout.tsx",
    "content": "// Similar to src/.server/templates/Layout.tsx\n\nimport React from 'react';\nimport { Body, Container, Head, Html, Img, Link, Preview, Section } from '@react-email/components';\n\nimport { Footer } from './components/footer.tsx';\n\nimport type { TenantSettings } from 'oa-shared';\n\nconst body = {\n  backgroundColor: '#f4f6f7',\n  fontFamily: '\"Varela Round\", Arial, sans-serif',\n  fontSize: '14px',\n  color: '#000000',\n  maxWidth: '100%',\n};\n\nconst card = {\n  background: '#fff',\n  border: '2px solid black',\n  borderRadius: '15px',\n  padding: '20px',\n  margin: '0 auto',\n};\n\nconst link = {\n  color: '#27272c',\n  fontWeight: 'bold',\n  textDecoration: 'underline',\n};\n\nconst mainContainer = {\n  maxWidth: '100%',\n  width: '600px',\n};\n\ntype EmailType = 'service' | 'moderation' | 'notification';\n\ntype LayoutArgs = {\n  children: React.ReactNode;\n  emailType: EmailType;\n  preview: string;\n  settings: TenantSettings;\n  userCode?: string;\n};\n\nexport const urlAppend = (path: string, emailType: EmailType) => {\n  const url = new URL(`${path}`);\n  url.searchParams.append('utm_source', emailType);\n  url.searchParams.append('utm_medium', 'email');\n  return url.toString();\n};\n\nexport const Layout = (props: LayoutArgs) => {\n  const { children, emailType, preview, settings, userCode } = props;\n\n  const basePreferencesPath = userCode\n    ? `${settings.siteUrl}/email-preferences?code=${userCode}`\n    : `${settings.siteUrl}/settings/notifications`;\n  const preferencesUpdatePath = urlAppend(basePreferencesPath, emailType);\n\n  const isNotificationEmail = emailType === 'notification';\n\n  return (\n    <Html lang=\"en\">\n      <Head />\n      <Preview>{preview}</Preview>\n      <Body style={body}>\n        <Container style={mainContainer}>\n          <Img\n            alt={settings.siteName}\n            height=\"85px\"\n            width=\"85px\"\n            src={settings.siteImage}\n            style={{ margin: '30px auto' }}\n          />\n          <Section style={card}>{children}</Section>\n          <Footer>\n            {!isNotificationEmail && <>This is a service email.</>}\n            {isNotificationEmail && (\n              <>\n                <Link href={preferencesUpdatePath} style={link}>\n                  Unsubscribe or update your email preferences.\n                </Link>\n              </>\n            )}\n            <br />\n            Something is not right? Send us{' '}\n            <Link href={`${settings.siteUrl}/feedback/#page=email`} style={link}>\n              feedback\n            </Link>\n            .\n          </Footer>\n        </Container>\n      </Body>\n    </Html>\n  );\n};\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/magic-link.tsx",
    "content": "import React from 'react';\n\nimport { Layout } from './layout.tsx';\nimport { Button } from './components/button.tsx';\nimport { Heading } from './components/heading.tsx';\nimport { Hero } from './components/hero.tsx';\nimport { PlainText } from './components/plain-text.tsx';\n\nimport type { TenantSettings } from 'oa-shared';\n\nconst copy = {\n  h1: (username: string) => `Hey ${username}! Time to login`,\n  intro: 'You requested a magic link for fast login',\n  clickHere: 'Login... with magic!',\n  notRequested:\n    'If you did not request this email, there is nothing to worry about, you can safely ignore this.',\n  preview: \"Here's your magic link!\",\n};\n\ninterface SignUpEmailProps {\n  username: string;\n  supabaseUrl: string;\n  newEmail: string;\n  email_action_type: string;\n  redirect_to: string;\n  token_hash: string;\n  settings: TenantSettings;\n}\n\nexport const MagicLinkEmail = (props: SignUpEmailProps) => {\n  const { username, supabaseUrl, email_action_type, redirect_to, settings, token_hash } = props;\n\n  const href = `${supabaseUrl}/auth/v1/verify?token=${token_hash}&type=${email_action_type}&redirect_to=${redirect_to}`;\n\n  return (\n    <Layout emailType=\"service\" preview={copy.preview} settings={settings}>\n      <Heading>{copy.h1(username)}</Heading>\n      <Hero>{copy.intro}</Hero>\n\n      <Button href={href}>{copy.clickHere}</Button>\n\n      <PlainText>{copy.notRequested}</PlainText>\n    </Layout>\n  );\n};\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/moderation-email.tsx",
    "content": "import React from 'react';\nimport { Text } from '@react-email/components';\n\nimport { BoxText } from './components/box-text.tsx';\nimport { Button } from './components/button.tsx';\nimport { Layout } from './layout.tsx';\nimport { Heading } from './components/heading.tsx';\n\nimport type { NotificationDisplay, TenantSettings } from 'oa-shared';\nimport { Header } from './components/header.tsx';\n\nconst text = {\n  color: '#686868',\n  fontSize: '18px',\n  lineHeight: '30px',\n  whiteSpace: 'pre',\n};\n\ninterface IProps {\n  notification: NotificationDisplay;\n  settings: TenantSettings;\n}\n\nexport const ModerationEmail = (props: IProps) => {\n  const { notification, settings } = props;\n\n  const buttonLink = `${settings.siteUrl}/${notification.link}`;\n\n  const preview = `${notification.triggeredBy} ${notification.title}`;\n  const buttonWording = notification.body ? 'Update it now' : 'See it live';\n\n  return (\n    <Layout emailType=\"moderation\" preview={preview} settings={settings}>\n      <Header>\n        <Heading>{preview}</Heading>\n      </Header>\n\n      {notification.body && (\n        <BoxText>\n          <Heading>They wrote:</Heading>\n          <Text style={text}>{notification.body}</Text>\n        </BoxText>\n      )}\n      <Button href={buttonLink}>{buttonWording}</Button>\n    </Layout>\n  );\n};\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/reset-password.tsx",
    "content": "import React from 'react';\n\nimport { Layout } from './layout.tsx';\nimport { Button } from './components/button.tsx';\nimport { Heading } from './components/heading.tsx';\nimport { Hero } from './components/hero.tsx';\nimport { PlainText } from './components/plain-text.tsx';\n\nimport type { TenantSettings } from 'oa-shared';\n\nconst copy = {\n  h1: (username: string) => `Hey ${username}!`,\n  intro: \"So you forgot your password? That's ok, it can happen to the best of us.\",\n  clickHere: 'Reset... Now!',\n  notRequested:\n    'If you did not request this email, there is nothing to worry about, you can safely ignore this.',\n  preview: 'I need to reset your password?',\n};\n\ninterface ResetPasswordProps {\n  username: string;\n  redirect_to: string;\n  token_hash: string;\n  settings: TenantSettings;\n}\n\nexport const ResetPasswordEmail = (props: ResetPasswordProps) => {\n  const { username, redirect_to, settings, token_hash } = props;\n\n  const href = `${redirect_to}?token=${token_hash}`;\n\n  return (\n    <Layout emailType=\"service\" preview={copy.preview} settings={settings}>\n      <Heading>{copy.h1(username)}</Heading>\n      <Hero>{copy.intro}</Hero>\n\n      <Button href={href}>{copy.clickHere}</Button>\n\n      <PlainText>{copy.notRequested}</PlainText>\n    </Layout>\n  );\n};\n"
  },
  {
    "path": "supabase/functions/send-email/_templates/sign-up.tsx",
    "content": "import React from 'react';\n\nimport { Layout } from './layout.tsx';\nimport { Button } from './components/button.tsx';\nimport { Heading } from './components/heading.tsx';\nimport { Hero } from './components/hero.tsx';\nimport { PlainText } from './components/plain-text.tsx';\n\nimport type { TenantSettings } from 'oa-shared';\n\nconst copy = {\n  h1: (username: string) => `Hey ${username}! We're excited you're joining us`,\n  emailConfirmationBody: 'Please complete the email confirmation for full access.',\n  clickHere: 'Confirm your email address',\n  notRequested:\n    'If you did not request this email, there is nothing to worry about, you can safely ignore this.',\n  preview: 'Welcome! We need you to confirm your email address',\n};\n\ninterface SignUpEmailProps {\n  username: string;\n  redirect_to: string;\n  token_hash: string;\n  settings: TenantSettings;\n}\n\nexport const SignUpEmail = (props: SignUpEmailProps) => {\n  const { username, redirect_to, settings, token_hash } = props;\n\n  const href = `${redirect_to}?token=${token_hash}`;\n\n  return (\n    <Layout emailType=\"service\" preview={copy.preview} settings={settings}>\n      <Heading>{copy.h1(username)}</Heading>\n      <Hero>{copy.emailConfirmationBody}</Hero>\n\n      <Button href={href}>{copy.clickHere}</Button>\n\n      <PlainText>{copy.notRequested}</PlainText>\n    </Layout>\n  );\n};\n"
  },
  {
    "path": "supabase/functions/send-email/deno.json",
    "content": "{\n  \"imports\": {\n    \"@react-email/components\": \"npm:@react-email/components@0.1.1\",\n    \"oa-shared\": \"../../../shared/\",\n    \"react\": \"npm:react@18.3.1\",\n    \"react-dom\": \"npm:react-dom@18.3.1\",\n    \"resend\": \"npm:resend@6.6.0\",\n    \"standardwebhooks\": \"npm:standardwebhooks@1.0.0\"\n  },\n  \"nodeModulesDir\": \"auto\",\n  \"compilerOptions\": {\n    \"checkJs\": true,\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"react\"\n  }\n}\n"
  },
  {
    "path": "supabase/functions/send-email/getTenantSettings.ts",
    "content": "import { createClient } from 'jsr:@supabase/supabase-js@2.46.1';\n\nexport async function getTenantSettings(req, redirect_to) {\n  const tenantId = hackyWayToGetTenantId(req.headers, redirect_to);\n  const client = createClient(\n    Deno.env.get('SUPABASE_URL') ?? '',\n    Deno.env.get('PUBLISHABLE_KEY') ?? '',\n    {\n      global: {\n        headers: {\n          Authorization: req.headers.get('Authorization')!,\n          'x-tenant-id': tenantId,\n        },\n      },\n    },\n  );\n\n  const { data } = await client\n    .from('tenant_settings')\n    .select('site_name,site_description,site_url,message_sign_off,email_from,site_imagecolor_primary,color_primary_hover,color_accent,color_accent_hover,show_impact,create_research_roles')\n    .single();\n\n  return {\n    siteName: data?.site_name || 'The Community Platform',\n    siteDescription: data?.site_description,\n    siteUrl: data?.site_url || 'https://community.preciousplastic.com',\n    messageSignOff: data?.message_sign_off || 'One Army',\n    emailFrom: data?.email_from || 'hello@onearmy.earth',\n    colorPrimary: data?.color_primary,\n    colorPrimaryHover: data?.color_primary_hover,\n    colorAccent: data?.color_accent,\n    colorAccentHover: data?.color_accent_hover,\n    siteImage:\n      data?.site_image || 'https://community.preciousplastic.com/assets/img/one-army-logo.png',\n  };\n}\n\nconst hackyWayToGetTenantId = (headers, url: string | undefined): string => {\n  const tenantIdMap = {\n    projectkamp: 'project-kamp',\n    preciousplastic: 'precious-plastic',\n    fixing: 'fixing-fashion',\n  };\n  if (headers.get('x-tenant-id')) {\n    return headers.get('x-tenant-id');\n  }\n\n  if (url) {\n    const address = new URL(url);\n    const host = address.host.split('.')[1];\n    return tenantIdMap[host] || '';\n  }\n\n  return '';\n};\n"
  },
  {
    "path": "supabase/functions/send-email/index.ts",
    "content": "import React from 'react';\n\nimport { Webhook } from 'standardwebhooks';\nimport { Resend } from 'resend';\nimport { render } from '@react-email/components';\n\nimport { EmailChangeNewEmail } from './_templates/email-change-new.tsx';\nimport { MagicLinkEmail } from './_templates/magic-link.tsx';\nimport { ResetPasswordEmail } from './_templates/reset-password.tsx';\nimport { SignUpEmail } from './_templates/sign-up.tsx';\nimport { signWebhookHeader } from './signWebhookHeader.ts';\nimport { getTenantSettings } from './getTenantSettings.ts';\nimport { createClient } from 'jsr:@supabase/supabase-js@2.46.1';\n\nimport type { NotificationDisplay, UserEmailData } from 'oa-shared';\nimport { ModerationEmail } from './_templates/moderation-email.tsx';\n\nconst hookSecret = Deno.env.get('SEND_EMAIL_HOOK_SECRET') as string;\nconst resend = new Resend(Deno.env.get('RESEND_API_KEY') as string);\nconst supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';\n\ntype EmailData = {\n  email_action_type: string;\n  notification?: NotificationDisplay;\n  redirect_to?: string;\n  token_hash?: string;\n};\n\nDeno.serve(async (req) => {\n  if (req.method !== 'POST') {\n    return new Response('not allowed', { status: 400 });\n  }\n\n  const payload = await req.text();\n  const webhook = new Webhook(hookSecret);\n  const headers = signWebhookHeader(webhook, req.headers, payload);\n\n  try {\n    const { email_data, user } = webhook.verify(payload, headers) as {\n      email_data: EmailData;\n      user: UserEmailData;\n    };\n\n    const settings = await getTenantSettings(req, email_data.redirect_to);\n\n    let html;\n    let subject: string = '';\n    let to = user.email;\n\n    const details = {\n      supabaseUrl,\n      settings,\n      user,\n      ...email_data,\n    } as any;\n\n    const username = await getUsername(req, user.id);\n\n    switch (email_data.email_action_type) {\n      case 'moderation_notification': {\n        if (email_data.notification) {\n          subject = `Moderation update for: ${email_data.notification.email.subject}`;\n          html = await render(React.createElement(ModerationEmail, details));\n        }\n        break;\n      }\n      case 'signup': {\n        subject = 'Welcome! Please confirm your email';\n        details.username = username;\n\n        html = await render(React.createElement(SignUpEmail, details));\n        break;\n      }\n      case 'login': {\n        subject = 'Fancy magic link for login!';\n        details.username = username;\n\n        html = await render(React.createElement(MagicLinkEmail, details));\n        break;\n      }\n      case 'recovery': {\n        subject = 'So you need to reset your password?';\n        details.username = username;\n\n        html = await render(React.createElement(ResetPasswordEmail, details));\n        break;\n      }\n      case 'email_change': {\n        const newEmail = user['new_email']!;\n        subject = \"You're changing your email\";\n        to = newEmail;\n        details.username = username;\n\n        html = await render(\n          React.createElement(EmailChangeNewEmail, {\n            ...details,\n            newEmail,\n          }),\n        );\n        break;\n      }\n      default: {\n        throw new Error(`Template not implemented for: ${email_data.email_action_type}`);\n      }\n    }\n\n    await resend.emails.send(\n      {\n        from: `${settings.messageSignOff} <${settings.emailFrom}>`,\n        to,\n        subject,\n        html,\n      },\n      {\n        idempotencyKey: `${email_data.email_action_type}/${crypto.randomUUID()}`,\n      },\n    );\n  } catch (error: any) {\n    console.error(error);\n    return new Response(\n      JSON.stringify({\n        error: {\n          http_code: error.code,\n          message: error.message,\n        },\n      }),\n      {\n        status: 401,\n        headers: { 'Content-Type': 'application/json' },\n      },\n    );\n  }\n\n  const responseHeaders = new Headers();\n  responseHeaders.set('Content-Type', 'application/json');\n\n  return new Response(JSON.stringify({}), {\n    status: 200,\n    headers: responseHeaders,\n  });\n});\n\nasync function getUsername(req: Request, authId: string): Promise<string> {\n  try {\n    const client = createClient(\n      Deno.env.get('SUPABASE_URL') ?? '',\n      Deno.env.get('PUBLISHABLE_KEY') ?? '',\n      {\n        global: {\n          headers: {\n            Authorization: req.headers.get('Authorization')!,\n          },\n        },\n      },\n    );\n\n    const { data } = await client\n      .from('profiles')\n      .select('username')\n      .eq('auth_id', authId)\n      .single();\n\n    return data?.username || 'there';\n  } catch {\n    return 'there';\n  }\n}\n"
  },
  {
    "path": "supabase/functions/send-email/signWebhookHeader.ts",
    "content": "import type { Webhook } from 'standardwebhooks';\n\n/**\n * Signs the webhook headers. Why? Because for some reason Supabase doesn't\n * sign the webhook headers when running locally... 🤷‍♂️\n *\n * USE THIS ONLY FOR LOCAL DEVELOPMENT!\n *\n * @param webhook The Webhook instance.\n * @param headers The request headers.\n * @param payload The request payload.\n * @returns The signed headers.\n */\nexport function signWebhookHeader(\n  webhook: Webhook,\n  headers: {\n    [k: string]: string;\n  },\n  payload: string,\n): {\n  [k: string]: string;\n} {\n  const newDate = new Date();\n  const whId = 'webhook_id';\n  const signature = webhook.sign(whId, newDate, payload);\n\n  headers['webhook-id'] = whId;\n  headers['webhook-signature'] = signature;\n  headers['webhook-timestamp'] = `${Math.floor(newDate.getTime() / 1000)}`;\n\n  return headers;\n}\n"
  },
  {
    "path": "supabase/migrations/20241125140428_profiles_and_comments.sql",
    "content": "create table \"public\".\"categories\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"tenant_id\" text not null,\n    \"name\" text not null\n);\n\n\nalter table \"public\".\"categories\" enable row level security;\n\ncreate table \"public\".\"comments\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"comment\" text not null,\n    \"source_id\" bigint,\n    \"parent_id\" bigint,\n    \"tenant_id\" text not null default ''::text,\n    \"created_by\" bigint,\n    \"source_type\" text not null,\n    \"modified_at\" timestamp with time zone,\n    \"source_id_legacy\" text,\n    \"deleted\" boolean,\n    \"legacy_id\" text\n);\n\n\nalter table \"public\".\"comments\" enable row level security;\n\ncreate table \"public\".\"profiles\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"firebase_auth_id\" text not null,\n    \"display_name\" text not null,\n    \"is_verified\" boolean not null default false,\n    \"country\" text,\n    \"photo_url\" text,\n    \"about\" text,\n    \"tenant_id\" text not null,\n    \"username\" text not null default ''::text,\n    \"roles\" text[]\n);\n\n\nalter table \"public\".\"profiles\" enable row level security;\n\ncreate table \"public\".\"questions\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"created_by\" bigint,\n    \"deleted\" boolean,\n    \"modified_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"comment_count\" bigint default '0'::bigint,\n    \"description\" text not null,\n    \"moderation\" text,\n    \"slug\" text not null,\n    \"previous_slugs\" text[],\n    \"category\" bigint,\n    \"tags\" bigint[],\n    \"title\" text not null,\n    \"total_views\" bigint,\n    \"tenant_id\" text not null,\n    \"fts\" tsvector generated always as (to_tsvector('english'::regconfig, ((title || ' '::text) || description))) stored\n);\n\n\nalter table \"public\".\"questions\" enable row level security;\n\nCREATE UNIQUE INDEX categories_pkey ON public.categories USING btree (id);\n\nCREATE INDEX comments_created_at_source_type_source_id_tenant_id_idx ON public.comments USING btree (created_at, source_type, source_id, tenant_id);\n\nCREATE UNIQUE INDEX comments_pkey ON public.comments USING btree (id);\n\nCREATE INDEX profiles_firebase_auth_id_idx ON public.profiles USING btree (firebase_auth_id);\n\nCREATE UNIQUE INDEX profiles_pkey ON public.profiles USING btree (id);\n\nCREATE INDEX profiles_tenant_id_is_verified_created_at_idx ON public.profiles USING btree (tenant_id, is_verified, created_at);\n\nCREATE UNIQUE INDEX question_pkey ON public.questions USING btree (id);\n\nCREATE INDEX questions_deleted_moderation_category_total_views_tags_crea_idx ON public.questions USING btree (deleted, moderation, category, total_views, tags, created_at, comment_count, created_by);\n\nCREATE INDEX questions_tags_idx ON public.questions USING gin (tags);\n\nalter table \"public\".\"categories\" add constraint \"categories_pkey\" PRIMARY KEY using index \"categories_pkey\";\n\nalter table \"public\".\"comments\" add constraint \"comments_pkey\" PRIMARY KEY using index \"comments_pkey\";\n\nalter table \"public\".\"profiles\" add constraint \"profiles_pkey\" PRIMARY KEY using index \"profiles_pkey\";\n\nalter table \"public\".\"questions\" add constraint \"question_pkey\" PRIMARY KEY using index \"question_pkey\";\n\nalter table \"public\".\"comments\" add constraint \"comment_created_by_fkey\" FOREIGN KEY (created_by) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"comments\" validate constraint \"comment_created_by_fkey\";\n\nalter table \"public\".\"questions\" add constraint \"question_created_by_fkey\" FOREIGN KEY (created_by) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"questions\" validate constraint \"question_created_by_fkey\";\n\nalter table \"public\".\"questions\" add constraint \"questions_category_fkey\" FOREIGN KEY (category) REFERENCES categories(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"questions\" validate constraint \"questions_category_fkey\";\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.comment_authors_by_source_id_legacy(source_id_legacy_input text)\n RETURNS SETOF text\n LANGUAGE sql\nAS $function$\n  SELECT DISTINCT (p.username)\n  FROM comments c\n  INNER JOIN profiles p\n  ON c.created_by = p.id\n  WHERE c.source_id_legacy = source_id_legacy_input\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.update_comment_count()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$BEGIN\n  IF (TG_OP = 'INSERT') THEN\n    IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL THEN\n      IF NEW.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = comment_count + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'research' THEN\n        UPDATE research SET comment_count = comment_count + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'howtos' THEN\n        UPDATE howtos SET comment_count = comment_count + 1\n        WHERE id = NEW.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: source_type or source_id is NULL';\n    END IF;\n\n  ELSIF (TG_OP = 'DELETE') THEN\n    IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n      IF OLD.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = comment_count - 1\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'research' THEN\n        UPDATE research SET comment_count = comment_count - 1\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'howtos' THEN\n        UPDATE howtos SET comment_count = comment_count - 1\n        WHERE id = OLD.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n    END IF;\n  END IF;\n\n  -- Explicit return for the trigger function\n  RETURN NULL;\nEND;$function$\n;\n\ngrant delete on table \"public\".\"categories\" to \"anon\";\n\ngrant insert on table \"public\".\"categories\" to \"anon\";\n\ngrant references on table \"public\".\"categories\" to \"anon\";\n\ngrant select on table \"public\".\"categories\" to \"anon\";\n\ngrant trigger on table \"public\".\"categories\" to \"anon\";\n\ngrant truncate on table \"public\".\"categories\" to \"anon\";\n\ngrant update on table \"public\".\"categories\" to \"anon\";\n\ngrant delete on table \"public\".\"categories\" to \"authenticated\";\n\ngrant insert on table \"public\".\"categories\" to \"authenticated\";\n\ngrant references on table \"public\".\"categories\" to \"authenticated\";\n\ngrant select on table \"public\".\"categories\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"categories\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"categories\" to \"authenticated\";\n\ngrant update on table \"public\".\"categories\" to \"authenticated\";\n\ngrant delete on table \"public\".\"categories\" to \"service_role\";\n\ngrant insert on table \"public\".\"categories\" to \"service_role\";\n\ngrant references on table \"public\".\"categories\" to \"service_role\";\n\ngrant select on table \"public\".\"categories\" to \"service_role\";\n\ngrant trigger on table \"public\".\"categories\" to \"service_role\";\n\ngrant truncate on table \"public\".\"categories\" to \"service_role\";\n\ngrant update on table \"public\".\"categories\" to \"service_role\";\n\ngrant delete on table \"public\".\"comments\" to \"anon\";\n\ngrant insert on table \"public\".\"comments\" to \"anon\";\n\ngrant references on table \"public\".\"comments\" to \"anon\";\n\ngrant select on table \"public\".\"comments\" to \"anon\";\n\ngrant trigger on table \"public\".\"comments\" to \"anon\";\n\ngrant truncate on table \"public\".\"comments\" to \"anon\";\n\ngrant update on table \"public\".\"comments\" to \"anon\";\n\ngrant delete on table \"public\".\"comments\" to \"authenticated\";\n\ngrant insert on table \"public\".\"comments\" to \"authenticated\";\n\ngrant references on table \"public\".\"comments\" to \"authenticated\";\n\ngrant select on table \"public\".\"comments\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"comments\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"comments\" to \"authenticated\";\n\ngrant update on table \"public\".\"comments\" to \"authenticated\";\n\ngrant delete on table \"public\".\"comments\" to \"service_role\";\n\ngrant insert on table \"public\".\"comments\" to \"service_role\";\n\ngrant references on table \"public\".\"comments\" to \"service_role\";\n\ngrant select on table \"public\".\"comments\" to \"service_role\";\n\ngrant trigger on table \"public\".\"comments\" to \"service_role\";\n\ngrant truncate on table \"public\".\"comments\" to \"service_role\";\n\ngrant update on table \"public\".\"comments\" to \"service_role\";\n\ngrant delete on table \"public\".\"profiles\" to \"anon\";\n\ngrant insert on table \"public\".\"profiles\" to \"anon\";\n\ngrant references on table \"public\".\"profiles\" to \"anon\";\n\ngrant select on table \"public\".\"profiles\" to \"anon\";\n\ngrant trigger on table \"public\".\"profiles\" to \"anon\";\n\ngrant truncate on table \"public\".\"profiles\" to \"anon\";\n\ngrant update on table \"public\".\"profiles\" to \"anon\";\n\ngrant delete on table \"public\".\"profiles\" to \"authenticated\";\n\ngrant insert on table \"public\".\"profiles\" to \"authenticated\";\n\ngrant references on table \"public\".\"profiles\" to \"authenticated\";\n\ngrant select on table \"public\".\"profiles\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"profiles\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"profiles\" to \"authenticated\";\n\ngrant update on table \"public\".\"profiles\" to \"authenticated\";\n\ngrant delete on table \"public\".\"profiles\" to \"service_role\";\n\ngrant insert on table \"public\".\"profiles\" to \"service_role\";\n\ngrant references on table \"public\".\"profiles\" to \"service_role\";\n\ngrant select on table \"public\".\"profiles\" to \"service_role\";\n\ngrant trigger on table \"public\".\"profiles\" to \"service_role\";\n\ngrant truncate on table \"public\".\"profiles\" to \"service_role\";\n\ngrant update on table \"public\".\"profiles\" to \"service_role\";\n\ngrant delete on table \"public\".\"questions\" to \"anon\";\n\ngrant insert on table \"public\".\"questions\" to \"anon\";\n\ngrant references on table \"public\".\"questions\" to \"anon\";\n\ngrant select on table \"public\".\"questions\" to \"anon\";\n\ngrant trigger on table \"public\".\"questions\" to \"anon\";\n\ngrant truncate on table \"public\".\"questions\" to \"anon\";\n\ngrant update on table \"public\".\"questions\" to \"anon\";\n\ngrant delete on table \"public\".\"questions\" to \"authenticated\";\n\ngrant insert on table \"public\".\"questions\" to \"authenticated\";\n\ngrant references on table \"public\".\"questions\" to \"authenticated\";\n\ngrant select on table \"public\".\"questions\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"questions\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"questions\" to \"authenticated\";\n\ngrant update on table \"public\".\"questions\" to \"authenticated\";\n\ngrant delete on table \"public\".\"questions\" to \"service_role\";\n\ngrant insert on table \"public\".\"questions\" to \"service_role\";\n\ngrant references on table \"public\".\"questions\" to \"service_role\";\n\ngrant select on table \"public\".\"questions\" to \"service_role\";\n\ngrant trigger on table \"public\".\"questions\" to \"service_role\";\n\ngrant truncate on table \"public\".\"questions\" to \"service_role\";\n\ngrant update on table \"public\".\"questions\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"categories\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"comments\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profiles\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"questions\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\nCREATE TRIGGER update_comment_count AFTER INSERT OR DELETE ON public.comments FOR EACH ROW EXECUTE FUNCTION update_comment_count();\n\n\n"
  },
  {
    "path": "supabase/migrations/20250111151556_questions.sql",
    "content": "create type \"public\".\"content_types\" as enum ('questions', 'projects', 'research');\n\ncreate table \"public\".\"subscribers\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"user_id\" bigint not null,\n    \"content_id\" bigint not null,\n    \"content_type\" content_types not null,\n    \"tenant_id\" text not null\n);\n\n\nalter table \"public\".\"subscribers\" enable row level security;\n\ncreate table \"public\".\"tags\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"name\" text not null,\n    \"tenant_id\" text not null,\n    \"legacy_id\" text\n);\n\n\nalter table \"public\".\"tags\" enable row level security;\n\ncreate table \"public\".\"useful_votes\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"content_id\" bigint not null,\n    \"content_type\" content_types not null,\n    \"user_id\" bigint not null,\n    \"tenant_id\" text not null\n);\n\n\nalter table \"public\".\"useful_votes\" enable row level security;\n\nalter table \"public\".\"categories\" add column \"legacy_id\" text;\n\nalter table \"public\".\"categories\" add column \"type\" content_types;\n\nalter table \"public\".\"profiles\" add column \"impact\" json;\n\nalter table \"public\".\"profiles\" add column \"is_blocked_from_messaging\" boolean;\n\nalter table \"public\".\"profiles\" add column \"is_contactable\" boolean default true;\n\nalter table \"public\".\"profiles\" add column \"is_supporter\" boolean;\n\nalter table \"public\".\"profiles\" add column \"links\" json;\n\nalter table \"public\".\"profiles\" add column \"location\" json;\n\nalter table \"public\".\"profiles\" add column \"notification_settings\" json;\n\nalter table \"public\".\"profiles\" add column \"patreon\" json;\n\nalter table \"public\".\"profiles\" add column \"tags\" text[];\n\nalter table \"public\".\"profiles\" add column \"total_useful\" integer;\n\nalter table \"public\".\"profiles\" add column \"total_views\" integer;\n\nalter table \"public\".\"profiles\" add column \"type\" text;\n\nalter table \"public\".\"questions\" add column \"images\" json[];\n\nalter table \"public\".\"questions\" add column \"legacy_id\" text;\n\nCREATE INDEX comments_created_by_idx ON public.comments USING btree (created_by);\n\nCREATE INDEX questions_category_idx ON public.questions USING btree (category);\n\nCREATE INDEX questions_created_by_idx ON public.questions USING btree (created_by);\n\nCREATE UNIQUE INDEX subscribers_pkey ON public.subscribers USING btree (id);\n\nCREATE UNIQUE INDEX tags_pkey ON public.tags USING btree (id);\n\nCREATE UNIQUE INDEX unique_tenant_slug ON public.questions USING btree (tenant_id, slug);\n\nCREATE UNIQUE INDEX useful_votes_pkey ON public.useful_votes USING btree (id);\n\nalter table \"public\".\"subscribers\" add constraint \"subscribers_pkey\" PRIMARY KEY using index \"subscribers_pkey\";\n\nalter table \"public\".\"tags\" add constraint \"tags_pkey\" PRIMARY KEY using index \"tags_pkey\";\n\nalter table \"public\".\"useful_votes\" add constraint \"useful_votes_pkey\" PRIMARY KEY using index \"useful_votes_pkey\";\n\nalter table \"public\".\"questions\" add constraint \"unique_tenant_slug\" UNIQUE using index \"unique_tenant_slug\";\n\nalter table \"public\".\"subscribers\" add constraint \"subscribers_user_id_fkey\" FOREIGN KEY (user_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"subscribers\" validate constraint \"subscribers_user_id_fkey\";\n\nalter table \"public\".\"useful_votes\" add constraint \"useful_votes_user_id_fkey\" FOREIGN KEY (user_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"useful_votes\" validate constraint \"useful_votes_user_id_fkey\";\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.comment_authors_by_source_id(source_id_input bigint)\n RETURNS SETOF text\n LANGUAGE sql\nAS $function$\n  SELECT DISTINCT (p.username)\n  FROM comments c\n  INNER JOIN profiles p\n  ON c.created_by = p.id\n  WHERE c.source_id = source_id_input\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_useful_votes_count_by_content_id(p_content_type content_types, p_content_ids bigint[])\n RETURNS TABLE(content_id bigint, count bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT v.content_id, COUNT(*) as count\n  FROM public.useful_votes v\n  WHERE content_type = p_content_type\n  AND v.content_id = ANY(p_content_ids)\n  GROUP BY v.content_id;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.questions_search_fields(questions)\n RETURNS text\n LANGUAGE sql\nAS $function$\n  SELECT $1.title || ' ' || $1.description;\n$function$\n;\n\ngrant delete on table \"public\".\"subscribers\" to \"anon\";\n\ngrant insert on table \"public\".\"subscribers\" to \"anon\";\n\ngrant references on table \"public\".\"subscribers\" to \"anon\";\n\ngrant select on table \"public\".\"subscribers\" to \"anon\";\n\ngrant trigger on table \"public\".\"subscribers\" to \"anon\";\n\ngrant truncate on table \"public\".\"subscribers\" to \"anon\";\n\ngrant update on table \"public\".\"subscribers\" to \"anon\";\n\ngrant delete on table \"public\".\"subscribers\" to \"authenticated\";\n\ngrant insert on table \"public\".\"subscribers\" to \"authenticated\";\n\ngrant references on table \"public\".\"subscribers\" to \"authenticated\";\n\ngrant select on table \"public\".\"subscribers\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"subscribers\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"subscribers\" to \"authenticated\";\n\ngrant update on table \"public\".\"subscribers\" to \"authenticated\";\n\ngrant delete on table \"public\".\"subscribers\" to \"service_role\";\n\ngrant insert on table \"public\".\"subscribers\" to \"service_role\";\n\ngrant references on table \"public\".\"subscribers\" to \"service_role\";\n\ngrant select on table \"public\".\"subscribers\" to \"service_role\";\n\ngrant trigger on table \"public\".\"subscribers\" to \"service_role\";\n\ngrant truncate on table \"public\".\"subscribers\" to \"service_role\";\n\ngrant update on table \"public\".\"subscribers\" to \"service_role\";\n\ngrant delete on table \"public\".\"tags\" to \"anon\";\n\ngrant insert on table \"public\".\"tags\" to \"anon\";\n\ngrant references on table \"public\".\"tags\" to \"anon\";\n\ngrant select on table \"public\".\"tags\" to \"anon\";\n\ngrant trigger on table \"public\".\"tags\" to \"anon\";\n\ngrant truncate on table \"public\".\"tags\" to \"anon\";\n\ngrant update on table \"public\".\"tags\" to \"anon\";\n\ngrant delete on table \"public\".\"tags\" to \"authenticated\";\n\ngrant insert on table \"public\".\"tags\" to \"authenticated\";\n\ngrant references on table \"public\".\"tags\" to \"authenticated\";\n\ngrant select on table \"public\".\"tags\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"tags\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"tags\" to \"authenticated\";\n\ngrant update on table \"public\".\"tags\" to \"authenticated\";\n\ngrant delete on table \"public\".\"tags\" to \"service_role\";\n\ngrant insert on table \"public\".\"tags\" to \"service_role\";\n\ngrant references on table \"public\".\"tags\" to \"service_role\";\n\ngrant select on table \"public\".\"tags\" to \"service_role\";\n\ngrant trigger on table \"public\".\"tags\" to \"service_role\";\n\ngrant truncate on table \"public\".\"tags\" to \"service_role\";\n\ngrant update on table \"public\".\"tags\" to \"service_role\";\n\ngrant delete on table \"public\".\"useful_votes\" to \"anon\";\n\ngrant insert on table \"public\".\"useful_votes\" to \"anon\";\n\ngrant references on table \"public\".\"useful_votes\" to \"anon\";\n\ngrant select on table \"public\".\"useful_votes\" to \"anon\";\n\ngrant trigger on table \"public\".\"useful_votes\" to \"anon\";\n\ngrant truncate on table \"public\".\"useful_votes\" to \"anon\";\n\ngrant update on table \"public\".\"useful_votes\" to \"anon\";\n\ngrant delete on table \"public\".\"useful_votes\" to \"authenticated\";\n\ngrant insert on table \"public\".\"useful_votes\" to \"authenticated\";\n\ngrant references on table \"public\".\"useful_votes\" to \"authenticated\";\n\ngrant select on table \"public\".\"useful_votes\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"useful_votes\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"useful_votes\" to \"authenticated\";\n\ngrant update on table \"public\".\"useful_votes\" to \"authenticated\";\n\ngrant delete on table \"public\".\"useful_votes\" to \"service_role\";\n\ngrant insert on table \"public\".\"useful_votes\" to \"service_role\";\n\ngrant references on table \"public\".\"useful_votes\" to \"service_role\";\n\ngrant select on table \"public\".\"useful_votes\" to \"service_role\";\n\ngrant trigger on table \"public\".\"useful_votes\" to \"service_role\";\n\ngrant truncate on table \"public\".\"useful_votes\" to \"service_role\";\n\ngrant update on table \"public\".\"useful_votes\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"subscribers\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"tags\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"useful_votes\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\n\n"
  },
  {
    "path": "supabase/migrations/20250113184950_profile_auth_columns.sql",
    "content": "alter table \"public\".\"profiles\" add column \"auth_id\" uuid;\n\nalter table \"public\".\"profiles\" add column \"legacy_id\" text;\n\nCREATE UNIQUE INDEX profiles_auth_id_tenant_id_key ON public.profiles USING btree (auth_id, tenant_id);\n\nalter table \"public\".\"profiles\" add constraint \"profiles_auth_id_fkey\" FOREIGN KEY (auth_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"profiles\" validate constraint \"profiles_auth_id_fkey\";\n\nalter table \"public\".\"profiles\" add constraint \"profiles_auth_id_tenant_id_key\" UNIQUE using index \"profiles_auth_id_tenant_id_key\";\n"
  },
  {
    "path": "supabase/migrations/20250208130256_auth_rpc.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_user_id_by_email(email text)\n RETURNS TABLE(id uuid)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$\nBEGIN\n  RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20250209131232_profile_firebase.sql",
    "content": "alter table \"public\".\"profiles\" alter column \"firebase_auth_id\" drop not null;"
  },
  {
    "path": "supabase/migrations/20250214235014_messages.sql",
    "content": "create table \"public\".\"messages\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"message\" text not null,\n    \"sender_id\" bigint not null,\n    \"receiver_id\" bigint,\n    \"tenant_id\" text not null\n);\n\n\nalter table \"public\".\"messages\" enable row level security;\n\ncreate table \"public\".\"tenant_settings\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"site_name\" text not null,\n    \"site_url\" text not null,\n    \"message_sign_off\" text,\n    \"tenant_id\" text not null,\n    \"email_from\" text,\n    \"site_image\" text\n);\n\n\nalter table \"public\".\"tenant_settings\" enable row level security;\n\nCREATE UNIQUE INDEX messages_pkey ON public.messages USING btree (id);\n\nCREATE UNIQUE INDEX tenant_settings_pkey ON public.tenant_settings USING btree (id);\n\nalter table \"public\".\"messages\" add constraint \"messages_pkey\" PRIMARY KEY using index \"messages_pkey\";\n\nalter table \"public\".\"tenant_settings\" add constraint \"tenant_settings_pkey\" PRIMARY KEY using index \"tenant_settings_pkey\";\n\nalter table \"public\".\"messages\" add constraint \"messages_from_fkey\" FOREIGN KEY (sender_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"messages\" validate constraint \"messages_from_fkey\";\n\nalter table \"public\".\"messages\" add constraint \"messages_sender_id_fkey\" FOREIGN KEY (sender_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"messages\" validate constraint \"messages_sender_id_fkey\";\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_user_email_by_id(id uuid)\n RETURNS TABLE(email character varying)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.id = $1;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.comment_authors_by_source_id(source_id_input bigint)\n RETURNS SETOF text\n LANGUAGE sql\nAS $function$\n  SELECT DISTINCT (p.username)\n  FROM comments c\n  INNER JOIN profiles p\n  ON c.created_by = p.id\n  WHERE c.source_id = source_id_input\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_useful_votes_count_by_content_id(p_content_type content_types, p_content_ids bigint[])\n RETURNS TABLE(content_id bigint, count bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT v.content_id, COUNT(*) as count\n  FROM public.useful_votes v\n  WHERE content_type = p_content_type\n  AND v.content_id = ANY(p_content_ids)\n  GROUP BY v.content_id;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_id_by_email(email text)\n RETURNS TABLE(id uuid)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$BEGIN\n  RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.questions_search_fields(questions)\n RETURNS text\n LANGUAGE sql\nAS $function$\n  SELECT $1.title || ' ' || $1.description;\n$function$\n;\n\ngrant delete on table \"public\".\"messages\" to \"anon\";\n\ngrant insert on table \"public\".\"messages\" to \"anon\";\n\ngrant references on table \"public\".\"messages\" to \"anon\";\n\ngrant select on table \"public\".\"messages\" to \"anon\";\n\ngrant trigger on table \"public\".\"messages\" to \"anon\";\n\ngrant truncate on table \"public\".\"messages\" to \"anon\";\n\ngrant update on table \"public\".\"messages\" to \"anon\";\n\ngrant delete on table \"public\".\"messages\" to \"authenticated\";\n\ngrant insert on table \"public\".\"messages\" to \"authenticated\";\n\ngrant references on table \"public\".\"messages\" to \"authenticated\";\n\ngrant select on table \"public\".\"messages\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"messages\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"messages\" to \"authenticated\";\n\ngrant update on table \"public\".\"messages\" to \"authenticated\";\n\ngrant delete on table \"public\".\"messages\" to \"service_role\";\n\ngrant insert on table \"public\".\"messages\" to \"service_role\";\n\ngrant references on table \"public\".\"messages\" to \"service_role\";\n\ngrant select on table \"public\".\"messages\" to \"service_role\";\n\ngrant trigger on table \"public\".\"messages\" to \"service_role\";\n\ngrant truncate on table \"public\".\"messages\" to \"service_role\";\n\ngrant update on table \"public\".\"messages\" to \"service_role\";\n\ngrant delete on table \"public\".\"tenant_settings\" to \"anon\";\n\ngrant insert on table \"public\".\"tenant_settings\" to \"anon\";\n\ngrant references on table \"public\".\"tenant_settings\" to \"anon\";\n\ngrant select on table \"public\".\"tenant_settings\" to \"anon\";\n\ngrant trigger on table \"public\".\"tenant_settings\" to \"anon\";\n\ngrant truncate on table \"public\".\"tenant_settings\" to \"anon\";\n\ngrant update on table \"public\".\"tenant_settings\" to \"anon\";\n\ngrant delete on table \"public\".\"tenant_settings\" to \"authenticated\";\n\ngrant insert on table \"public\".\"tenant_settings\" to \"authenticated\";\n\ngrant references on table \"public\".\"tenant_settings\" to \"authenticated\";\n\ngrant select on table \"public\".\"tenant_settings\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"tenant_settings\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"tenant_settings\" to \"authenticated\";\n\ngrant update on table \"public\".\"tenant_settings\" to \"authenticated\";\n\ngrant delete on table \"public\".\"tenant_settings\" to \"service_role\";\n\ngrant insert on table \"public\".\"tenant_settings\" to \"service_role\";\n\ngrant references on table \"public\".\"tenant_settings\" to \"service_role\";\n\ngrant select on table \"public\".\"tenant_settings\" to \"service_role\";\n\ngrant trigger on table \"public\".\"tenant_settings\" to \"service_role\";\n\ngrant truncate on table \"public\".\"tenant_settings\" to \"service_role\";\n\ngrant update on table \"public\".\"tenant_settings\" to \"service_role\";\n\ncreate policy \"message_isolation\"\non \"public\".\"messages\"\nas restrictive\nfor all\nto authenticated\nusing (((auth.uid() IN ( SELECT profiles.auth_id\n   FROM profiles\n  WHERE (profiles.id = messages.sender_id))) OR (auth.uid() IN ( SELECT profiles.auth_id\n   FROM profiles\n  WHERE (profiles.id = messages.receiver_id)))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"messages\"\nas permissive\nfor all\nto authenticated\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"tenant_settings\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\n\n"
  },
  {
    "path": "supabase/migrations/20250225071100_unique_username.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.is_username_available(username text)\n RETURNS boolean\n LANGUAGE sql\n STABLE SECURITY DEFINER\nAS $function$\n  SELECT NOT EXISTS (SELECT 1 FROM profiles WHERE username = $1);\n$function$\n;"
  },
  {
    "path": "supabase/migrations/20250307061534_messages_temp_fix.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_user_email_by_username(username text)\n RETURNS TABLE(email character varying)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.raw_user_meta_data->>'username' = $1;\nEND;\n$function$\n;"
  },
  {
    "path": "supabase/migrations/20250312063008_patreon.sql",
    "content": "create table \"public\".\"patreon_settings\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"tiers\" json,\n    \"tenant_id\" text\n);\n\nalter table \"public\".\"patreon_settings\" enable row level security;\n\nCREATE UNIQUE INDEX patreon_settings_pkey ON public.patreon_settings USING btree (id);\n\nalter table \"public\".\"patreon_settings\" add constraint \"patreon_settings_pkey\" PRIMARY KEY using index \"patreon_settings_pkey\";\n\ngrant delete on table \"public\".\"patreon_settings\" to \"anon\";\n\ngrant insert on table \"public\".\"patreon_settings\" to \"anon\";\n\ngrant references on table \"public\".\"patreon_settings\" to \"anon\";\n\ngrant select on table \"public\".\"patreon_settings\" to \"anon\";\n\ngrant trigger on table \"public\".\"patreon_settings\" to \"anon\";\n\ngrant truncate on table \"public\".\"patreon_settings\" to \"anon\";\n\ngrant update on table \"public\".\"patreon_settings\" to \"anon\";\n\ngrant delete on table \"public\".\"patreon_settings\" to \"authenticated\";\n\ngrant insert on table \"public\".\"patreon_settings\" to \"authenticated\";\n\ngrant references on table \"public\".\"patreon_settings\" to \"authenticated\";\n\ngrant select on table \"public\".\"patreon_settings\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"patreon_settings\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"patreon_settings\" to \"authenticated\";\n\ngrant update on table \"public\".\"patreon_settings\" to \"authenticated\";\n\ngrant delete on table \"public\".\"patreon_settings\" to \"service_role\";\n\ngrant insert on table \"public\".\"patreon_settings\" to \"service_role\";\n\ngrant references on table \"public\".\"patreon_settings\" to \"service_role\";\n\ngrant select on table \"public\".\"patreon_settings\" to \"service_role\";\n\ngrant trigger on table \"public\".\"patreon_settings\" to \"service_role\";\n\ngrant truncate on table \"public\".\"patreon_settings\" to \"service_role\";\n\ngrant update on table \"public\".\"patreon_settings\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"patreon_settings\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n"
  },
  {
    "path": "supabase/migrations/20250331134409_add_news.sql",
    "content": "alter type \"public\".\"content_types\" ADD VALUE 'news';\n\ncreate table \"public\".\"news\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"created_by\" bigint,\n    \"deleted\" boolean,\n    \"modified_at\" timestamp with time zone default (now() AT TIME ZONE 'utc'::text),\n    \"comment_count\" bigint default '0'::bigint,\n    \"body\" text not null,\n    \"moderation\" text,\n    \"slug\" text not null,\n    \"previous_slugs\" text[],\n    \"category\" bigint,\n    \"tags\" bigint[],\n    \"title\" text not null,\n    \"total_views\" bigint,\n    \"tenant_id\" text not null,\n    \"fts\" tsvector generated always as (to_tsvector('english'::regconfig, ((title || ' '::text) || body))) stored,\n    \"hero_image\" json\n);\n\n\nalter table \"public\".\"news\" enable row level security;\n\nCREATE INDEX news_category_idx ON public.news USING btree (category);\n\nCREATE INDEX news_created_by_idx ON public.news USING btree (created_by);\n\nCREATE INDEX news_deleted_moderation_category_total_views_tags_created_a_idx ON public.news USING btree (deleted, moderation, category, total_views, tags, created_at, comment_count, created_by);\n\nCREATE UNIQUE INDEX news_pkey ON public.news USING btree (id);\n\nCREATE INDEX news_tags_idx ON public.news USING gin (tags);\n\nCREATE UNIQUE INDEX news_tenant_id_slug_key ON public.news USING btree (tenant_id, slug);\n\nalter table \"public\".\"news\" add constraint \"news_pkey\" PRIMARY KEY using index \"news_pkey\";\n\nalter table \"public\".\"news\" add constraint \"news_category_fkey\" FOREIGN KEY (category) REFERENCES categories(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"news\" validate constraint \"news_category_fkey\";\n\nalter table \"public\".\"news\" add constraint \"news_created_by_fkey\" FOREIGN KEY (created_by) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"news\" validate constraint \"news_created_by_fkey\";\n\nalter table \"public\".\"news\" add constraint \"news_tenant_id_slug_key\" UNIQUE using index \"news_tenant_id_slug_key\";\n\ngrant delete on table \"public\".\"news\" to \"anon\";\n\ngrant insert on table \"public\".\"news\" to \"anon\";\n\ngrant references on table \"public\".\"news\" to \"anon\";\n\ngrant select on table \"public\".\"news\" to \"anon\";\n\ngrant trigger on table \"public\".\"news\" to \"anon\";\n\ngrant truncate on table \"public\".\"news\" to \"anon\";\n\ngrant update on table \"public\".\"news\" to \"anon\";\n\ngrant delete on table \"public\".\"news\" to \"authenticated\";\n\ngrant insert on table \"public\".\"news\" to \"authenticated\";\n\ngrant references on table \"public\".\"news\" to \"authenticated\";\n\ngrant select on table \"public\".\"news\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"news\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"news\" to \"authenticated\";\n\ngrant update on table \"public\".\"news\" to \"authenticated\";\n\ngrant delete on table \"public\".\"news\" to \"service_role\";\n\ngrant insert on table \"public\".\"news\" to \"service_role\";\n\ngrant references on table \"public\".\"news\" to \"service_role\";\n\ngrant select on table \"public\".\"news\" to \"service_role\";\n\ngrant trigger on table \"public\".\"news\" to \"service_role\";\n\ngrant truncate on table \"public\".\"news\" to \"service_role\";\n\ngrant update on table \"public\".\"news\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"news\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\nCREATE OR REPLACE FUNCTION public.news_search_fields(news)\n RETURNS text\n LANGUAGE sql\nAS $function$\n  SELECT $1.title || ' ' || $1.body;\n$function$\n;\n\nalter table \"public\".\"tags\" add column \"modified_at\" date;\n\nCREATE OR REPLACE FUNCTION public.update_comment_count()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$BEGIN\n  IF (TG_OP = 'INSERT') THEN\n    IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL THEN\n      IF NEW.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = comment_count + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'research' THEN\n        UPDATE research SET comment_count = comment_count + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'news' THEN\n        UPDATE news SET comment_count = comment_count + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'howtos' THEN\n        UPDATE howtos SET comment_count = comment_count + 1\n        WHERE id = NEW.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: source_type or source_id is NULL';\n    END IF;\n\n  ELSIF (TG_OP = 'DELETE') THEN\n    IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n      IF OLD.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = comment_count - 1\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'research' THEN\n        UPDATE research SET comment_count = comment_count - 1\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'news' THEN\n        UPDATE news SET comment_count = comment_count - 1\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'howtos' THEN\n        UPDATE howtos SET comment_count = comment_count - 1\n        WHERE id = OLD.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n    END IF;\n  END IF;\n\n  -- Explicit return for the trigger function\n  RETURN NULL;\nEND;$function$\n;\n\nCREATE OR REPLACE TRIGGER update_comment_count AFTER INSERT OR DELETE ON public.comments FOR EACH ROW EXECUTE FUNCTION update_comment_count();\n\ncreate policy \"tenant_isolation\"\non \"storage\".\"objects\"\nas permissive\nfor all\nto public\nusing ((bucket_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\nalter table \"public\".\"news\" drop column \"fts\";\n\nalter table \"public\".\"news\" add column \"summary\" text;\n\nalter table \"public\".\"news\" add column \"fts\" tsvector generated always as (to_tsvector('english'::regconfig, ((title || ' '::text) || body || (summary || ''::text)))) stored;\n"
  },
  {
    "path": "supabase/migrations/20250416104948_research.sql",
    "content": "create type \"public\".\"research_status\" as enum ('in-progress', 'complete', 'archived');\n\ncreate table \"public\".\"research\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"modified_at\" timestamp with time zone default now(),\n    \"title\" text not null,\n    \"slug\" text not null,\n    \"description\" text not null,\n    \"category\" bigint,\n    \"created_by\" bigint,\n    \"tags\" text[],\n    \"deleted\" boolean,\n    \"total_views\" integer,\n    \"total_useful\" integer,\n    \"previous_slugs\" text[],\n    \"status\" research_status,\n    \"is_draft\" boolean,\n    \"tenant_id\" text not null,\n    \"fts\" tsvector,\n    \"collaborators\" text[],\n    \"image\" json,\n    \"legacy_id\" text\n);\n\n\nalter table \"public\".\"research\" enable row level security;\n\ncreate table \"public\".\"research_updates\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"research_id\" bigint not null,\n    \"title\" text not null,\n    \"description\" text not null,\n    \"images\" json[],\n    \"files\" json[],\n    \"video_url\" text,\n    \"is_draft\" boolean,\n    \"comment_count\" integer,\n    \"tenant_id\" text not null,\n    \"modified_at\" timestamp with time zone default (now() AT TIME ZONE 'utc'::text),\n    \"deleted\" boolean,\n    \"file_link\" text,\n    \"file_download_count\" integer,\n    \"created_by\" bigint,\n    \"legacy_id\" text\n);\n\n\nalter table \"public\".\"research_updates\" enable row level security;\n\nCREATE INDEX research_fts_idx ON public.research USING gin (fts);\n\nCREATE UNIQUE INDEX research_pkey ON public.research USING btree (id);\n\nCREATE UNIQUE INDEX research_update_pkey ON public.research_updates USING btree (id);\n\nalter table \"public\".\"research\" add constraint \"research_pkey\" PRIMARY KEY using index \"research_pkey\";\n\nalter table \"public\".\"research_updates\" add constraint \"research_update_pkey\" PRIMARY KEY using index \"research_update_pkey\";\n\nalter table \"public\".\"research\" add constraint \"research_category_fkey\" FOREIGN KEY (category) REFERENCES categories(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"research\" validate constraint \"research_category_fkey\";\n\nalter table \"public\".\"research\" add constraint \"research_created_by_fkey\" FOREIGN KEY (created_by) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"research\" validate constraint \"research_created_by_fkey\";\n\nalter table \"public\".\"research_updates\" add constraint \"research_update_research_id_fkey\" FOREIGN KEY (research_id) REFERENCES research(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"research_updates\" validate constraint \"research_update_research_id_fkey\";\n\nalter table \"public\".\"research_updates\" add constraint \"research_updates_created_by_fkey\" FOREIGN KEY (created_by) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"research_updates\" validate constraint \"research_updates_created_by_fkey\";\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.combined_research_search_fields(research_id_param bigint)\n RETURNS text\n LANGUAGE sql\nAS $function$\n  SELECT\n    (SELECT r.title || ' ' || r.description FROM research r WHERE r.id = research_id_param) || ' ' ||\n    COALESCE(string_agg(ru.title || ' ' || ru.description, ' '), '')\n  FROM research_updates ru\n  WHERE ru.research_id = research_id_param;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status research_status DEFAULT NULL::research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    r.modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object('id', p.id, 'display_name', p.display_name, 'username', p.username, 'is_verified', p.is_verified, 'country', p.country) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count\n  FROM research r\n  WHERE\n    (search_query IS NULL OR r.fts @@ to_tsquery('english', search_query)) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    CASE sort_by\n      WHEN 'Newest' THEN extract(epoch from r.created_at)\n      WHEN 'LatestUpdated' THEN extract(epoch from r.modified_at)\n      WHEN 'MostComments' THEN \n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE))\n      WHEN 'MostUpdates' THEN \n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.update_research_tsvector()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  IF TG_TABLE_NAME = 'research_updates' THEN\n    UPDATE research\n    SET fts = to_tsvector('english', public.combined_research_search_fields(NEW.research_id))\n    WHERE id = NEW.research_id;\n  ELSEIF TG_TABLE_NAME = 'research' THEN\n    UPDATE research\n    SET fts = to_tsvector('english', public.combined_research_search_fields(NEW.id))\n    WHERE id = NEW.id;\n  END IF;\n  RETURN NEW;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.comment_authors_by_source_id(source_id_input bigint)\n RETURNS SETOF text\n LANGUAGE sql\nAS $function$\n  SELECT DISTINCT (p.username)\n  FROM comments c\n  INNER JOIN profiles p\n  ON c.created_by = p.id\n  WHERE c.source_id = source_id_input\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_useful_votes_count_by_content_id(p_content_type content_types, p_content_ids bigint[])\n RETURNS TABLE(content_id bigint, count bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT v.content_id, COUNT(*) as count\n  FROM public.useful_votes v\n  WHERE content_type = p_content_type\n  AND v.content_id = ANY(p_content_ids)\n  GROUP BY v.content_id;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_email_by_id(id uuid)\n RETURNS TABLE(email character varying)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.id = $1;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_email_by_username(username text)\n RETURNS TABLE(email character varying)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.raw_user_meta_data->>'username' = $1;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_id_by_email(email text)\n RETURNS TABLE(id uuid)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$BEGIN\n  RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.is_username_available(username text)\n RETURNS boolean\n LANGUAGE sql\n STABLE SECURITY DEFINER\nAS $function$\n  SELECT NOT EXISTS (SELECT 1 FROM profiles WHERE username = $1);\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.news_search_fields(news)\n RETURNS text\n LANGUAGE sql\nAS $function$\n  SELECT $1.title || ' ' || $1.body;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.questions_search_fields(questions)\n RETURNS text\n LANGUAGE sql\nAS $function$\n  SELECT $1.title || ' ' || $1.description;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.update_comment_count()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$BEGIN\n  IF (TG_OP = 'INSERT') THEN\n    IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL THEN\n      IF NEW.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'research_update' THEN\n        UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'news' THEN\n        UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: source_type or source_id is NULL';\n    END IF;\n  ELSIF (TG_OP = 'DELETE') THEN\n    IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n      IF OLD.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'research_update' THEN\n        UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'news' THEN\n        UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n    END IF;\n  END IF;\n  -- Explicit return for the trigger function\n  RETURN NULL;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, research_status research_status DEFAULT NULL::research_status)\n RETURNS integer\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN (\n    SELECT COUNT(*)\n    FROM research r\n    WHERE\n      (search_query IS NULL OR r.fts @@ to_tsquery('english', search_query)) AND\n      (category_id IS NULL OR r.category = category_id) AND\n      (research_status IS NULL OR r.status = research_status) AND\n      (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n      (r.deleted IS NULL OR r.deleted = FALSE)\n  );\nEND;\n$function$\n;\n\ngrant delete on table \"public\".\"research\" to \"anon\";\n\ngrant insert on table \"public\".\"research\" to \"anon\";\n\ngrant references on table \"public\".\"research\" to \"anon\";\n\ngrant select on table \"public\".\"research\" to \"anon\";\n\ngrant trigger on table \"public\".\"research\" to \"anon\";\n\ngrant truncate on table \"public\".\"research\" to \"anon\";\n\ngrant update on table \"public\".\"research\" to \"anon\";\n\ngrant delete on table \"public\".\"research\" to \"authenticated\";\n\ngrant insert on table \"public\".\"research\" to \"authenticated\";\n\ngrant references on table \"public\".\"research\" to \"authenticated\";\n\ngrant select on table \"public\".\"research\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"research\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"research\" to \"authenticated\";\n\ngrant update on table \"public\".\"research\" to \"authenticated\";\n\ngrant delete on table \"public\".\"research\" to \"service_role\";\n\ngrant insert on table \"public\".\"research\" to \"service_role\";\n\ngrant references on table \"public\".\"research\" to \"service_role\";\n\ngrant select on table \"public\".\"research\" to \"service_role\";\n\ngrant trigger on table \"public\".\"research\" to \"service_role\";\n\ngrant truncate on table \"public\".\"research\" to \"service_role\";\n\ngrant update on table \"public\".\"research\" to \"service_role\";\n\ngrant delete on table \"public\".\"research_updates\" to \"anon\";\n\ngrant insert on table \"public\".\"research_updates\" to \"anon\";\n\ngrant references on table \"public\".\"research_updates\" to \"anon\";\n\ngrant select on table \"public\".\"research_updates\" to \"anon\";\n\ngrant trigger on table \"public\".\"research_updates\" to \"anon\";\n\ngrant truncate on table \"public\".\"research_updates\" to \"anon\";\n\ngrant update on table \"public\".\"research_updates\" to \"anon\";\n\ngrant delete on table \"public\".\"research_updates\" to \"authenticated\";\n\ngrant insert on table \"public\".\"research_updates\" to \"authenticated\";\n\ngrant references on table \"public\".\"research_updates\" to \"authenticated\";\n\ngrant select on table \"public\".\"research_updates\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"research_updates\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"research_updates\" to \"authenticated\";\n\ngrant update on table \"public\".\"research_updates\" to \"authenticated\";\n\ngrant delete on table \"public\".\"research_updates\" to \"service_role\";\n\ngrant insert on table \"public\".\"research_updates\" to \"service_role\";\n\ngrant references on table \"public\".\"research_updates\" to \"service_role\";\n\ngrant select on table \"public\".\"research_updates\" to \"service_role\";\n\ngrant trigger on table \"public\".\"research_updates\" to \"service_role\";\n\ngrant truncate on table \"public\".\"research_updates\" to \"service_role\";\n\ngrant update on table \"public\".\"research_updates\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"research\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"research_updates\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\nCREATE TRIGGER research_text_trigger AFTER INSERT OR UPDATE OF title, description ON public.research FOR EACH ROW EXECUTE FUNCTION update_research_tsvector();\n\nCREATE TRIGGER research_update_trigger AFTER INSERT OR DELETE OR UPDATE OF title, description ON public.research_updates FOR EACH ROW EXECUTE FUNCTION update_research_tsvector();\n\ndrop policy if exists \"tenant_isolation\" on storage.objects;\n\ncreate policy \"tenant_isolation\"\non \"storage\".\"objects\"\nas permissive\nfor all\nto public\nusing (\n  (bucket_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))\n  OR\n  (bucket_id = (((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text) || '-documents'))\n);"
  },
  {
    "path": "supabase/migrations/20250422092704_add_notifications.sql",
    "content": "create type \"public\".\"notification_action_types\" as enum ('newComment', 'newContent');\n\n\ncreate type \"public\".\"notification_source_content_type\" as enum ('news', 'research', 'researchUpdate', 'library', 'questions');\n\ncreate type \"public\".\"notification_content_types\" as enum ('news', 'research', 'researchUpdate', 'library', 'questions', 'comment', 'reply');\n\ncreate table \"public\".\"notifications\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"modified_at\" timestamp with time zone default (now() AT TIME ZONE 'utc'::text),\n    \"owned_by_id\" bigint not null,\n    \"triggered_by_id\" bigint not null,\n    \"content_type\" notification_content_types not null,\n    \"content_id\" bigint not null,\n    \"is_read\" boolean default false,\n    \"action_type\" notification_action_types not null,\n    \"tenant_id\" text not null,\n    \"source_content_type\" notification_source_content_type not null,\n    \"source_content_id\" bigint not null\n);\n\n\nalter table \"public\".\"notifications\" enable row level security;\n\nalter table \"public\".\"profiles\" add column \"notifications\" uuid[];\n\nCREATE UNIQUE INDEX notifications_pkey ON public.notifications USING btree (id);\n\nalter table \"public\".\"notifications\" add constraint \"notifications_pkey\" PRIMARY KEY using index \"notifications_pkey\";\n\nalter table \"public\".\"notifications\" add constraint \"notifications_owned_by_id_fkey\" FOREIGN KEY (owned_by_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"notifications\" validate constraint \"notifications_owned_by_id_fkey\";\n\nalter table \"public\".\"notifications\" add constraint \"notifications_triggered_by_id_fkey\" FOREIGN KEY (triggered_by_id) REFERENCES profiles(id) ON UPDATE CASCADE not valid;\n\nalter table \"public\".\"notifications\" validate constraint \"notifications_triggered_by_id_fkey\";\n\ngrant delete on table \"public\".\"notifications\" to \"anon\";\n\ngrant insert on table \"public\".\"notifications\" to \"anon\";\n\ngrant references on table \"public\".\"notifications\" to \"anon\";\n\ngrant select on table \"public\".\"notifications\" to \"anon\";\n\ngrant trigger on table \"public\".\"notifications\" to \"anon\";\n\ngrant truncate on table \"public\".\"notifications\" to \"anon\";\n\ngrant update on table \"public\".\"notifications\" to \"anon\";\n\ngrant delete on table \"public\".\"notifications\" to \"authenticated\";\n\ngrant insert on table \"public\".\"notifications\" to \"authenticated\";\n\ngrant references on table \"public\".\"notifications\" to \"authenticated\";\n\ngrant select on table \"public\".\"notifications\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"notifications\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"notifications\" to \"authenticated\";\n\ngrant update on table \"public\".\"notifications\" to \"authenticated\";\n\ngrant delete on table \"public\".\"notifications\" to \"service_role\";\n\ngrant insert on table \"public\".\"notifications\" to \"service_role\";\n\ngrant references on table \"public\".\"notifications\" to \"service_role\";\n\ngrant select on table \"public\".\"notifications\" to \"service_role\";\n\ngrant trigger on table \"public\".\"notifications\" to \"service_role\";\n\ngrant truncate on table \"public\".\"notifications\" to \"service_role\";\n\ngrant update on table \"public\".\"notifications\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"notifications\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\n\n"
  },
  {
    "path": "supabase/migrations/20250512170000_research_sorting.sql",
    "content": "\nCREATE OR REPLACE FUNCTION public.get_research(\n    search_query text DEFAULT NULL::text, \n    category_id bigint DEFAULT NULL::bigint, \n    research_status research_status DEFAULT NULL::research_status, \n    sort_by text DEFAULT 'Newest'::text, \n    limit_val integer DEFAULT 10, \n    offset_val integer DEFAULT 0\n)\n RETURNS TABLE(\n    id bigint, \n    created_at timestamp with time zone, \n    created_by bigint, \n    modified_at timestamp with time zone, \n    description text, \n    slug text, \n    image json, \n    status research_status, \n    category json, \n    tags text[], \n    title text, \n    total_views integer, \n    author json, \n    update_count bigint, \n    comment_count bigint\n)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    r.modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object('id', p.id, 'display_name', p.display_name, 'username', p.username, 'is_verified', p.is_verified, 'country', p.country) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count\n  FROM research r\n  WHERE\n    (search_query IS NULL OR r.fts @@ to_tsquery('english', search_query)) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    CASE sort_by\n      WHEN 'Newest' THEN extract(epoch from r.created_at)\n      WHEN 'LatestUpdated' THEN \n        COALESCE(\n          (SELECT extract(epoch from MAX(ru.created_at)) \n           FROM research_updates ru \n           WHERE ru.research_id = r.id \n             AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n             AND (ru.deleted IS NULL OR ru.deleted = FALSE)\n          ),\n          extract(epoch from r.created_at)\n        )\n      WHEN 'MostComments' THEN \n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE))\n      WHEN 'MostUpdates' THEN \n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;"
  },
  {
    "path": "supabase/migrations/20250513090512_update_for_email_notifications.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_user_email_by_profile_id(id int8)\n RETURNS TABLE(email character varying)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au inner join public.profiles p on au.id = p.auth_id WHERE p.id = $1;\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20250514163118_update_notifications.sql",
    "content": "alter table \"public\".\"notifications\" add column \"parent_content_id\" bigint;\n\nalter table \"public\".\"notifications\" alter column \"source_content_id\" drop not null;\n\nalter table \"public\".\"notifications\" alter column \"source_content_type\" drop not null;\n\n\n"
  },
  {
    "path": "supabase/migrations/20250520134140_update_notifications_for_research_comments.sql",
    "content": "alter table \"public\".\"notifications\" rename column \"parent_content_id\" to \"parent_comment_id\";\n\nalter table \"public\".\"notifications\" add column \"parent_content_id\" bigint;\n\nalter table \"public\".\"notifications\" alter column \"source_content_type\" set data type text using \"source_content_type\"::text;\n\nalter table \"public\".\"subscribers\" alter column \"content_type\" set data type text using \"content_type\"::text;\n\n"
  },
  {
    "path": "supabase/migrations/20250521044802_fix-research-sorting-useful.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_user_research(username_param text)\n RETURNS TABLE(id bigint, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    r.id,\n    r.title,\n    r.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM research r\n  INNER JOIN profiles p ON p.id = r.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = r.id AND uv.content_type = 'research'\n  WHERE p.username = username_param\n  AND (r.deleted IS NULL OR r.deleted = FALSE)\n  GROUP BY r.id, r.title, r.slug;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status research_status DEFAULT NULL::research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    r.modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object('id', p.id, 'display_name', p.display_name, 'username', p.username, 'is_verified', p.is_verified, 'country', p.country) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count\n  FROM research r\n  WHERE\n    (search_query IS NULL OR r.fts @@ to_tsquery('english', search_query)) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from r.modified_at)\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;"
  },
  {
    "path": "supabase/migrations/20250523104253_20250523_add-get-user-questions.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_user_questions(username_param text)\n RETURNS TABLE(id bigint, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    q.id,\n    q.title,\n    q.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM questions q\n  INNER JOIN profiles p ON p.id = q.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = q.id AND uv.content_type = 'questions'\n  WHERE p.username = username_param\n  AND (q.deleted IS NULL OR q.deleted = FALSE)\n  GROUP BY q.id, q.title, q.slug;\nEND;\n$function$\n;\n\n\n"
  },
  {
    "path": "supabase/migrations/20250603130017_add_notifications_preferences.sql",
    "content": "create table \"public\".\"notifications_preferences\" (\n    \"id\" bigint generated by default as identity not null,\n    \"user_id\" bigint not null,\n    \"comments\" boolean not null,\n    \"replies\" boolean not null,\n    \"tenant_id\" text not null\n);\n\n\nalter table \"public\".\"notifications_preferences\" enable row level security;\n\nCREATE UNIQUE INDEX notifications_preferences_pkey ON public.notifications_preferences USING btree (id);\n\nalter table \"public\".\"notifications_preferences\" add constraint \"notifications_preferences_pkey\" PRIMARY KEY using index \"notifications_preferences_pkey\";\n\nalter table \"public\".\"notifications_preferences\" add constraint \"notifications_preferences_user_id_fkey\" FOREIGN KEY (user_id) REFERENCES profiles(id) not valid;\n\nalter table \"public\".\"notifications_preferences\" validate constraint \"notifications_preferences_user_id_fkey\";\n\ngrant delete on table \"public\".\"notifications_preferences\" to \"anon\";\n\ngrant insert on table \"public\".\"notifications_preferences\" to \"anon\";\n\ngrant references on table \"public\".\"notifications_preferences\" to \"anon\";\n\ngrant select on table \"public\".\"notifications_preferences\" to \"anon\";\n\ngrant trigger on table \"public\".\"notifications_preferences\" to \"anon\";\n\ngrant truncate on table \"public\".\"notifications_preferences\" to \"anon\";\n\ngrant update on table \"public\".\"notifications_preferences\" to \"anon\";\n\ngrant delete on table \"public\".\"notifications_preferences\" to \"authenticated\";\n\ngrant insert on table \"public\".\"notifications_preferences\" to \"authenticated\";\n\ngrant references on table \"public\".\"notifications_preferences\" to \"authenticated\";\n\ngrant select on table \"public\".\"notifications_preferences\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"notifications_preferences\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"notifications_preferences\" to \"authenticated\";\n\ngrant update on table \"public\".\"notifications_preferences\" to \"authenticated\";\n\ngrant delete on table \"public\".\"notifications_preferences\" to \"service_role\";\n\ngrant insert on table \"public\".\"notifications_preferences\" to \"service_role\";\n\ngrant references on table \"public\".\"notifications_preferences\" to \"service_role\";\n\ngrant select on table \"public\".\"notifications_preferences\" to \"service_role\";\n\ngrant trigger on table \"public\".\"notifications_preferences\" to \"service_role\";\n\ngrant truncate on table \"public\".\"notifications_preferences\" to \"service_role\";\n\ngrant update on table \"public\".\"notifications_preferences\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"notifications_preferences\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\n\n"
  },
  {
    "path": "supabase/migrations/20250605115351_research-latest-updated.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status research_status DEFAULT NULL::research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object('id', p.id, 'display_name', p.display_name, 'username', p.username, 'is_verified', p.is_verified, 'country', p.country) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count\n  FROM research r\n  WHERE\n    (search_query IS NULL OR r.fts @@ to_tsquery('english', search_query)) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from \n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru \n             WHERE ru.research_id = r.id \n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;"
  },
  {
    "path": "supabase/migrations/20250609100159_research_query_ranking.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status research_status DEFAULT NULL::research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint)\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', search_query);\n  END IF;\n  \n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object('id', p.id, 'display_name', p.display_name, 'username', p.username, 'is_verified', p.is_verified, 'country', p.country) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count\n  FROM research r\n  WHERE\n    (search_query IS NULL OR r.fts @@ ts_query) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from \n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru \n             WHERE ru.research_id = r.id \n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;"
  },
  {
    "path": "supabase/migrations/20250609101144_library.sql",
    "content": "create table \"public\".\"project_steps\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"project_id\" bigint not null,\n    \"title\" text not null,\n    \"description\" text not null,\n    \"images\" json,\n    \"video_url\" text,\n    \"order\" smallint null,\n    \"tenant_id\" text not null\n);\n\n\nalter table \"public\".\"project_steps\" enable row level security;\n\ncreate table \"public\".\"projects\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"modified_at\" timestamp with time zone,\n    \"title\" text not null,\n    \"slug\" text not null,\n    \"previous_slugs\" text[],\n    \"description\" text not null,\n    \"created_by\" bigint,\n    \"deleted\" boolean,\n    \"category\" bigint,\n    \"difficulty_level\" text,\n    \"cover_image\" json,\n    \"file_link\" text,\n    \"files\" json[],\n    \"tags\" text[],\n    \"is_draft\" boolean,\n    \"time\" text,\n    \"file_download_count\" integer,\n    \"moderation\" text,\n    \"moderation_feedback\" text,\n    \"tenant_id\" text not null,\n    \"fts\" tsvector,\n    \"total_views\" bigint,\n    \"comment_count\" integer,\n    \"legacy_id\" text\n);\n\n\nalter table \"public\".\"projects\" enable row level security;\n\nCREATE UNIQUE INDEX project_steps_pkey ON public.project_steps USING btree (id);\n\nCREATE UNIQUE INDEX projects_pkey ON public.projects USING btree (id);\n\nCREATE UNIQUE INDEX projects_slug_key ON public.projects USING btree (slug, tenant_id);\n\nCREATE UNIQUE INDEX projects_title_key ON public.projects USING btree (title, tenant_id);\n\nalter table \"public\".\"project_steps\" add constraint \"project_steps_pkey\" PRIMARY KEY using index \"project_steps_pkey\";\n\nalter table \"public\".\"projects\" add constraint \"projects_pkey\" PRIMARY KEY using index \"projects_pkey\";\n\nalter table \"public\".\"project_steps\" add constraint \"project_steps_project_id_fkey\" FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"project_steps\" validate constraint \"project_steps_project_id_fkey\";\n\nalter table \"public\".\"projects\" add constraint \"projects_category_id_fkey\" FOREIGN KEY (category) REFERENCES categories(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"projects\" validate constraint \"projects_category_id_fkey\";\n\nalter table \"public\".\"projects\" add constraint \"projects_created_by_fkey\" FOREIGN KEY (created_by) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"projects\" validate constraint \"projects_created_by_fkey\";\n\nalter table \"public\".\"projects\" add constraint \"projects_slug_key\" UNIQUE using index \"projects_slug_key\";\n\nalter table \"public\".\"projects\" add constraint \"projects_title_key\" UNIQUE using index \"projects_title_key\";\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.combined_project_search_fields(project_id_param bigint)\n RETURNS text\n LANGUAGE sql\nAS $function$\n  SELECT\n    (SELECT p.title || ' ' || p.description FROM projects p WHERE p.id = project_id_param) || ' ' ||\n    COALESCE(string_agg(ps.title || ' ' || ps.description, ' '), '')\n  FROM project_steps ps\n  WHERE ps.project_id = project_id_param;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer)\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided\n    IF search_query IS NOT NULL THEN\n      ts_query := to_tsquery('english', search_query);\n    END IF;\n\n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        json_build_object(\n            'id', prof.id,\n            'display_name', prof.display_name,\n            'username', prof.username,\n            'is_verified', prof.is_verified,\n            'country', prof.country\n        ) AS author,\n        p.comment_count\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n        (search_query IS NULL OR \n         p.fts @@ ts_query OR \n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.created_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.created_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;\n$function$;\n\nCREATE OR REPLACE FUNCTION public.get_projects_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, current_username text DEFAULT NULL::text)\n RETURNS integer\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (search_query IS NULL OR \n         p.fts @@ ts_query OR \n         prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (search_query IS NULL OR p.fts @@ to_tsquery('english', search_query)) AND\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.update_project_tsvector()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  IF TG_TABLE_NAME = 'project_steps' THEN\n    UPDATE projects\n    SET fts = to_tsvector('english', public.combined_project_search_fields(NEW.project_id))\n    WHERE id = NEW.project_id;\n  ELSEIF TG_TABLE_NAME = 'projects' THEN\n    UPDATE projects\n    SET fts = to_tsvector('english', public.combined_project_search_fields(NEW.id))\n    WHERE id = NEW.id;\n  END IF;\n  RETURN NEW;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_projects(username_param text)\n RETURNS TABLE(id bigint, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    pr.id,\n    pr.title,\n    pr.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM projects pr\n  INNER JOIN profiles p ON p.id = pr.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = pr.id AND uv.content_type = 'projects'\n  WHERE p.username = username_param\n  AND (pr.deleted IS NULL OR pr.deleted = FALSE)\n  GROUP BY pr.id, pr.title, pr.slug;\nEND;\n$function$\n;\n\ngrant delete on table \"public\".\"project_steps\" to \"anon\";\n\ngrant insert on table \"public\".\"project_steps\" to \"anon\";\n\ngrant references on table \"public\".\"project_steps\" to \"anon\";\n\ngrant select on table \"public\".\"project_steps\" to \"anon\";\n\ngrant trigger on table \"public\".\"project_steps\" to \"anon\";\n\ngrant truncate on table \"public\".\"project_steps\" to \"anon\";\n\ngrant update on table \"public\".\"project_steps\" to \"anon\";\n\ngrant delete on table \"public\".\"project_steps\" to \"authenticated\";\n\ngrant insert on table \"public\".\"project_steps\" to \"authenticated\";\n\ngrant references on table \"public\".\"project_steps\" to \"authenticated\";\n\ngrant select on table \"public\".\"project_steps\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"project_steps\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"project_steps\" to \"authenticated\";\n\ngrant update on table \"public\".\"project_steps\" to \"authenticated\";\n\ngrant delete on table \"public\".\"project_steps\" to \"service_role\";\n\ngrant insert on table \"public\".\"project_steps\" to \"service_role\";\n\ngrant references on table \"public\".\"project_steps\" to \"service_role\";\n\ngrant select on table \"public\".\"project_steps\" to \"service_role\";\n\ngrant trigger on table \"public\".\"project_steps\" to \"service_role\";\n\ngrant truncate on table \"public\".\"project_steps\" to \"service_role\";\n\ngrant update on table \"public\".\"project_steps\" to \"service_role\";\n\ngrant delete on table \"public\".\"projects\" to \"anon\";\n\ngrant insert on table \"public\".\"projects\" to \"anon\";\n\ngrant references on table \"public\".\"projects\" to \"anon\";\n\ngrant select on table \"public\".\"projects\" to \"anon\";\n\ngrant trigger on table \"public\".\"projects\" to \"anon\";\n\ngrant truncate on table \"public\".\"projects\" to \"anon\";\n\ngrant update on table \"public\".\"projects\" to \"anon\";\n\ngrant delete on table \"public\".\"projects\" to \"authenticated\";\n\ngrant insert on table \"public\".\"projects\" to \"authenticated\";\n\ngrant references on table \"public\".\"projects\" to \"authenticated\";\n\ngrant select on table \"public\".\"projects\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"projects\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"projects\" to \"authenticated\";\n\ngrant update on table \"public\".\"projects\" to \"authenticated\";\n\ngrant delete on table \"public\".\"projects\" to \"service_role\";\n\ngrant insert on table \"public\".\"projects\" to \"service_role\";\n\ngrant references on table \"public\".\"projects\" to \"service_role\";\n\ngrant select on table \"public\".\"projects\" to \"service_role\";\n\ngrant trigger on table \"public\".\"projects\" to \"service_role\";\n\ngrant truncate on table \"public\".\"projects\" to \"service_role\";\n\ngrant update on table \"public\".\"projects\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"project_steps\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"projects\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\nCREATE TRIGGER project_step_trigger AFTER INSERT OR DELETE OR UPDATE OF title, description ON public.project_steps FOR EACH ROW EXECUTE FUNCTION update_project_tsvector();\n\nCREATE TRIGGER project_text_trigger AFTER INSERT OR UPDATE OF title, description ON public.projects FOR EACH ROW EXECUTE FUNCTION update_project_tsvector();\n\n\n"
  },
  {
    "path": "supabase/migrations/20250617151457_update_notification_preferences.sql",
    "content": "alter table \"public\".\"notifications_preferences\" add column \"research_updates\" boolean not null;\n\n\n"
  },
  {
    "path": "supabase/migrations/20250621165139_user_research_filter_draft.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_user_research(username_param text)\n RETURNS TABLE(id bigint, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    r.id,\n    r.title,\n    r.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM research r\n  INNER JOIN profiles p ON p.id = r.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = r.id AND uv.content_type = 'research'\n  WHERE p.username = username_param\n  AND (r.deleted IS NULL OR r.deleted = FALSE)\n  AND (r.is_draft IS NULL OR r.is_draft = FALSE)\n  GROUP BY r.id, r.title, r.slug;\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20250624101144_replace_get_projects_count.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_projects_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, current_username text DEFAULT NULL::text)\n RETURNS integer\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    IF search_query IS NOT NULL THEN\n      ts_query := to_tsquery('english', search_query);\n    END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$function$;\n"
  },
  {
    "path": "supabase/migrations/20250624101145_replace_get_user_projects.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_user_projects(username_param text)\n RETURNS TABLE(id bigint, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    pr.id,\n    pr.title,\n    pr.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM projects pr\n  INNER JOIN profiles p ON p.id = pr.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = pr.id AND uv.content_type = 'projects'\n  WHERE p.username = username_param\n  AND (pr.deleted IS NULL OR pr.deleted = FALSE)\n  AND (pr.is_draft IS NULL OR pr.is_draft = FALSE)\n  AND (pr.moderation = \"accepted\")\n  GROUP BY pr.id, pr.title, pr.slug;\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20250624101147_replace_get_user_projects_again.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_user_projects(username_param text)\n RETURNS TABLE(id bigint, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    pr.id,\n    pr.title,\n    pr.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM projects pr\n  INNER JOIN profiles p ON p.id = pr.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = pr.id AND uv.content_type = 'projects'\n  WHERE p.username = username_param\n  AND (pr.deleted IS NULL OR pr.deleted = FALSE)\n  AND (pr.is_draft IS NULL OR pr.is_draft = FALSE)\n  AND (pr.moderation = 'accepted')\n  GROUP BY pr.id, pr.title, pr.slug;\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20250625162400_update_questions_news_is_draft.sql",
    "content": "alter table \"public\".\"news\" add column \"is_draft\" boolean not null default false;\n\nalter table \"public\".\"questions\" add column \"is_draft\" boolean not null default false;\n"
  },
  {
    "path": "supabase/migrations/20250702155642_add_unsubscribe_to_preferences.sql",
    "content": "alter table \"public\".\"notifications_preferences\" add column \"is_unsubscribed\" boolean not null default false;\n\n\n"
  },
  {
    "path": "supabase/migrations/20250722145300_profile.sql",
    "content": "create table \"public\".\"map_pins\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"profile_id\" bigint not null,\n    \"country\" text not null,\n    \"country_code\" text not null,\n    \"administrative\" text,\n    \"post_code\" text,\n    \"lat\" text not null,\n    \"lng\" text not null,\n    \"moderation\" text not null,\n    \"tenant_id\" text not null,\n    \"moderation_feedback\" text,\n    \"name\" text\n);\n\n\nalter table \"public\".\"map_pins\" enable row level security;\n\ncreate table \"public\".\"profile_tags\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"name\" text not null,\n    \"tenant_id\" text not null,\n    \"profile_type\" text\n);\n\nalter table \"public\".\"profile_tags\" enable row level security;\n\ncreate table \"public\".\"profile_tags_relations\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text),\n    \"profile_id\" bigint not null,\n    \"profile_tag_id\" bigint not null,\n    \"tenant_id\" text not null\n);\n\ncreate table \"public\".\"profile_badges\" (\n    \"id\" bigint generated by default as identity not null,\n    \"name\" text not null,\n    \"display_name\" text not null,\n    \"image_url\" text not null,\n    \"action_url\" text,\n    \"tenant_id\" text not null\n);\n\nalter table \"public\".\"profile_badges\" enable row level security;\n\ncreate table \"public\".\"profile_badges_relations\" (\n    \"id\" bigint generated by default as identity not null,\n    \"profile_id\" bigint not null,\n    \"profile_badge_id\" bigint not null,\n    \"tenant_id\" text not null\n);\n\nalter table \"public\".\"profile_tags_relations\" enable row level security;\n\ncreate table \"public\".\"profile_types\" (\n    \"id\" bigint generated by default as identity not null,\n    \"name\" text not null,\n    \"display_name\" text not null,\n    \"order\" smallint not null,\n    \"image_url\" text not null,\n    \"small_image_url\" text not null,\n    \"description\" text not null,\n    \"map_pin_name\" text not null,\n    \"is_space\" boolean not null,\n    \"tenant_id\" text not null\n);\n\nalter table \"public\".\"profile_types\" enable row level security;\n\ncreate table \"public\".\"map_settings\" (\n    \"id\" bigint generated by default as identity not null,\n    \"default_type_filters\" text[] null,\n    \"setting_filters\" text[] not null,\n    \"tenant_id\" text not null\n);\n\nalter table \"public\".\"map_settings\" enable row level security;\n\nalter table \"public\".\"profiles\" drop column \"links\";\n\nalter table \"public\".\"profiles\" drop column \"notification_settings\";\n\nalter table \"public\".\"profiles\" drop column \"notifications\";\n\nalter table \"public\".\"profiles\" drop column \"tags\";\n\nalter table \"public\".\"profiles\" drop column \"total_useful\";\n\nalter table \"public\".\"profiles\" drop column \"location\";\n\nalter table \"public\".\"profiles\" drop column \"is_verified\";\n\nalter table \"public\".\"profiles\" add column \"cover_images\" json[];\n\nalter table \"public\".\"profiles\" add column \"last_active\" timestamp with time zone;\n\nalter table \"public\".\"profiles\" add column \"photo\" json;\n\nalter table \"public\".\"profiles\" add column \"visitor_policy\" json;\n\nalter table \"public\".\"profiles\" add column \"website\" text;\n\nalter table \"public\".\"profiles\" add column \"profile_type\" bigint;\n\nCREATE INDEX map_pins_lat_lng_idx ON public.map_pins USING btree (lat, lng);\n\nCREATE UNIQUE INDEX map_pins_pkey ON public.map_pins USING btree (id);\n\nCREATE INDEX map_pins_user_id_idx ON public.map_pins USING btree (profile_id);\n\nCREATE UNIQUE INDEX profile_tags_pkey ON public.profile_tags USING btree (id);\n\nCREATE UNIQUE INDEX profile_tags_relations_pkey ON public.profile_tags_relations USING btree (id);\n\nCREATE UNIQUE INDEX profile_badges_pkey ON public.profile_badges USING btree (id);\n\nCREATE UNIQUE INDEX profile_badges_relations_pkey ON public.profile_badges_relations USING btree (id);\n\nCREATE UNIQUE INDEX profile_types_pkey ON public.profile_types USING btree (id);\nalter table \"public\".\"profile_types\" add constraint \"profile_types_pkey\" PRIMARY KEY using index \"profile_types_pkey\";\n\nCREATE UNIQUE INDEX map_settings_pkey ON public.map_settings USING btree (id);\nalter table \"public\".\"map_settings\" add constraint \"map_settings_pkey\" PRIMARY KEY using index \"map_settings_pkey\";\n\nalter table \"public\".\"profile_badges_relations\" enable row level security;\n\nalter table \"public\".\"map_pins\" add constraint \"map_pins_pkey\" PRIMARY KEY using index \"map_pins_pkey\";\n\nalter table \"public\".\"profile_tags\" add constraint \"profile_tags_pkey\" PRIMARY KEY using index \"profile_tags_pkey\";\n\nalter table \"public\".\"profile_tags_relations\" add constraint \"profile_tags_relations_pkey\" PRIMARY KEY using index \"profile_tags_relations_pkey\";\n\nalter table \"public\".\"profile_badges\" add constraint \"profile_badges_pkey\" PRIMARY KEY using index \"profile_badges_pkey\";\n\nalter table \"public\".\"profile_badges_relations\" add constraint \"profile_badges_relations_pkey\" PRIMARY KEY using index \"profile_badges_relations_pkey\";\n\nalter table \"public\".\"map_pins\" add constraint \"map_pins_user_id_fkey\" FOREIGN KEY (profile_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"map_pins\" validate constraint \"map_pins_user_id_fkey\";\n\nalter table \"public\".\"profile_tags_relations\" add constraint \"profile_tags_relations_profile_id_fkey\" FOREIGN KEY (profile_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"profile_tags_relations\" validate constraint \"profile_tags_relations_profile_id_fkey\";\n\nalter table \"public\".\"profile_tags_relations\" add constraint \"profile_tags_relations_profile_tag_id_fkey\" FOREIGN KEY (profile_tag_id) REFERENCES profile_tags(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"profile_tags_relations\" validate constraint \"profile_tags_relations_profile_tag_id_fkey\";\n\nalter table \"public\".\"profile_badges_relations\" add constraint \"profile_badges_relations_profile_id_fkey\" FOREIGN KEY (profile_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"profile_badges_relations\" validate constraint \"profile_badges_relations_profile_id_fkey\";\n\nalter table \"public\".\"profile_badges_relations\" add constraint \"profile_badges_relations_profile_badge_id_fkey\" FOREIGN KEY (profile_badge_id) REFERENCES profile_badges(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"profile_badges_relations\" validate constraint \"profile_badges_relations_profile_badge_id_fkey\";\n\nalter table \"public\".\"profiles\" add constraint \"profiles_profile_type_fkey\" FOREIGN KEY (profile_type) REFERENCES profile_types(id) ON UPDATE CASCADE ON DELETE SET NULL not valid;\n\nalter table \"public\".\"profiles\" validate constraint \"profiles_profile_type_fkey\";\n\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_author_vote_counts(author_id bigint)\n RETURNS TABLE(content_type text, vote_count bigint)\n LANGUAGE sql\n STABLE\nAS $function$\n    SELECT \n        uv.content_type,\n        COUNT(*) as vote_count\n    FROM useful_votes uv\n    WHERE (uv.content_type = 'questions' AND EXISTS (\n        SELECT 1 FROM questions q WHERE q.id = uv.content_id AND q.created_by = author_id\n    ))\n    OR (uv.content_type = 'projects' AND EXISTS (\n        SELECT 1 FROM projects p WHERE p.id = uv.content_id AND p.created_by = author_id\n    ))\n    OR (uv.content_type = 'news' AND EXISTS (\n        SELECT 1 FROM news n WHERE n.id = uv.content_id AND n.created_by = author_id\n    ))\n    OR (uv.content_type = 'research' AND EXISTS (\n        SELECT 1 FROM research r WHERE r.id = uv.content_id AND r.created_by = author_id\n    ))\n    GROUP BY uv.content_type\n    ORDER BY vote_count DESC;\n$function$\n;\n\ngrant delete on table \"public\".\"map_pins\" to \"anon\";\n\ngrant insert on table \"public\".\"map_pins\" to \"anon\";\n\ngrant references on table \"public\".\"map_pins\" to \"anon\";\n\ngrant select on table \"public\".\"map_pins\" to \"anon\";\n\ngrant trigger on table \"public\".\"map_pins\" to \"anon\";\n\ngrant truncate on table \"public\".\"map_pins\" to \"anon\";\n\ngrant update on table \"public\".\"map_pins\" to \"anon\";\n\ngrant delete on table \"public\".\"map_pins\" to \"authenticated\";\n\ngrant insert on table \"public\".\"map_pins\" to \"authenticated\";\n\ngrant references on table \"public\".\"map_pins\" to \"authenticated\";\n\ngrant select on table \"public\".\"map_pins\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"map_pins\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"map_pins\" to \"authenticated\";\n\ngrant update on table \"public\".\"map_pins\" to \"authenticated\";\n\ngrant delete on table \"public\".\"map_pins\" to \"service_role\";\n\ngrant insert on table \"public\".\"map_pins\" to \"service_role\";\n\ngrant references on table \"public\".\"map_pins\" to \"service_role\";\n\ngrant select on table \"public\".\"map_pins\" to \"service_role\";\n\ngrant trigger on table \"public\".\"map_pins\" to \"service_role\";\n\ngrant truncate on table \"public\".\"map_pins\" to \"service_role\";\n\ngrant update on table \"public\".\"map_pins\" to \"service_role\";\n\ngrant delete on table \"public\".\"profile_tags\" to \"anon\";\n\ngrant insert on table \"public\".\"profile_tags\" to \"anon\";\n\ngrant references on table \"public\".\"profile_tags\" to \"anon\";\n\ngrant select on table \"public\".\"profile_tags\" to \"anon\";\n\ngrant trigger on table \"public\".\"profile_tags\" to \"anon\";\n\ngrant truncate on table \"public\".\"profile_tags\" to \"anon\";\n\ngrant update on table \"public\".\"profile_tags\" to \"anon\";\n\ngrant delete on table \"public\".\"profile_tags\" to \"authenticated\";\n\ngrant insert on table \"public\".\"profile_tags\" to \"authenticated\";\n\ngrant references on table \"public\".\"profile_tags\" to \"authenticated\";\n\ngrant select on table \"public\".\"profile_tags\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"profile_tags\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"profile_tags\" to \"authenticated\";\n\ngrant update on table \"public\".\"profile_tags\" to \"authenticated\";\n\ngrant delete on table \"public\".\"profile_tags\" to \"service_role\";\n\ngrant insert on table \"public\".\"profile_tags\" to \"service_role\";\n\ngrant references on table \"public\".\"profile_tags\" to \"service_role\";\n\ngrant select on table \"public\".\"profile_tags\" to \"service_role\";\n\ngrant trigger on table \"public\".\"profile_tags\" to \"service_role\";\n\ngrant truncate on table \"public\".\"profile_tags\" to \"service_role\";\n\ngrant update on table \"public\".\"profile_tags\" to \"service_role\";\n\ngrant delete on table \"public\".\"profile_tags_relations\" to \"anon\";\n\ngrant insert on table \"public\".\"profile_tags_relations\" to \"anon\";\n\ngrant references on table \"public\".\"profile_tags_relations\" to \"anon\";\n\ngrant select on table \"public\".\"profile_tags_relations\" to \"anon\";\n\ngrant trigger on table \"public\".\"profile_tags_relations\" to \"anon\";\n\ngrant truncate on table \"public\".\"profile_tags_relations\" to \"anon\";\n\ngrant update on table \"public\".\"profile_tags_relations\" to \"anon\";\n\ngrant delete on table \"public\".\"profile_tags_relations\" to \"authenticated\";\n\ngrant insert on table \"public\".\"profile_tags_relations\" to \"authenticated\";\n\ngrant references on table \"public\".\"profile_tags_relations\" to \"authenticated\";\n\ngrant select on table \"public\".\"profile_tags_relations\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"profile_tags_relations\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"profile_tags_relations\" to \"authenticated\";\n\ngrant update on table \"public\".\"profile_tags_relations\" to \"authenticated\";\n\ngrant delete on table \"public\".\"profile_tags_relations\" to \"service_role\";\n\ngrant insert on table \"public\".\"profile_tags_relations\" to \"service_role\";\n\ngrant references on table \"public\".\"profile_tags_relations\" to \"service_role\";\n\ngrant select on table \"public\".\"profile_tags_relations\" to \"service_role\";\n\ngrant trigger on table \"public\".\"profile_tags_relations\" to \"service_role\";\n\ngrant truncate on table \"public\".\"profile_tags_relations\" to \"service_role\";\n\ngrant update on table \"public\".\"profile_badges_relations\" to \"service_role\";\n\ngrant delete on table \"public\".\"profile_badges\" to \"anon\";\n\ngrant insert on table \"public\".\"profile_badges\" to \"anon\";\n\ngrant references on table \"public\".\"profile_badges\" to \"anon\";\n\ngrant select on table \"public\".\"profile_badges\" to \"anon\";\n\ngrant trigger on table \"public\".\"profile_badges\" to \"anon\";\n\ngrant truncate on table \"public\".\"profile_badges\" to \"anon\";\n\ngrant update on table \"public\".\"profile_badges\" to \"anon\";\n\ngrant delete on table \"public\".\"profile_badges\" to \"authenticated\";\n\ngrant insert on table \"public\".\"profile_badges\" to \"authenticated\";\n\ngrant references on table \"public\".\"profile_badges\" to \"authenticated\";\n\ngrant select on table \"public\".\"profile_badges\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"profile_badges\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"profile_badges\" to \"authenticated\";\n\ngrant update on table \"public\".\"profile_badges\" to \"authenticated\";\n\ngrant delete on table \"public\".\"profile_badges\" to \"service_role\";\n\ngrant insert on table \"public\".\"profile_badges\" to \"service_role\";\n\ngrant references on table \"public\".\"profile_badges\" to \"service_role\";\n\ngrant select on table \"public\".\"profile_badges\" to \"service_role\";\n\ngrant trigger on table \"public\".\"profile_badges\" to \"service_role\";\n\ngrant truncate on table \"public\".\"profile_badges\" to \"service_role\";\n\ngrant update on table \"public\".\"profile_badges\" to \"service_role\";\n\ngrant delete on table \"public\".\"profile_badges_relations\" to \"anon\";\n\ngrant insert on table \"public\".\"profile_badges_relations\" to \"anon\";\n\ngrant references on table \"public\".\"profile_badges_relations\" to \"anon\";\n\ngrant select on table \"public\".\"profile_badges_relations\" to \"anon\";\n\ngrant trigger on table \"public\".\"profile_badges_relations\" to \"anon\";\n\ngrant truncate on table \"public\".\"profile_badges_relations\" to \"anon\";\n\ngrant update on table \"public\".\"profile_badges_relations\" to \"anon\";\n\ngrant delete on table \"public\".\"profile_badges_relations\" to \"authenticated\";\n\ngrant insert on table \"public\".\"profile_badges_relations\" to \"authenticated\";\n\ngrant references on table \"public\".\"profile_badges_relations\" to \"authenticated\";\n\ngrant select on table \"public\".\"profile_badges_relations\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"profile_badges_relations\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"profile_badges_relations\" to \"authenticated\";\n\ngrant update on table \"public\".\"profile_badges_relations\" to \"authenticated\";\n\ngrant delete on table \"public\".\"profile_badges_relations\" to \"service_role\";\n\ngrant insert on table \"public\".\"profile_badges_relations\" to \"service_role\";\n\ngrant references on table \"public\".\"profile_badges_relations\" to \"service_role\";\n\ngrant select on table \"public\".\"profile_badges_relations\" to \"service_role\";\n\ngrant trigger on table \"public\".\"profile_badges_relations\" to \"service_role\";\n\ngrant truncate on table \"public\".\"profile_badges_relations\" to \"service_role\";\n\ngrant update on table \"public\".\"profile_badges_relations\" to \"service_role\";\n\ngrant delete on table \"public\".\"profile_types\" to \"anon\";\n\ngrant insert on table \"public\".\"profile_types\" to \"anon\";\n\ngrant references on table \"public\".\"profile_types\" to \"anon\";\n\ngrant select on table \"public\".\"profile_types\" to \"anon\";\n\ngrant trigger on table \"public\".\"profile_types\" to \"anon\";\n\ngrant truncate on table \"public\".\"profile_types\" to \"anon\";\n\ngrant update on table \"public\".\"profile_types\" to \"anon\";\n\ngrant delete on table \"public\".\"profile_types\" to \"authenticated\";\n\ngrant insert on table \"public\".\"profile_types\" to \"authenticated\";\n\ngrant references on table \"public\".\"profile_types\" to \"authenticated\";\n\ngrant select on table \"public\".\"profile_types\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"profile_types\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"profile_types\" to \"authenticated\";\n\ngrant update on table \"public\".\"profile_types\" to \"authenticated\";\n\ngrant delete on table \"public\".\"profile_types\" to \"service_role\";\n\ngrant insert on table \"public\".\"profile_types\" to \"service_role\";\n\ngrant references on table \"public\".\"profile_types\" to \"service_role\";\n\ngrant select on table \"public\".\"profile_types\" to \"service_role\";\n\ngrant trigger on table \"public\".\"profile_types\" to \"service_role\";\n\ngrant truncate on table \"public\".\"profile_types\" to \"service_role\";\n\ngrant update on table \"public\".\"profile_types\" to \"service_role\";\n\n-- Missing grants for map_settings table\ngrant delete on table \"public\".\"map_settings\" to \"anon\";\n\ngrant insert on table \"public\".\"map_settings\" to \"anon\";\n\ngrant references on table \"public\".\"map_settings\" to \"anon\";\n\ngrant select on table \"public\".\"map_settings\" to \"anon\";\n\ngrant trigger on table \"public\".\"map_settings\" to \"anon\";\n\ngrant truncate on table \"public\".\"map_settings\" to \"anon\";\n\ngrant update on table \"public\".\"map_settings\" to \"anon\";\n\ngrant delete on table \"public\".\"map_settings\" to \"authenticated\";\n\ngrant insert on table \"public\".\"map_settings\" to \"authenticated\";\n\ngrant references on table \"public\".\"map_settings\" to \"authenticated\";\n\ngrant select on table \"public\".\"map_settings\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"map_settings\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"map_settings\" to \"authenticated\";\n\ngrant update on table \"public\".\"map_settings\" to \"authenticated\";\n\ngrant delete on table \"public\".\"map_settings\" to \"service_role\";\n\ngrant insert on table \"public\".\"map_settings\" to \"service_role\";\n\ngrant references on table \"public\".\"map_settings\" to \"service_role\";\n\ngrant select on table \"public\".\"map_settings\" to \"service_role\";\n\ngrant trigger on table \"public\".\"map_settings\" to \"service_role\";\n\ngrant truncate on table \"public\".\"map_settings\" to \"service_role\";\n\ngrant update on table \"public\".\"map_settings\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"map_pins\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_tags\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_tags_relations\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_badges\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_badges_relations\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_types\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\ncreate policy \"tenant_isolation\"\non \"public\".\"map_settings\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));\n\n\nalter table \"public\".\"profiles\" drop column \"photo_url\";\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer)\n LANGUAGE plpgsql\nAS $function$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided\n    IF search_query IS NOT NULL THEN\n        ts_query := to_tsquery('english', search_query);\n    END IF;\n    \n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.created_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.created_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status research_status DEFAULT NULL::research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint)\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', search_query);\n  END IF;\n \n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count\n  FROM research r\n  WHERE\n    (search_query IS NULL OR r.fts @@ ts_query) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nset check_function_bodies = off;\n"
  },
  {
    "path": "supabase/migrations/20250819103736_add_badge_id_to_news.sql",
    "content": "alter table \"public\".\"news\" add column \"profile_badge\" bigint;\n\nalter table \"public\".\"news\" add constraint \"news_profile_badge_fkey\" FOREIGN KEY (profile_badge) REFERENCES profile_badges(id) ON DELETE SET NULL not valid;\n\nalter table \"public\".\"news\" validate constraint \"news_profile_badge_fkey\";\n\n"
  },
  {
    "path": "supabase/migrations/20250824222054_fix-author-vote-count-deleted.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_author_vote_counts(author_id bigint)\n RETURNS TABLE(content_type text, vote_count bigint)\n LANGUAGE sql\n STABLE\nAS $function$\n    SELECT \n        uv.content_type,\n        COUNT(*) as vote_count\n    FROM useful_votes uv\n    WHERE (uv.content_type = 'questions' AND EXISTS (\n        SELECT 1 FROM questions q WHERE q.id = uv.content_id AND q.created_by = author_id and (q.deleted is null or q.deleted = false)\n    ))\n    OR (uv.content_type = 'projects' AND EXISTS (\n        SELECT 1 FROM projects p WHERE p.id = uv.content_id AND p.created_by = author_id and (p.deleted is null or p.deleted = false)\n    ))\n    OR (uv.content_type = 'news' AND EXISTS (\n        SELECT 1 FROM news n WHERE n.id = uv.content_id AND n.created_by = author_id and (n.deleted is null or n.deleted = false)\n    ))\n    OR (uv.content_type = 'research' AND EXISTS (\n        SELECT 1 FROM research r WHERE r.id = uv.content_id AND r.created_by = author_id and (r.deleted is null or r.deleted = false)\n    ))\n    GROUP BY uv.content_type\n    ORDER BY vote_count DESC;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20250830203011_banner.sql",
    "content": "create table \"public\".\"banners\" (\n    \"id\" bigint generated by default as identity not null,\n    \"created_at\" timestamp with time zone not null default now(),\n    \"modified_at\" timestamp with time zone,\n    \"text\" text not null,\n    \"url\" text,\n    \"tenant_id\" text not null\n);\n\n\nalter table \"public\".\"banners\" enable row level security;\n\nCREATE UNIQUE INDEX banner_pkey ON public.banners USING btree (id);\n\nalter table \"public\".\"banners\" add constraint \"banner_pkey\" PRIMARY KEY using index \"banner_pkey\";\n\ngrant delete on table \"public\".\"banners\" to \"anon\";\n\ngrant insert on table \"public\".\"banners\" to \"anon\";\n\ngrant references on table \"public\".\"banners\" to \"anon\";\n\ngrant select on table \"public\".\"banners\" to \"anon\";\n\ngrant trigger on table \"public\".\"banners\" to \"anon\";\n\ngrant truncate on table \"public\".\"banners\" to \"anon\";\n\ngrant update on table \"public\".\"banners\" to \"anon\";\n\ngrant delete on table \"public\".\"banners\" to \"authenticated\";\n\ngrant insert on table \"public\".\"banners\" to \"authenticated\";\n\ngrant references on table \"public\".\"banners\" to \"authenticated\";\n\ngrant select on table \"public\".\"banners\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"banners\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"banners\" to \"authenticated\";\n\ngrant update on table \"public\".\"banners\" to \"authenticated\";\n\ngrant delete on table \"public\".\"banners\" to \"service_role\";\n\ngrant insert on table \"public\".\"banners\" to \"service_role\";\n\ngrant references on table \"public\".\"banners\" to \"service_role\";\n\ngrant select on table \"public\".\"banners\" to \"service_role\";\n\ngrant trigger on table \"public\".\"banners\" to \"service_role\";\n\ngrant truncate on table \"public\".\"banners\" to \"service_role\";\n\ngrant update on table \"public\".\"banners\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"banners\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n"
  },
  {
    "path": "supabase/migrations/20250902101059_map_pins-optimizations.sql",
    "content": "CREATE INDEX idx_map_pins_moderation ON public.map_pins USING btree (moderation);\n\nCREATE INDEX idx_map_pins_moderation_tenant ON public.map_pins USING btree (moderation, tenant_id);\n\nCREATE INDEX idx_map_pins_tenant_id ON public.map_pins USING btree (tenant_id);\n\nCREATE INDEX idx_profile_badges_relations_badge_id ON public.profile_badges_relations USING btree (profile_badge_id);\n\nCREATE INDEX idx_profile_badges_relations_profile_id ON public.profile_badges_relations USING btree (profile_id);\n\nCREATE INDEX idx_profile_badges_relations_tenant_id ON public.profile_badges_relations USING btree (tenant_id);\n\nCREATE INDEX idx_profile_badges_tenant_id ON public.profile_badges USING btree (tenant_id);\n\nCREATE INDEX idx_profile_tags_relations_profile_id ON public.profile_tags_relations USING btree (profile_id);\n\nCREATE INDEX idx_profile_tags_relations_tag_id ON public.profile_tags_relations USING btree (profile_tag_id);\n\nCREATE INDEX idx_profile_tags_relations_tenant_id ON public.profile_tags_relations USING btree (tenant_id);\n\nCREATE INDEX idx_profile_tags_tenant_id ON public.profile_tags USING btree (tenant_id);\n\nCREATE INDEX idx_profile_types_tenant_id ON public.profile_types USING btree (tenant_id);\n\nCREATE INDEX idx_profiles_profile_type ON public.profiles USING btree (profile_type);\n\n\n"
  },
  {
    "path": "supabase/migrations/20250904101010_useful_comments.sql",
    "content": "DROP FUNCTION IF EXISTS public.get_useful_votes_count_by_content_id(p_content_type content_types, p_content_ids bigint[]);\n\nCREATE TYPE \"public\".\"useful_content_types\" AS ENUM ('questions', 'projects', 'research', 'news', 'comments');\n\nALTER TABLE \"public\".\"useful_votes\" \nALTER COLUMN content_type TYPE \"public\".\"useful_content_types\" \nUSING content_type::text::\"public\".\"useful_content_types\";\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_useful_votes_count_by_content_id(\n    p_content_type public.useful_content_types, \n    p_content_ids bigint[]\n)\nRETURNS TABLE(content_id bigint, count bigint)\nLANGUAGE plpgsql\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT v.content_id, COUNT(*) as count\n  FROM public.useful_votes v\n  WHERE v.content_type = p_content_type\n    AND v.content_id = ANY(p_content_ids)\n  GROUP BY v.content_id;\nEND;\n$function$;\n\nCREATE OR REPLACE FUNCTION public.get_comments_with_votes(p_source_type text, p_source_id bigint, p_current_user_id bigint DEFAULT NULL::bigint)\nRETURNS TABLE(id bigint, comment text, created_at timestamp with time zone, modified_at timestamp with time zone, deleted boolean, source_id bigint, source_type text, parent_id bigint, created_by bigint, profile json, vote_count bigint, has_voted boolean)\nLANGUAGE plpgsql AS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    c.id,\n    c.comment,\n    c.created_at,\n    c.modified_at,\n    c.deleted,\n    c.source_id,\n    c.source_type,\n    c.parent_id,\n    c.created_by,\n    -- Build the profile JSON structure\n    CASE \n      WHEN p.id IS NOT NULL THEN \n        json_build_object(\n          'id', p.id,\n          'display_name', p.display_name,\n          'username', p.username,\n          'photo', p.photo,\n          'country', p.country,\n          'badges', COALESCE(badges_agg.badges_array, '[]'::json)\n        )\n      ELSE NULL \n    END as profile,\n    -- Count of useful votes for this comment\n    COALESCE(vote_counts.vote_count, 0) as vote_count,\n    -- Whether current user has voted on this comment\n    CASE \n      WHEN p_current_user_id IS NOT NULL AND user_votes.content_id IS NOT NULL \n      THEN TRUE \n      ELSE FALSE \n    END as has_voted\n  FROM comments c\n  LEFT JOIN profiles p ON c.created_by = p.id\n  -- Aggregate badges for each profile\n  LEFT JOIN (\n    SELECT \n      pbr.profile_id,\n      json_agg(\n        json_build_object(\n          'id', pb.id,\n          'name', pb.name,\n          'display_name', pb.display_name,\n          'image_url', pb.image_url,\n          'action_url', pb.action_url\n        )\n      ) as badges_array\n    FROM profile_badges_relations pbr\n    JOIN profile_badges pb ON pbr.profile_badge_id = pb.id\n    GROUP BY pbr.profile_id\n  ) badges_agg ON p.id = badges_agg.profile_id\n  -- Count useful votes for each comment\n  LEFT JOIN (\n    SELECT \n      uv.content_id,\n      COUNT(*) as vote_count\n    FROM useful_votes uv\n    WHERE uv.content_type = 'comments'\n    GROUP BY uv.content_id\n  ) vote_counts ON c.id = vote_counts.content_id\n  -- Check if current user has voted on each comment\n  LEFT JOIN (\n    SELECT DISTINCT uv.content_id\n    FROM useful_votes uv\n    WHERE uv.content_type = 'comments'\n      AND uv.user_id = p_current_user_id\n  ) user_votes ON c.id = user_votes.content_id\n  WHERE \n    c.source_type = p_source_type\n    AND c.source_id = p_source_id\n  ORDER BY c.created_at ASC;\nEND;\n$function$;\n"
  },
  {
    "path": "supabase/migrations/20250912091153_fix-delete-comment-trigger.sql",
    "content": "drop trigger if exists \"update_comment_count\" on \"public\".\"comments\";\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.update_comment_count()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$BEGIN\n  IF (TG_OP = 'INSERT') THEN\n    IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL AND (NEW.deleted IS NULL OR NEW.deleted = false) THEN\n      IF NEW.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'research_update' THEN\n        UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'news' THEN\n        UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: source_type or source_id is NULL, or comment is deleted';\n    END IF;\n  ELSIF (TG_OP = 'UPDATE') THEN\n    IF (COALESCE(OLD.deleted, false) = false AND NEW.deleted = true) THEN\n      IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n        IF OLD.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'research_update' THEN\n          UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'news' THEN\n          UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n      END IF;\n    ELSIF (OLD.deleted = true AND COALESCE(NEW.deleted, false) = false) THEN\n      IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL THEN\n        IF NEW.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'research_update' THEN\n          UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'news' THEN\n          UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: NEW.source_type or NEW.source_id is NULL';\n      END IF;\n    END IF;\n  ELSIF (TG_OP = 'DELETE') THEN\n    IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n      IF OLD.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'research_update' THEN\n        UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'news' THEN\n        UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n    END IF;\n  END IF;\n  \n  RETURN NULL;\nEND;$function$\n;\n\nCREATE TRIGGER update_comment_count AFTER INSERT OR DELETE OR UPDATE ON public.comments FOR EACH ROW EXECUTE FUNCTION update_comment_count();\n\n\n"
  },
  {
    "path": "supabase/migrations/20250912110000_optimize_get_comments_with_votes.sql",
    "content": "DROP INDEX IF EXISTS public.comments_created_at_source_type_source_id_tenant_id_idx;\n\nCREATE INDEX IF NOT EXISTS comments_source_type_source_id_created_at_idx ON public.comments USING btree (source_type, source_id, created_at);\n\nCREATE INDEX IF NOT EXISTS useful_votes_comments_content_id_idx ON public.useful_votes USING btree (content_id)\nWHERE\n  content_type = 'comments';\n\nCREATE INDEX IF NOT EXISTS useful_votes_comments_user_id_content_id_idx ON public.useful_votes USING btree (user_id, content_id)\nWHERE\n  content_type = 'comments';"
  },
  {
    "path": "supabase/migrations/20250912210000_optimize_profile_indexes.sql",
    "content": "CREATE INDEX IF NOT EXISTS profiles_tenant_created_at_idx ON public.profiles (tenant_id, created_at DESC);\n\nCREATE INDEX IF NOT EXISTS projects_created_by_idx ON public.projects (created_by);\n\nCREATE INDEX IF NOT EXISTS research_created_by_idx ON public.research (created_by);\n\nCREATE INDEX IF NOT EXISTS research_updates_created_by_idx ON public.research_updates (created_by);\n\nCREATE INDEX IF NOT EXISTS questions_created_by_idx ON public.questions (created_by);\n\nCREATE INDEX IF NOT EXISTS useful_votes_content_type_content_id_idx ON public.useful_votes (content_type, content_id);\n\nCREATE INDEX IF NOT EXISTS profile_tags_relations_profile_tenant_idx ON public.profile_tags_relations (profile_id, tenant_id);\n\nCREATE INDEX IF NOT EXISTS profile_badges_relations_profile_tenant_idx ON public.profile_badges_relations (profile_id, tenant_id);"
  },
  {
    "path": "supabase/migrations/20250914104923_update_notifications.sql",
    "content": "alter table \"public\".\"notifications\" add column \"should_email\" boolean;\n\n\n"
  },
  {
    "path": "supabase/migrations/20251011132910_fix_messages_receiver.sql",
    "content": "alter table \"public\".\"messages\" drop constraint \"messages_from_fkey\";\n\nalter table \"public\".\"messages\" drop constraint \"messages_sender_id_fkey\";\n\nalter table \"public\".\"banners\" drop constraint \"banner_pkey\";\n\nalter table \"public\".\"questions\" drop constraint \"question_pkey\";\n\nalter table \"public\".\"research_updates\" drop constraint \"research_update_pkey\";\n\ndrop index if exists \"public\".\"banner_pkey\";\n\ndrop index if exists \"public\".\"question_pkey\";\n\ndrop index if exists \"public\".\"research_update_pkey\";\n\nalter type \"public\".\"content_types\" rename to \"content_types__old_version_to_be_dropped\";\n\ncreate type \"public\".\"content_types\" as enum ('questions', 'projects', 'research', 'news', 'comments');\n\nalter table \"public\".\"categories\" alter column type type \"public\".\"content_types\" using type::text::\"public\".\"content_types\";\n\ndrop type \"public\".\"content_types__old_version_to_be_dropped\";\n\nCREATE UNIQUE INDEX banners_pkey ON public.banners USING btree (id);\n\nCREATE UNIQUE INDEX questions_pkey ON public.questions USING btree (id);\n\nCREATE UNIQUE INDEX research_updates_pkey ON public.research_updates USING btree (id);\n\nalter table \"public\".\"banners\" add constraint \"banners_pkey\" PRIMARY KEY using index \"banners_pkey\";\n\nalter table \"public\".\"questions\" add constraint \"questions_pkey\" PRIMARY KEY using index \"questions_pkey\";\n\nalter table \"public\".\"research_updates\" add constraint \"research_updates_pkey\" PRIMARY KEY using index \"research_updates_pkey\";\n\nalter table \"public\".\"messages\" add constraint \"messages_receiver_fkey\" FOREIGN KEY (receiver_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"messages\" validate constraint \"messages_receiver_fkey\";\n\nalter table \"public\".\"messages\" add constraint \"messages_sender_fkey\" FOREIGN KEY (sender_id) REFERENCES profiles(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"messages\" validate constraint \"messages_sender_fkey\";\n\n\n"
  },
  {
    "path": "supabase/migrations/20251011233154_fix_policy_performance.sql",
    "content": "drop policy \"tenant_isolation\" on \"public\".\"categories\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"comments\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"map_pins\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"map_settings\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"messages\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"news\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"notifications\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"notifications_preferences\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"patreon_settings\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_badges\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_badges_relations\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_tags\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_tags_relations\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_types\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profiles\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"project_steps\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"projects\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"questions\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"research\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"research_updates\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"subscribers\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"tags\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"tenant_settings\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"useful_votes\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"categories\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"comments\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"map_pins\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"map_settings\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"messages\"\nas permissive\nfor all\nto authenticated\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"news\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"notifications\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"notifications_preferences\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"patreon_settings\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_badges\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_badges_relations\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_tags\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_tags_relations\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_types\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profiles\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"project_steps\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"projects\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"questions\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"research\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"research_updates\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"subscribers\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"tags\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"tenant_settings\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"useful_votes\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ( SELECT ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text))));\n\n\n\n"
  },
  {
    "path": "supabase/migrations/20251011234304_fix_function_security_warning.sql",
    "content": "drop function if exists \"public\".\"comment_authors_by_source_id_legacy\"(source_id_legacy_input text);\n\nCREATE OR REPLACE FUNCTION \"public\".\"comment_authors_by_source_id\"(\"source_id_input\" bigint) RETURNS SETOF \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $$\n  SELECT DISTINCT (p.username)\n  FROM comments c\n  INNER JOIN profiles p\n  ON c.created_by = p.id\n  WHERE c.source_id = source_id_input\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_comments_with_votes\"(\"p_source_type\" \"text\", \"p_source_id\" bigint, \"p_current_user_id\" bigint DEFAULT NULL::bigint) RETURNS TABLE(\"id\" bigint, \"comment\" \"text\", \"created_at\" timestamp with time zone, \"modified_at\" timestamp with time zone, \"deleted\" boolean, \"source_id\" bigint, \"source_type\" \"text\", \"parent_id\" bigint, \"created_by\" bigint, \"profile\" \"json\", \"vote_count\" bigint, \"has_voted\" boolean)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  RETURN QUERY\n  SELECT \n    c.id,\n    c.comment,\n    c.created_at,\n    c.modified_at,\n    c.deleted,\n    c.source_id,\n    c.source_type,\n    c.parent_id,\n    c.created_by,\n    -- Build the profile JSON structure\n    CASE \n      WHEN p.id IS NOT NULL THEN \n        json_build_object(\n          'id', p.id,\n          'display_name', p.display_name,\n          'username', p.username,\n          'photo', p.photo,\n          'country', p.country,\n          'badges', COALESCE(badges_agg.badges_array, '[]'::json)\n        )\n      ELSE NULL \n    END as profile,\n    -- Count of useful votes for this comment\n    COALESCE(vote_counts.vote_count, 0) as vote_count,\n    -- Whether current user has voted on this comment\n    CASE \n      WHEN p_current_user_id IS NOT NULL AND user_votes.content_id IS NOT NULL \n      THEN TRUE \n      ELSE FALSE \n    END as has_voted\n  FROM comments c\n  LEFT JOIN profiles p ON c.created_by = p.id\n  -- Aggregate badges for each profile\n  LEFT JOIN (\n    SELECT \n      pbr.profile_id,\n      json_agg(\n        json_build_object(\n          'id', pb.id,\n          'name', pb.name,\n          'display_name', pb.display_name,\n          'image_url', pb.image_url,\n          'action_url', pb.action_url\n        )\n      ) as badges_array\n    FROM profile_badges_relations pbr\n    JOIN profile_badges pb ON pbr.profile_badge_id = pb.id\n    GROUP BY pbr.profile_id\n  ) badges_agg ON p.id = badges_agg.profile_id\n  -- Count useful votes for each comment\n  LEFT JOIN (\n    SELECT \n      uv.content_id,\n      COUNT(*) as vote_count\n    FROM useful_votes uv\n    WHERE uv.content_type = 'comments'\n    GROUP BY uv.content_id\n  ) vote_counts ON c.id = vote_counts.content_id\n  -- Check if current user has voted on each comment\n  LEFT JOIN (\n    SELECT DISTINCT uv.content_id\n    FROM useful_votes uv\n    WHERE uv.content_type = 'comments'\n      AND uv.user_id = p_current_user_id\n  ) user_votes ON c.id = user_votes.content_id\n  WHERE \n    c.source_type = p_source_type\n    AND c.source_id = p_source_id\n  ORDER BY c.created_at ASC;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"update_comment_count\"() RETURNS \"trigger\"\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$BEGIN\n  IF (TG_OP = 'INSERT') THEN\n    IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL AND (NEW.deleted IS NULL OR NEW.deleted = false) THEN\n      IF NEW.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'research_update' THEN\n        UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'news' THEN\n        UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: source_type or source_id is NULL, or comment is deleted';\n    END IF;\n  ELSIF (TG_OP = 'UPDATE') THEN\n    IF (COALESCE(OLD.deleted, false) = false AND NEW.deleted = true) THEN\n      IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n        IF OLD.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'research_update' THEN\n          UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'news' THEN\n          UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n      END IF;\n    ELSIF (OLD.deleted = true AND COALESCE(NEW.deleted, false) = false) THEN\n      IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL THEN\n        IF NEW.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'research_update' THEN\n          UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'news' THEN\n          UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: NEW.source_type or NEW.source_id is NULL';\n      END IF;\n    END IF;\n  ELSIF (TG_OP = 'DELETE') THEN\n    IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n      IF OLD.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'research_update' THEN\n        UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'news' THEN\n        UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n    END IF;\n  END IF;\n  \n  RETURN NULL;\nEND;$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_email_by_id\"(\"id\" \"uuid\") RETURNS TABLE(\"email\" character varying)\n    LANGUAGE \"plpgsql\" SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.id = $1;\nEND;\n$_$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_email_by_profile_id\"(\"id\" bigint) RETURNS TABLE(\"email\" character varying)\n    LANGUAGE \"plpgsql\" SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au inner join public.profiles p on au.id = p.auth_id WHERE p.id = $1;\nEND;\n$_$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_email_by_username\"(\"username\" \"text\") RETURNS TABLE(\"email\" character varying)\n    LANGUAGE \"plpgsql\" SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.raw_user_meta_data->>'username' = $1;\nEND;\n$_$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_id_by_email\"(\"email\" \"text\") RETURNS TABLE(\"id\" \"uuid\")\n    LANGUAGE \"plpgsql\" SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$BEGIN\n  RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;\nEND;$_$;\n\n\nCREATE OR REPLACE FUNCTION \"public\".\"news_search_fields\"(\"public\".\"news\") RETURNS \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $_$\n  SELECT $1.title || ' ' || $1.body;\n$_$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"is_username_available\"(\"username\" \"text\") RETURNS boolean\n    LANGUAGE \"sql\" STABLE SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$\n  SELECT NOT EXISTS (SELECT 1 FROM profiles WHERE username = $1);\n$_$;\n\n\nCREATE OR REPLACE FUNCTION \"public\".\"combined_project_search_fields\"(\"project_id_param\" bigint) RETURNS \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $$\n  SELECT\n    (SELECT p.title || ' ' || p.description FROM projects p WHERE p.id = project_id_param) || ' ' ||\n    COALESCE(string_agg(ps.title || ' ' || ps.description, ' '), '')\n  FROM project_steps ps\n  WHERE ps.project_id = project_id_param;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_projects\"(\"search_query\" \"text\" DEFAULT NULL::\"text\", \"category_id\" bigint DEFAULT NULL::bigint, \"sort_by\" \"text\" DEFAULT 'Newest'::\"text\", \"limit_val\" integer DEFAULT 12, \"offset_val\" integer DEFAULT 0, \"current_username\" \"text\" DEFAULT NULL::\"text\") RETURNS TABLE(\"id\" bigint, \"created_at\" timestamp with time zone, \"created_by\" bigint, \"modified_at\" timestamp with time zone, \"description\" \"text\", \"slug\" \"text\", \"cover_image\" \"json\", \"category\" \"json\", \"tags\" \"text\"[], \"title\" \"text\", \"moderation\" \"text\", \"total_views\" bigint, \"author\" \"json\", \"comment_count\" integer)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided\n    IF search_query IS NOT NULL THEN\n        ts_query := to_tsquery('english', search_query);\n    END IF;\n    \n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.created_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.created_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_projects_count\"(\"search_query\" \"text\" DEFAULT NULL::\"text\", \"category_id\" integer DEFAULT NULL::integer, \"current_username\" \"text\" DEFAULT NULL::\"text\") RETURNS integer\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    IF search_query IS NOT NULL THEN\n      ts_query := to_tsquery('english', search_query);\n    END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_projects\"(\"username_param\" \"text\") RETURNS TABLE(\"id\" bigint, \"title\" \"text\", \"slug\" \"text\", \"total_useful\" bigint)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  RETURN QUERY\n  SELECT \n    pr.id,\n    pr.title,\n    pr.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM projects pr\n  INNER JOIN profiles p ON p.id = pr.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = pr.id AND uv.content_type = 'projects'\n  WHERE p.username = username_param\n  AND (pr.deleted IS NULL OR pr.deleted = FALSE)\n  AND (pr.is_draft IS NULL OR pr.is_draft = FALSE)\n  AND (pr.moderation = 'accepted')\n  GROUP BY pr.id, pr.title, pr.slug;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"update_project_tsvector\"() RETURNS \"trigger\"\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  IF TG_TABLE_NAME = 'project_steps' THEN\n    UPDATE projects\n    SET fts = to_tsvector('english', public.combined_project_search_fields(NEW.project_id))\n    WHERE id = NEW.project_id;\n  ELSEIF TG_TABLE_NAME = 'projects' THEN\n    UPDATE projects\n    SET fts = to_tsvector('english', public.combined_project_search_fields(NEW.id))\n    WHERE id = NEW.id;\n  END IF;\n  RETURN NEW;\nEND;\n$$;\n\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_questions\"(\"username_param\" \"text\") RETURNS TABLE(\"id\" bigint, \"title\" \"text\", \"slug\" \"text\", \"total_useful\" bigint)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  RETURN QUERY\n  SELECT \n    q.id,\n    q.title,\n    q.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM questions q\n  INNER JOIN profiles p ON p.id = q.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = q.id AND uv.content_type = 'questions'\n  WHERE p.username = username_param\n  AND (q.deleted IS NULL OR q.deleted = FALSE)\n  GROUP BY q.id, q.title, q.slug;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"questions_search_fields\"(\"public\".\"questions\") RETURNS \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $_$\n  SELECT $1.title || ' ' || $1.description;\n$_$;\n\n\nCREATE OR REPLACE FUNCTION \"public\".\"combined_research_search_fields\"(\"research_id_param\" bigint) RETURNS \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $$\n  SELECT\n    (SELECT r.title || ' ' || r.description FROM research r WHERE r.id = research_id_param) || ' ' ||\n    COALESCE(string_agg(ru.title || ' ' || ru.description, ' '), '')\n  FROM research_updates ru\n  WHERE ru.research_id = research_id_param;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_research\"(\"search_query\" \"text\" DEFAULT NULL::\"text\", \"category_id\" bigint DEFAULT NULL::bigint, \"research_status\" \"public\".\"research_status\" DEFAULT NULL::\"public\".\"research_status\", \"sort_by\" \"text\" DEFAULT 'Newest'::\"text\", \"limit_val\" integer DEFAULT 10, \"offset_val\" integer DEFAULT 0) RETURNS TABLE(\"id\" bigint, \"created_at\" timestamp with time zone, \"created_by\" bigint, \"modified_at\" timestamp with time zone, \"description\" \"text\", \"slug\" \"text\", \"image\" \"json\", \"status\" \"public\".\"research_status\", \"category\" \"json\", \"tags\" \"text\"[], \"title\" \"text\", \"total_views\" integer, \"author\" \"json\", \"update_count\" bigint, \"comment_count\" bigint)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', search_query);\n  END IF;\n \n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count\n  FROM research r\n  WHERE\n    (search_query IS NULL OR r.fts @@ ts_query) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_research_count\"(\"search_query\" \"text\" DEFAULT NULL::\"text\", \"category_id\" integer DEFAULT NULL::integer, \"research_status\" \"public\".\"research_status\" DEFAULT NULL::\"public\".\"research_status\") RETURNS integer\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  RETURN (\n    SELECT COUNT(*)\n    FROM research r\n    WHERE\n      (search_query IS NULL OR r.fts @@ to_tsquery('english', search_query)) AND\n      (category_id IS NULL OR r.category = category_id) AND\n      (research_status IS NULL OR r.status = research_status) AND\n      (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n      (r.deleted IS NULL OR r.deleted = FALSE)\n  );\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_research\"(\"username_param\" \"text\") RETURNS TABLE(\"id\" bigint, \"title\" \"text\", \"slug\" \"text\", \"total_useful\" bigint)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  RETURN QUERY\n  SELECT \n    r.id,\n    r.title,\n    r.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM research r\n  INNER JOIN profiles p ON p.id = r.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = r.id AND uv.content_type = 'research'\n  WHERE p.username = username_param\n  AND (r.deleted IS NULL OR r.deleted = FALSE)\n  AND (r.is_draft IS NULL OR r.is_draft = FALSE)\n  GROUP BY r.id, r.title, r.slug;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"update_research_tsvector\"() RETURNS \"trigger\"\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  IF TG_TABLE_NAME = 'research_updates' THEN\n    UPDATE research\n    SET fts = to_tsvector('english', public.combined_research_search_fields(NEW.research_id))\n    WHERE id = NEW.research_id;\n  ELSEIF TG_TABLE_NAME = 'research' THEN\n    UPDATE research\n    SET fts = to_tsvector('english', public.combined_research_search_fields(NEW.id))\n    WHERE id = NEW.id;\n  END IF;\n  RETURN NEW;\nEND;\n$$;\n\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_author_vote_counts\"(\"author_id\" bigint) RETURNS TABLE(\"content_type\" \"text\", \"vote_count\" bigint)\n    LANGUAGE \"sql\" STABLE\n    SET search_path = public, pg_temp\n    AS $$\n    SELECT \n        uv.content_type,\n        COUNT(*) as vote_count\n    FROM useful_votes uv\n    WHERE (uv.content_type = 'questions' AND EXISTS (\n        SELECT 1 FROM questions q WHERE q.id = uv.content_id AND q.created_by = author_id and (q.deleted is null or q.deleted = false)\n    ))\n    OR (uv.content_type = 'projects' AND EXISTS (\n        SELECT 1 FROM projects p WHERE p.id = uv.content_id AND p.created_by = author_id and (p.deleted is null or p.deleted = false)\n    ))\n    OR (uv.content_type = 'news' AND EXISTS (\n        SELECT 1 FROM news n WHERE n.id = uv.content_id AND n.created_by = author_id and (n.deleted is null or n.deleted = false)\n    ))\n    OR (uv.content_type = 'research' AND EXISTS (\n        SELECT 1 FROM research r WHERE r.id = uv.content_id AND r.created_by = author_id and (r.deleted is null or r.deleted = false)\n    ))\n    GROUP BY uv.content_type\n    ORDER BY vote_count DESC;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_useful_votes_count_by_content_id\"(\"p_content_type\" \"public\".\"useful_content_types\", \"p_content_ids\" bigint[]) RETURNS TABLE(\"content_id\" bigint, \"count\" bigint)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  RETURN QUERY\n  SELECT v.content_id, COUNT(*) as count\n  FROM public.useful_votes v\n  WHERE v.content_type = p_content_type\n    AND v.content_id = ANY(p_content_ids)\n  GROUP BY v.content_id;\nEND;\n$$;\n"
  },
  {
    "path": "supabase/migrations/20251012135652_fix_policy_performance_2.sql",
    "content": "drop policy \"tenant_isolation\" on \"public\".\"banners\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"categories\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"comments\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"map_pins\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"map_settings\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"messages\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"news\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"notifications\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"notifications_preferences\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"patreon_settings\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_badges\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_badges_relations\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_tags\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_tags_relations\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profile_types\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"profiles\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"project_steps\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"projects\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"questions\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"research\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"research_updates\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"subscribers\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"tags\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"tenant_settings\";\n\ndrop policy \"tenant_isolation\" on \"public\".\"useful_votes\";\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.combined_project_search_fields(project_id_param bigint)\n RETURNS text\n LANGUAGE sql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\n  SELECT\n    (SELECT p.title || ' ' || p.description FROM projects p WHERE p.id = project_id_param) || ' ' ||\n    COALESCE(string_agg(ps.title || ' ' || ps.description, ' '), '')\n  FROM project_steps ps\n  WHERE ps.project_id = project_id_param;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.combined_research_search_fields(research_id_param bigint)\n RETURNS text\n LANGUAGE sql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\n  SELECT\n    (SELECT r.title || ' ' || r.description FROM research r WHERE r.id = research_id_param) || ' ' ||\n    COALESCE(string_agg(ru.title || ' ' || ru.description, ' '), '')\n  FROM research_updates ru\n  WHERE ru.research_id = research_id_param;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.comment_authors_by_source_id(source_id_input bigint)\n RETURNS SETOF text\n LANGUAGE sql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\n  SELECT DISTINCT (p.username)\n  FROM comments c\n  INNER JOIN profiles p\n  ON c.created_by = p.id\n  WHERE c.source_id = source_id_input\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_author_vote_counts(author_id bigint)\n RETURNS TABLE(content_type text, vote_count bigint)\n LANGUAGE sql\n STABLE\n SET search_path TO 'public', 'pg_temp'\nAS $function$\n    SELECT \n        uv.content_type,\n        COUNT(*) as vote_count\n    FROM useful_votes uv\n    WHERE (uv.content_type = 'questions' AND EXISTS (\n        SELECT 1 FROM questions q WHERE q.id = uv.content_id AND q.created_by = author_id and (q.deleted is null or q.deleted = false)\n    ))\n    OR (uv.content_type = 'projects' AND EXISTS (\n        SELECT 1 FROM projects p WHERE p.id = uv.content_id AND p.created_by = author_id and (p.deleted is null or p.deleted = false)\n    ))\n    OR (uv.content_type = 'news' AND EXISTS (\n        SELECT 1 FROM news n WHERE n.id = uv.content_id AND n.created_by = author_id and (n.deleted is null or n.deleted = false)\n    ))\n    OR (uv.content_type = 'research' AND EXISTS (\n        SELECT 1 FROM research r WHERE r.id = uv.content_id AND r.created_by = author_id and (r.deleted is null or r.deleted = false)\n    ))\n    GROUP BY uv.content_type\n    ORDER BY vote_count DESC;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_comments_with_votes(p_source_type text, p_source_id bigint, p_current_user_id bigint DEFAULT NULL::bigint)\n RETURNS TABLE(id bigint, comment text, created_at timestamp with time zone, modified_at timestamp with time zone, deleted boolean, source_id bigint, source_type text, parent_id bigint, created_by bigint, profile json, vote_count bigint, has_voted boolean)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    c.id,\n    c.comment,\n    c.created_at,\n    c.modified_at,\n    c.deleted,\n    c.source_id,\n    c.source_type,\n    c.parent_id,\n    c.created_by,\n    -- Build the profile JSON structure\n    CASE \n      WHEN p.id IS NOT NULL THEN \n        json_build_object(\n          'id', p.id,\n          'display_name', p.display_name,\n          'username', p.username,\n          'photo', p.photo,\n          'country', p.country,\n          'badges', COALESCE(badges_agg.badges_array, '[]'::json)\n        )\n      ELSE NULL \n    END as profile,\n    -- Count of useful votes for this comment\n    COALESCE(vote_counts.vote_count, 0) as vote_count,\n    -- Whether current user has voted on this comment\n    CASE \n      WHEN p_current_user_id IS NOT NULL AND user_votes.content_id IS NOT NULL \n      THEN TRUE \n      ELSE FALSE \n    END as has_voted\n  FROM comments c\n  LEFT JOIN profiles p ON c.created_by = p.id\n  -- Aggregate badges for each profile\n  LEFT JOIN (\n    SELECT \n      pbr.profile_id,\n      json_agg(\n        json_build_object(\n          'id', pb.id,\n          'name', pb.name,\n          'display_name', pb.display_name,\n          'image_url', pb.image_url,\n          'action_url', pb.action_url\n        )\n      ) as badges_array\n    FROM profile_badges_relations pbr\n    JOIN profile_badges pb ON pbr.profile_badge_id = pb.id\n    GROUP BY pbr.profile_id\n  ) badges_agg ON p.id = badges_agg.profile_id\n  -- Count useful votes for each comment\n  LEFT JOIN (\n    SELECT \n      uv.content_id,\n      COUNT(*) as vote_count\n    FROM useful_votes uv\n    WHERE uv.content_type = 'comments'\n    GROUP BY uv.content_id\n  ) vote_counts ON c.id = vote_counts.content_id\n  -- Check if current user has voted on each comment\n  LEFT JOIN (\n    SELECT DISTINCT uv.content_id\n    FROM useful_votes uv\n    WHERE uv.content_type = 'comments'\n      AND uv.user_id = p_current_user_id\n  ) user_votes ON c.id = user_votes.content_id\n  WHERE \n    c.source_type = p_source_type\n    AND c.source_id = p_source_id\n  ORDER BY c.created_at ASC;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided\n    IF search_query IS NOT NULL THEN\n        ts_query := to_tsquery('english', search_query);\n    END IF;\n    \n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.created_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.created_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_projects_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, current_username text DEFAULT NULL::text)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    IF search_query IS NOT NULL THEN\n      ts_query := to_tsquery('english', search_query);\n    END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status research_status DEFAULT NULL::research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', search_query);\n  END IF;\n \n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count\n  FROM research r\n  WHERE\n    (search_query IS NULL OR r.fts @@ ts_query) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, research_status research_status DEFAULT NULL::research_status)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN (\n    SELECT COUNT(*)\n    FROM research r\n    WHERE\n      (search_query IS NULL OR r.fts @@ to_tsquery('english', search_query)) AND\n      (category_id IS NULL OR r.category = category_id) AND\n      (research_status IS NULL OR r.status = research_status) AND\n      (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n      (r.deleted IS NULL OR r.deleted = FALSE)\n  );\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_useful_votes_count_by_content_id(p_content_type useful_content_types, p_content_ids bigint[])\n RETURNS TABLE(content_id bigint, count bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT v.content_id, COUNT(*) as count\n  FROM public.useful_votes v\n  WHERE v.content_type = p_content_type\n    AND v.content_id = ANY(p_content_ids)\n  GROUP BY v.content_id;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_email_by_id(id uuid)\n RETURNS TABLE(email character varying)\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.id = $1;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_email_by_profile_id(id bigint)\n RETURNS TABLE(email character varying)\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au inner join public.profiles p on au.id = p.auth_id WHERE p.id = $1;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_email_by_username(username text)\n RETURNS TABLE(email character varying)\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.raw_user_meta_data->>'username' = $1;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_id_by_email(email text)\n RETURNS TABLE(id uuid)\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public', 'pg_temp'\nAS $function$BEGIN\n  RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_projects(username_param text)\n RETURNS TABLE(id bigint, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    pr.id,\n    pr.title,\n    pr.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM projects pr\n  INNER JOIN profiles p ON p.id = pr.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = pr.id AND uv.content_type = 'projects'\n  WHERE p.username = username_param\n  AND (pr.deleted IS NULL OR pr.deleted = FALSE)\n  AND (pr.is_draft IS NULL OR pr.is_draft = FALSE)\n  AND (pr.moderation = 'accepted')\n  GROUP BY pr.id, pr.title, pr.slug;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_questions(username_param text)\n RETURNS TABLE(id bigint, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    q.id,\n    q.title,\n    q.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM questions q\n  INNER JOIN profiles p ON p.id = q.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = q.id AND uv.content_type = 'questions'\n  WHERE p.username = username_param\n  AND (q.deleted IS NULL OR q.deleted = FALSE)\n  GROUP BY q.id, q.title, q.slug;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_user_research(username_param text)\n RETURNS TABLE(id bigint, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    r.id,\n    r.title,\n    r.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM research r\n  INNER JOIN profiles p ON p.id = r.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = r.id AND uv.content_type = 'research'\n  WHERE p.username = username_param\n  AND (r.deleted IS NULL OR r.deleted = FALSE)\n  AND (r.is_draft IS NULL OR r.is_draft = FALSE)\n  GROUP BY r.id, r.title, r.slug;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.is_username_available(username text)\n RETURNS boolean\n LANGUAGE sql\n STABLE SECURITY DEFINER\n SET search_path TO 'public', 'pg_temp'\nAS $function$\n  SELECT NOT EXISTS (SELECT 1 FROM profiles WHERE username = $1);\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.news_search_fields(news)\n RETURNS text\n LANGUAGE sql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\n  SELECT $1.title || ' ' || $1.body;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.questions_search_fields(questions)\n RETURNS text\n LANGUAGE sql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\n  SELECT $1.title || ' ' || $1.description;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.update_comment_count()\n RETURNS trigger\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$BEGIN\n  IF (TG_OP = 'INSERT') THEN\n    IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL AND (NEW.deleted IS NULL OR NEW.deleted = false) THEN\n      IF NEW.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'research_update' THEN\n        UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'news' THEN\n        UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: source_type or source_id is NULL, or comment is deleted';\n    END IF;\n  ELSIF (TG_OP = 'UPDATE') THEN\n    IF (COALESCE(OLD.deleted, false) = false AND NEW.deleted = true) THEN\n      IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n        IF OLD.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'research_update' THEN\n          UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'news' THEN\n          UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n      END IF;\n    ELSIF (OLD.deleted = true AND COALESCE(NEW.deleted, false) = false) THEN\n      IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL THEN\n        IF NEW.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'research_update' THEN\n          UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'news' THEN\n          UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: NEW.source_type or NEW.source_id is NULL';\n      END IF;\n    END IF;\n  ELSIF (TG_OP = 'DELETE') THEN\n    IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n      IF OLD.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'research_update' THEN\n        UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'news' THEN\n        UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n    END IF;\n  END IF;\n  \n  RETURN NULL;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.update_project_tsvector()\n RETURNS trigger\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  IF TG_TABLE_NAME = 'project_steps' THEN\n    UPDATE projects\n    SET fts = to_tsvector('english', public.combined_project_search_fields(NEW.project_id))\n    WHERE id = NEW.project_id;\n  ELSEIF TG_TABLE_NAME = 'projects' THEN\n    UPDATE projects\n    SET fts = to_tsvector('english', public.combined_project_search_fields(NEW.id))\n    WHERE id = NEW.id;\n  END IF;\n  RETURN NEW;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.update_research_tsvector()\n RETURNS trigger\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  IF TG_TABLE_NAME = 'research_updates' THEN\n    UPDATE research\n    SET fts = to_tsvector('english', public.combined_research_search_fields(NEW.research_id))\n    WHERE id = NEW.research_id;\n  ELSEIF TG_TABLE_NAME = 'research' THEN\n    UPDATE research\n    SET fts = to_tsvector('english', public.combined_research_search_fields(NEW.id))\n    WHERE id = NEW.id;\n  END IF;\n  RETURN NEW;\nEND;\n$function$\n;\n\ncreate policy \"tenant_isolation\"\non \"public\".\"banners\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"categories\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"comments\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"map_pins\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"map_settings\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"messages\"\nas permissive\nfor all\nto authenticated\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"news\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"notifications\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"notifications_preferences\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"patreon_settings\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_badges\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_badges_relations\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_tags\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_tags_relations\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profile_types\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"profiles\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"project_steps\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"projects\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"questions\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"research\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"research_updates\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"subscribers\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"tags\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"tenant_settings\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\ncreate policy \"tenant_isolation\"\non \"public\".\"useful_votes\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\n\n"
  },
  {
    "path": "supabase/migrations/20251024140004_fix_get_project_count.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_projects_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, current_username text DEFAULT NULL::text)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    IF search_query IS NOT NULL THEN\n      ts_query := to_tsquery('english', search_query);\n    END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (search_query IS NULL OR p.fts @@ to_tsquery('english', search_query)) AND\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$function$\n;\n\n\n"
  },
  {
    "path": "supabase/migrations/20251025145021_add_most_views_sort.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided\n    IF search_query IS NOT NULL THEN\n        ts_query := to_tsquery('english', search_query);\n    END IF;\n    \n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.created_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            WHEN sort_by = 'MostViews' THEN p.total_views\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.created_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$function$\n;\n\n\n"
  },
  {
    "path": "supabase/migrations/20251031045802_add_favicon.sql",
    "content": "alter table \"public\".\"tenant_settings\" add column \"site_favicon\" text;\n\n\n"
  },
  {
    "path": "supabase/migrations/20251119072930_track_badges.sql",
    "content": "alter table \"public\".\"profile_badges_relations\" add column \"created_at\" timestamp with time zone not null default (now() AT TIME ZONE 'utc'::text);\n\n\n"
  },
  {
    "path": "supabase/migrations/20251124054625_profile_donations.sql",
    "content": "alter table \"public\".\"profiles\" add column \"donations_enabled\" boolean not null default false;\n\nalter table \"public\".\"tenant_settings\" add column \"donation_settings\" json;\n"
  },
  {
    "path": "supabase/migrations/20251130000401_create_upgrade_badge_table.sql",
    "content": "create table \"public\".\"upgrade_badge\" (\n    \"id\" bigint generated by default as identity not null,\n    \"action_label\" text not null,\n    \"badge_id\" bigint not null,\n    \"is_space\" boolean not null,\n    \"action_url\" text not null,\n    \"tenant_id\" text not null\n);\n\n\nalter table \"public\".\"upgrade_badge\" enable row level security;\n\nCREATE INDEX idx_upgrade_badge_is_space ON public.upgrade_badge USING btree (is_space);\n\nCREATE INDEX idx_upgrade_badge_tenant_id ON public.upgrade_badge USING btree (tenant_id);\n\nCREATE UNIQUE INDEX upgrade_badge_pkey ON public.upgrade_badge USING btree (id);\n\nalter table \"public\".\"upgrade_badge\" add constraint \"upgrade_badge_pkey\" PRIMARY KEY using index \"upgrade_badge_pkey\";\n\nalter table \"public\".\"upgrade_badge\" add constraint \"upgrade_badge_badge_id_fkey\" FOREIGN KEY (badge_id) REFERENCES profile_badges(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;\n\nalter table \"public\".\"upgrade_badge\" validate constraint \"upgrade_badge_badge_id_fkey\";\n\ngrant delete on table \"public\".\"upgrade_badge\" to \"anon\";\n\ngrant insert on table \"public\".\"upgrade_badge\" to \"anon\";\n\ngrant references on table \"public\".\"upgrade_badge\" to \"anon\";\n\ngrant select on table \"public\".\"upgrade_badge\" to \"anon\";\n\ngrant trigger on table \"public\".\"upgrade_badge\" to \"anon\";\n\ngrant truncate on table \"public\".\"upgrade_badge\" to \"anon\";\n\ngrant update on table \"public\".\"upgrade_badge\" to \"anon\";\n\ngrant delete on table \"public\".\"upgrade_badge\" to \"authenticated\";\n\ngrant insert on table \"public\".\"upgrade_badge\" to \"authenticated\";\n\ngrant references on table \"public\".\"upgrade_badge\" to \"authenticated\";\n\ngrant select on table \"public\".\"upgrade_badge\" to \"authenticated\";\n\ngrant trigger on table \"public\".\"upgrade_badge\" to \"authenticated\";\n\ngrant truncate on table \"public\".\"upgrade_badge\" to \"authenticated\";\n\ngrant update on table \"public\".\"upgrade_badge\" to \"authenticated\";\n\ngrant delete on table \"public\".\"upgrade_badge\" to \"service_role\";\n\ngrant insert on table \"public\".\"upgrade_badge\" to \"service_role\";\n\ngrant references on table \"public\".\"upgrade_badge\" to \"service_role\";\n\ngrant select on table \"public\".\"upgrade_badge\" to \"service_role\";\n\ngrant trigger on table \"public\".\"upgrade_badge\" to \"service_role\";\n\ngrant truncate on table \"public\".\"upgrade_badge\" to \"service_role\";\n\ngrant update on table \"public\".\"upgrade_badge\" to \"service_role\";\n\ncreate policy \"tenant_isolation\"\non \"public\".\"upgrade_badge\"\nas permissive\nfor all\nto public\nusing ((tenant_id = ((( SELECT current_setting('request.headers'::text, true) AS current_setting))::json ->> 'x-tenant-id'::text)));\n\n\n\n"
  },
  {
    "path": "supabase/migrations/20251203151049_research_author_search_fix.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_projects_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, current_username text DEFAULT NULL::text)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    IF search_query IS NOT NULL THEN\n      ts_query := to_tsquery('english', search_query);\n    END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (search_query IS NULL OR\n       p.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status public.research_status DEFAULT NULL::public.research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status public.research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', search_query);\n  END IF;\n \n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count\n  FROM research r\n  JOIN profiles prof ON prof.id = r.created_by\n  WHERE\n    (search_query IS NULL OR\n     r.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, research_status public.research_status DEFAULT NULL::public.research_status)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', search_query);\n  END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM research r\n    INNER JOIN profiles prof ON prof.id = r.created_by\n    WHERE\n      (search_query IS NULL OR\n       r.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR r.category = category_id) AND\n      (research_status IS NULL OR r.status = research_status) AND\n      (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n      (r.deleted IS NULL OR r.deleted = FALSE)\n  );\nEND;\n$function$\n;\n\n"
  },
  {
    "path": "supabase/migrations/20251217132033_add_most_useful_last_week_feature.sql",
    "content": "drop function if exists \"public\".\"get_projects\"(search_query text, category_id bigint, sort_by text, limit_val integer, offset_val integer, current_username text);\n\ndrop function if exists \"public\".\"get_research\"(search_query text, category_id bigint, research_status research_status, sort_by text, limit_val integer, offset_val integer);\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided\n    IF search_query IS NOT NULL THEN\n        ts_query := to_tsquery('english', search_query);\n    END IF;\n\n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count,\n        (SELECT COALESCE(COUNT(uv.id), 0)::integer\n         FROM useful_votes uv\n         WHERE uv.content_id = p.id\n           AND uv.content_type = 'projects'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.created_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            WHEN sort_by = 'MostUsefulLastWeek' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id\n                   AND uv.content_type = 'projects'\n                   AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n            WHEN sort_by = 'MostViews' THEN p.total_views\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.created_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status research_status DEFAULT NULL::research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', search_query);\n  END IF;\n\n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count,\n    (SELECT COALESCE(COUNT(uv.id), 0)::integer\n     FROM useful_votes uv\n     WHERE uv.content_id = r.id\n       AND uv.content_type = 'research'\n       AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n  FROM research r\n  JOIN profiles prof ON prof.id = r.created_by\n  WHERE\n    (search_query IS NULL OR\n     r.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUsefulLastWeek' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv\n         WHERE uv.content_id = r.id\n           AND uv.content_type = 'research'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\n\n"
  },
  {
    "path": "supabase/migrations/20260103002808_add_premium_tier.sql",
    "content": "alter table \"public\".\"profile_badges\" add column \"premium_tier\" integer;\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided\n    IF search_query IS NOT NULL THEN\n        ts_query := to_tsquery('english', search_query);\n    END IF;\n    \n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count,\n        (SELECT COALESCE(COUNT(uv.id), 0)::integer\n         FROM useful_votes uv\n         WHERE uv.content_id = p.id\n           AND uv.content_type = 'projects'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.created_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            WHEN sort_by = 'MostUsefulLastWeek' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id\n                   AND uv.content_type = 'projects'\n                   AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n            WHEN sort_by = 'MostViews' THEN p.total_views\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.created_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status public.research_status DEFAULT NULL::public.research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status public.research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', search_query);\n  END IF;\n \n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count,\n    (SELECT COALESCE(COUNT(uv.id), 0)::integer\n     FROM useful_votes uv\n     WHERE uv.content_id = r.id\n       AND uv.content_type = 'research'\n       AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n  FROM research r\n  JOIN profiles prof ON prof.id = r.created_by\n  WHERE\n    (search_query IS NULL OR\n     r.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUsefulLastWeek' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv\n         WHERE uv.content_id = r.id\n           AND uv.content_type = 'research'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\n"
  },
  {
    "path": "supabase/migrations/20260118140428_update_user_doc_gets.sql",
    "content": "DROP FUNCTION public.get_user_projects(username_param text);\nCREATE OR REPLACE FUNCTION public.get_user_projects(username_param text)\n RETURNS TABLE(id bigint, comment_count integer, cover_image json, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    pr.id,\n    pr.comment_count,\n    pr.cover_image,\n    pr.title,\n    pr.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM projects pr\n  INNER JOIN profiles p ON p.id = pr.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = pr.id AND uv.content_type = 'projects'\n  WHERE p.username = username_param\n  AND (pr.deleted IS NULL OR pr.deleted = FALSE)\n  AND (pr.is_draft IS NULL OR pr.is_draft = FALSE)\n  AND (pr.moderation = 'accepted')\n  GROUP BY pr.id, pr.title, pr.slug;\nEND;\n$function$\n;\n\nDROP FUNCTION public.get_user_questions(username_param text);\nCREATE OR REPLACE FUNCTION public.get_user_questions(username_param text)\n RETURNS TABLE(id bigint, comment_count bigint, images json[], title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    q.id,\n    q.comment_count,\n    q.images,\n    q.title,\n    q.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM questions q\n  INNER JOIN profiles p ON p.id = q.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = q.id AND uv.content_type = 'questions'\n  WHERE p.username = username_param\n  AND (q.deleted IS NULL OR q.deleted = FALSE)\n  GROUP BY q.id, q.title, q.slug;\nEND;\n$function$\n;\n\nDROP FUNCTION \"public\".\"get_user_research\"(username_param text);\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_research\"(username_param text) \n RETURNS TABLE(\"id\" bigint, image json, title text, slug text, total_useful bigint)\n LANGUAGE \"plpgsql\"\n SET search_path = public, pg_temp\nAS $$\nBEGIN\n  RETURN QUERY\n  SELECT \n    r.id,\n    r.image,\n    r.title,\n    r.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM research r\n  INNER JOIN profiles p ON p.id = r.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = r.id AND uv.content_type = 'research'\n  WHERE p.username = username_param\n  AND (r.deleted IS NULL OR r.deleted = FALSE)\n  AND (r.is_draft IS NULL OR r.is_draft = FALSE)\n  GROUP BY r.id, r.title, r.slug;\nEND;\n$$;\n"
  },
  {
    "path": "supabase/migrations/20260118233300_batch_notifications.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_subscribed_users_emails_to_notify(p_content_id bigint, p_content_type text)\n RETURNS TABLE(\n    email character varying,\n    profile_id bigint,\n    profile_created_at timestamp with time zone,\n    display_name character varying,\n    comments boolean,\n    replies boolean,\n    research_updates boolean,\n    is_unsubscribed boolean\n)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$\nBEGIN\n    RETURN QUERY\n    SELECT DISTINCT ON (p.id)\n        u.email,\n        p.id AS profile_id,\n        p.created_at AS profile_created_at,\n        p.display_name::character varying,\n        np.comments,\n        np.replies,\n        np.research_updates,\n        np.is_unsubscribed\n    FROM subscribers s\n    INNER JOIN profiles p ON s.user_id = p.id\n    INNER JOIN auth.users u ON p.auth_id = u.id\n    LEFT JOIN notifications_preferences np ON np.user_id = p.id\n    WHERE s.content_id = p_content_id AND s.content_type = p_content_type\n    ORDER BY p.id;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.update_comment_count()\n RETURNS trigger\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$BEGIN\n  IF (TG_OP = 'INSERT') THEN\n    IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL AND (NEW.deleted IS NULL OR NEW.deleted = false) THEN\n      IF NEW.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'research_updates' THEN\n        UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'news' THEN\n        UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: source_type or source_id is NULL, or comment is deleted';\n    END IF;\n  ELSIF (TG_OP = 'UPDATE') THEN\n    IF (COALESCE(OLD.deleted, false) = false AND NEW.deleted = true) THEN\n      IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n        IF OLD.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'research_updates' THEN\n          UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'news' THEN\n          UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n      END IF;\n    ELSIF (OLD.deleted = true AND COALESCE(NEW.deleted, false) = false) THEN\n      IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL THEN\n        IF NEW.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'research_updates' THEN\n          UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'news' THEN\n          UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: NEW.source_type or NEW.source_id is NULL';\n      END IF;\n    END IF;\n  ELSIF (TG_OP = 'DELETE') THEN\n    IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n      IF OLD.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'research_updates' THEN\n        UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'news' THEN\n        UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n    END IF;\n  END IF;\n  \n  RETURN NULL;\nEND;$function$\n;\n\nalter table \"public\".\"notifications\" add column \"title\" text;\n\nALTER TYPE public.notification_content_types ADD VALUE IF NOT EXISTS 'research_updates';\nALTER TYPE public.notification_content_types ADD VALUE IF NOT EXISTS 'comments';\nALTER TYPE public.notification_content_types ADD VALUE IF NOT EXISTS 'projects';\n\nALTER TYPE public.notification_source_content_type ADD VALUE IF NOT EXISTS 'projects';\nALTER TYPE public.notification_source_content_type ADD VALUE IF NOT EXISTS 'research_updates';\n\nALTER TYPE public.notification_action_types ADD VALUE IF NOT EXISTS 'newReply';\n"
  },
  {
    "path": "supabase/migrations/20260216125228_more_tenant_settings.sql",
    "content": "alter table \"public\".\"tenant_settings\" add column \"academy_resource\" text;\n\nalter table \"public\".\"tenant_settings\" add column \"library_heading\" text;\n\nalter table \"public\".\"tenant_settings\" add column \"no_messaging\" boolean not null default false;\n\nalter table \"public\".\"tenant_settings\" add column \"patreon_id\" text;\n\nalter table \"public\".\"tenant_settings\" add column \"profile_guidelines\" text;\n\nalter table \"public\".\"tenant_settings\" add column \"questions_guidelines\" text;\n\nalter table \"public\".\"tenant_settings\" add column \"supported_modules\" text;\n"
  },
  {
    "path": "supabase/migrations/20260227070309_site_description.sql",
    "content": "alter table \"public\".\"tenant_settings\" add column \"site_description\" text;\nalter table \"public\".\"tenant_settings\" add column \"color_primary\" text;\nalter table \"public\".\"tenant_settings\" add column \"color_primary_hover\" text;\nalter table \"public\".\"tenant_settings\" add column \"color_accent\" text;\nalter table \"public\".\"tenant_settings\" add column \"color_accent_hover\" text;\nalter table \"public\".\"tenant_settings\" add column \"show_impact\" boolean;\nalter table \"public\".\"tenant_settings\" add column \"create_research_roles\" text[];\n"
  },
  {
    "path": "supabase/migrations/20260301000000_fix_partial_search.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status public.research_status DEFAULT NULL::public.research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status public.research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided, using prefix matching for partial words\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', regexp_replace(plainto_tsquery('english', search_query)::text, '''(\\w+)''', '''\\1'':*', 'g'));\n  END IF;\n\n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count,\n    (SELECT COALESCE(COUNT(uv.id), 0)::integer\n     FROM useful_votes uv\n     WHERE uv.content_id = r.id\n       AND uv.content_type = 'research'\n       AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n  FROM research r\n  JOIN profiles prof ON prof.id = r.created_by\n  WHERE\n    (search_query IS NULL OR\n     r.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.created_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUsefulLastWeek' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv\n         WHERE uv.content_id = r.id\n           AND uv.content_type = 'research'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, research_status public.research_status DEFAULT NULL::public.research_status)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', regexp_replace(plainto_tsquery('english', search_query)::text, '''(\\w+)''', '''\\1'':*', 'g'));\n  END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM research r\n    INNER JOIN profiles prof ON prof.id = r.created_by\n    WHERE\n      (search_query IS NULL OR\n       r.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR r.category = category_id) AND\n      (research_status IS NULL OR r.status = research_status) AND\n      (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n      (r.deleted IS NULL OR r.deleted = FALSE)\n  );\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided, using prefix matching for partial words\n    IF search_query IS NOT NULL THEN\n        ts_query := to_tsquery('english', regexp_replace(plainto_tsquery('english', search_query)::text, '''(\\w+)''', '''\\1'':*', 'g'));\n    END IF;\n\n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count,\n        (SELECT COALESCE(COUNT(uv.id), 0)::integer\n         FROM useful_votes uv\n         WHERE uv.content_id = p.id\n           AND uv.content_type = 'projects'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.created_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            WHEN sort_by = 'MostUsefulLastWeek' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id\n                   AND uv.content_type = 'projects'\n                   AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n            WHEN sort_by = 'MostViews' THEN p.total_views\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.created_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_projects_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, current_username text DEFAULT NULL::text)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    IF search_query IS NOT NULL THEN\n      ts_query := to_tsquery('english', regexp_replace(plainto_tsquery('english', search_query)::text, '''(\\w+)''', '''\\1'':*', 'g'));\n    END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (search_query IS NULL OR\n       p.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20260309135320_questions_search_rpc.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_questions(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 20, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, category json, tags bigint[], title text, total_views bigint, is_draft boolean, comment_count bigint, images json[], author json)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', regexp_replace(plainto_tsquery('english', search_query)::text, '''(\\w+)''', '''\\1'':*', 'g'));\n  END IF;\n\n  RETURN QUERY\n  SELECT\n    q.id,\n    q.created_at,\n    q.created_by,\n    q.modified_at,\n    q.description,\n    q.slug,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = q.category) AS category,\n    q.tags,\n    q.title,\n    q.total_views,\n    q.is_draft,\n    q.comment_count,\n    q.images,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'photo', p.photo,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = q.created_by) AS author\n  FROM questions q\n  JOIN profiles prof ON prof.id = q.created_by\n  WHERE\n    (search_query IS NULL OR\n     q.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR q.category = category_id) AND\n    q.is_draft = FALSE AND\n    (q.deleted IS NULL OR q.deleted = FALSE)\n  ORDER BY\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(q.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from q.created_at)\n      WHEN sort_by = 'Comments' THEN q.comment_count\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN q.comment_count\n    END ASC NULLS LAST,\n    q.created_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_questions_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', regexp_replace(plainto_tsquery('english', search_query)::text, '''(\\w+)''', '''\\1'':*', 'g'));\n  END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM questions q\n    INNER JOIN profiles prof ON prof.id = q.created_by\n    WHERE\n      (search_query IS NULL OR\n       q.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR q.category = category_id) AND\n      q.is_draft = FALSE AND\n      (q.deleted IS NULL OR q.deleted = FALSE)\n  );\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20260317131051_add_published_at.sql",
    "content": "drop function if exists \"public\".\"get_questions\"(search_query text, category_id bigint, sort_by text, limit_val integer, offset_val integer);\n\nalter table \"public\".\"news\" add column \"published_at\" timestamp with time zone;\n\nalter table \"public\".\"projects\" add column \"published_at\" timestamp with time zone;\n\nalter table \"public\".\"questions\" add column \"published_at\" timestamp with time zone;\n\nalter table \"public\".\"research\" add column \"published_at\" timestamp with time zone;\n\nalter table \"public\".\"research_updates\" add column \"published_at\" timestamp with time zone;\n\n-- Backfill existing published content with created_at as published_at\nUPDATE \"public\".\"news\" SET published_at = created_at WHERE is_draft = false AND published_at IS NULL;\nUPDATE \"public\".\"questions\" SET published_at = created_at WHERE is_draft = false AND published_at IS NULL;\nUPDATE \"public\".\"research\" SET published_at = created_at WHERE is_draft = false AND published_at IS NULL;\nUPDATE \"public\".\"research_updates\" SET published_at = created_at WHERE (is_draft = false OR is_draft IS NULL) AND published_at IS NULL;\nUPDATE \"public\".\"projects\" SET published_at = created_at WHERE is_draft = false AND published_at IS NULL;\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided, using prefix matching for partial words\n    IF search_query IS NOT NULL THEN\n        ts_query := to_tsquery('english', regexp_replace(plainto_tsquery('english', search_query)::text, '''(\\w+)''', '''\\1'':*', 'g'));\n    END IF;\n\n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count,\n        (SELECT COALESCE(COUNT(uv.id), 0)::integer\n         FROM useful_votes uv\n         WHERE uv.content_id = p.id\n           AND uv.content_type = 'projects'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.published_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            WHEN sort_by = 'MostUsefulLastWeek' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id\n                   AND uv.content_type = 'projects'\n                   AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n            WHEN sort_by = 'MostViews' THEN p.total_views\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.published_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_questions(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 20, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, published_at timestamp with time zone, description text, slug text, category json, tags bigint[], title text, total_views bigint, is_draft boolean, comment_count bigint, images json[], author json)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', regexp_replace(plainto_tsquery('english', search_query)::text, '''(\\w+)''', '''\\1'':*', 'g'));\n  END IF;\n\n  RETURN QUERY\n  SELECT\n    q.id,\n    q.created_at,\n    q.created_by,\n    q.modified_at,\n    q.published_at,\n    q.description,\n    q.slug,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = q.category) AS category,\n    q.tags,\n    q.title,\n    q.total_views,\n    q.is_draft,\n    q.comment_count,\n    q.images,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'photo', p.photo,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = q.created_by) AS author\n  FROM questions q\n  JOIN profiles prof ON prof.id = q.created_by\n  WHERE\n    (search_query IS NULL OR\n     q.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR q.category = category_id) AND\n    q.is_draft = FALSE AND\n    (q.deleted IS NULL OR q.deleted = FALSE)\n  ORDER BY\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(q.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from q.published_at)\n      WHEN sort_by = 'Comments' THEN q.comment_count\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN q.comment_count\n    END ASC NULLS LAST,\n    q.published_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status research_status DEFAULT NULL::research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided, using prefix matching for partial words\n  IF search_query IS NOT NULL THEN\n    ts_query := to_tsquery('english', regexp_replace(plainto_tsquery('english', search_query)::text, '''(\\w+)''', '''\\1'':*', 'g'));\n  END IF;\n\n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count,\n    (SELECT COALESCE(COUNT(uv.id), 0)::integer\n     FROM useful_votes uv\n     WHERE uv.content_id = r.id\n       AND uv.content_type = 'research'\n       AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n  FROM research r\n  JOIN profiles prof ON prof.id = r.created_by\n  WHERE\n    (search_query IS NULL OR\n     r.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.published_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUsefulLastWeek' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv\n         WHERE uv.content_id = r.id\n           AND uv.content_type = 'research'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.published_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20260322000000_add_get_storage_object_path.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_storage_object_path(\n  object_id uuid,\n  bucket_name text\n)\nRETURNS TABLE (\n  id uuid,\n  name text,\n  path_tokens text[],\n  bucket_id text\n) \nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public, pg_temp\nAS $$\nBEGIN\n  RETURN QUERY\n  SELECT \n    so.id,\n    so.name,\n    so.path_tokens,\n    so.bucket_id\n  FROM storage.objects so\n  WHERE so.id = object_id\n    AND so.bucket_id = bucket_name;\nEND;\n$$;\n\n-- Grant execute permission to authenticated users\nGRANT EXECUTE ON FUNCTION public.get_storage_object_path(uuid, text) TO authenticated;\n"
  },
  {
    "path": "supabase/migrations/20260330124656_make_username_nullable.sql",
    "content": "drop function if exists \"public\".\"is_username_available\"(username text);\n\nalter table \"public\".\"profiles\" alter column \"username\" drop default;\n\nalter table \"public\".\"profiles\" alter column \"username\" drop not null;\n\nUPDATE \"public\".\"profiles\" SET username = NULL WHERE username = '';\n\nDROP INDEX IF EXISTS \"public\".\"profiles_username_tenant_id_key\";\n\nCREATE UNIQUE INDEX profiles_username_tenant_id_key ON public.profiles USING btree (username, tenant_id) WHERE (username IS NOT NULL);\n\nset check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.is_username_available(username text, exclude_profile_id integer DEFAULT NULL::integer)\n RETURNS boolean\n LANGUAGE sql\n STABLE SECURITY DEFINER\n SET search_path TO 'public', 'pg_temp'\nAS $function$\n  SELECT CASE WHEN $1 IS NULL THEN false\n  ELSE NOT EXISTS (\n    SELECT 1 FROM profiles\n    WHERE LOWER(profiles.username) = LOWER($1)\n      AND ($2 IS NULL OR profiles.id != $2)\n      AND tenant_id = ((SELECT current_setting('request.headers', true))::json ->> 'x-tenant-id')\n  ) END;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20260401000000_tenant_settings_ga.sql",
    "content": "alter table \"public\".\"tenant_settings\" add column \"ga_tracking_id\" text;\n"
  },
  {
    "path": "supabase/migrations/20260402103538_search_multiple_words.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided, using prefix matching for partial words\n    IF search_query IS NOT NULL THEN\n        -- Split search query into words, sanitize each with plainto_tsquery, then add prefix matching\n        ts_query := to_tsquery('english', \n          array_to_string(\n            ARRAY(\n              SELECT regexp_replace(\n                plainto_tsquery('english', word)::text,\n                '''(\\w+)''',\n                '''\\1'':*',\n                'g'\n              )\n              FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n              WHERE word != ''\n            ),\n            ' & '\n          )\n        );\n    END IF;\n    \n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count,\n        (SELECT COALESCE(COUNT(uv.id), 0)::integer\n         FROM useful_votes uv\n         WHERE uv.content_id = p.id\n           AND uv.content_type = 'projects'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.published_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            WHEN sort_by = 'MostUsefulLastWeek' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id\n                   AND uv.content_type = 'projects'\n                   AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n            WHEN sort_by = 'MostViews' THEN p.total_views\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.published_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_projects_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, current_username text DEFAULT NULL::text)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    IF search_query IS NOT NULL THEN\n      -- Split search query into words, sanitize each with plainto_tsquery, then add prefix matching\n      ts_query := to_tsquery('english', \n        array_to_string(\n          ARRAY(\n            SELECT regexp_replace(\n              plainto_tsquery('english', word)::text,\n              '''(\\w+)''',\n              '''\\1'':*',\n              'g'\n            )\n            FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n            WHERE word != ''\n          ),\n          ' & '\n        )\n      );\n    END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (search_query IS NULL OR\n       p.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_questions(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 20, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, published_at timestamp with time zone, description text, slug text, category json, tags bigint[], title text, total_views bigint, is_draft boolean, comment_count bigint, images json[], author json)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words, sanitize each with plainto_tsquery, then add prefix matching\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT regexp_replace(\n            plainto_tsquery('english', word)::text,\n            '''(\\w+)''',\n            '''\\1'':*',\n            'g'\n          )\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n\n  RETURN QUERY\n  SELECT\n    q.id,\n    q.created_at,\n    q.created_by,\n    q.modified_at,\n    q.published_at,\n    q.description,\n    q.slug,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = q.category) AS category,\n    q.tags,\n    q.title,\n    q.total_views,\n    q.is_draft,\n    q.comment_count,\n    q.images,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'photo', p.photo,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = q.created_by) AS author\n  FROM questions q\n  JOIN profiles prof ON prof.id = q.created_by\n  WHERE\n    (search_query IS NULL OR\n     q.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR q.category = category_id) AND\n    q.is_draft = FALSE AND\n    (q.deleted IS NULL OR q.deleted = FALSE)\n  ORDER BY\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(q.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from q.published_at)\n      WHEN sort_by = 'Comments' THEN q.comment_count\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN q.comment_count\n    END ASC NULLS LAST,\n    q.published_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_questions_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words, sanitize each with plainto_tsquery, then add prefix matching\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT regexp_replace(\n            plainto_tsquery('english', word)::text,\n            '''(\\w+)''',\n            '''\\1'':*',\n            'g'\n          )\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM questions q\n    INNER JOIN profiles prof ON prof.id = q.created_by\n    WHERE\n      (search_query IS NULL OR\n       q.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR q.category = category_id) AND\n      q.is_draft = FALSE AND\n      (q.deleted IS NULL OR q.deleted = FALSE)\n  );\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status public.research_status DEFAULT NULL::public.research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status public.research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided, using prefix matching for partial words\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words, sanitize each with plainto_tsquery, then add prefix matching\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT regexp_replace(\n            plainto_tsquery('english', word)::text,\n            '''(\\w+)''',\n            '''\\1'':*',\n            'g'\n          )\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n \n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count,\n    (SELECT COALESCE(COUNT(uv.id), 0)::integer\n     FROM useful_votes uv\n     WHERE uv.content_id = r.id\n       AND uv.content_type = 'research'\n       AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n  FROM research r\n  JOIN profiles prof ON prof.id = r.created_by\n  WHERE\n    (search_query IS NULL OR\n     r.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.published_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUsefulLastWeek' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv\n         WHERE uv.content_id = r.id\n           AND uv.content_type = 'research'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.published_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, research_status public.research_status DEFAULT NULL::public.research_status)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words, sanitize each with plainto_tsquery, then add prefix matching\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT regexp_replace(\n            plainto_tsquery('english', word)::text,\n            '''(\\w+)''',\n            '''\\1'':*',\n            'g'\n          )\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM research r\n    INNER JOIN profiles prof ON prof.id = r.created_by\n    WHERE\n      (search_query IS NULL OR\n       r.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR r.category = category_id) AND\n      (research_status IS NULL OR r.status = research_status) AND\n      (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n      (r.deleted IS NULL OR r.deleted = FALSE)\n  );\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20260402150623_search_stop_words.sql",
    "content": "set check_function_bodies = off;\n\nCREATE OR REPLACE FUNCTION public.get_projects(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 12, offset_val integer DEFAULT 0, current_username text DEFAULT NULL::text, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, cover_image json, category json, tags text[], title text, moderation text, total_views bigint, author json, comment_count integer, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided, using prefix matching for partial words\n    IF search_query IS NOT NULL THEN\n        -- Split search query into words and create prefix-matching tsquery with AND logic\n        -- Sanitize each word to prevent tsquery injection\n        ts_query := to_tsquery('english', \n          array_to_string(\n            ARRAY(\n              SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n              FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n              WHERE word != '' \n                AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n            ),\n            ' & '\n          )\n        );\n    END IF;\n    \n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count,\n        (SELECT COALESCE(COUNT(uv.id), 0)::integer\n         FROM useful_votes uv\n         WHERE uv.content_id = p.id\n           AND uv.content_type = 'projects'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.published_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            WHEN sort_by = 'MostUsefulLastWeek' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id\n                   AND uv.content_type = 'projects'\n                   AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n            WHEN sort_by = 'MostViews' THEN p.total_views\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.published_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_projects_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, current_username text DEFAULT NULL::text)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    IF search_query IS NOT NULL THEN\n      -- Split search query into words and create prefix-matching tsquery with AND logic\n      -- Sanitize each word to prevent tsquery injection\n      ts_query := to_tsquery('english', \n        array_to_string(\n          ARRAY(\n            SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n            FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n            WHERE word != '' \n              AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n          ),\n          ' & '\n        )\n      );\n    END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (search_query IS NULL OR\n       p.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_questions(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 20, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, published_at timestamp with time zone, description text, slug text, category json, tags bigint[], title text, total_views bigint, is_draft boolean, comment_count bigint, images json[], author json)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words and create prefix-matching tsquery with AND logic\n    -- Sanitize each word to prevent tsquery injection\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != '' \n            AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n\n  RETURN QUERY\n  SELECT\n    q.id,\n    q.created_at,\n    q.created_by,\n    q.modified_at,\n    q.published_at,\n    q.description,\n    q.slug,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = q.category) AS category,\n    q.tags,\n    q.title,\n    q.total_views,\n    q.is_draft,\n    q.comment_count,\n    q.images,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'photo', p.photo,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = q.created_by) AS author\n  FROM questions q\n  JOIN profiles prof ON prof.id = q.created_by\n  WHERE\n    (search_query IS NULL OR\n     q.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR q.category = category_id) AND\n    q.is_draft = FALSE AND\n    (q.deleted IS NULL OR q.deleted = FALSE)\n  ORDER BY\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(q.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from q.published_at)\n      WHEN sort_by = 'Comments' THEN q.comment_count\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN q.comment_count\n    END ASC NULLS LAST,\n    q.published_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_questions_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words and create prefix-matching tsquery with AND logic\n    -- Sanitize each word to prevent tsquery injection\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != '' \n            AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM questions q\n    INNER JOIN profiles prof ON prof.id = q.created_by\n    WHERE\n      (search_query IS NULL OR\n       q.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR q.category = category_id) AND\n      q.is_draft = FALSE AND\n      (q.deleted IS NULL OR q.deleted = FALSE)\n  );\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, research_status public.research_status DEFAULT NULL::public.research_status, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 10, offset_val integer DEFAULT 0, days_back integer DEFAULT 7)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, description text, slug text, image json, status public.research_status, category json, tags text[], title text, total_views integer, author json, update_count bigint, comment_count bigint, useful_votes_last_week integer)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided, using prefix matching for partial words\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words and create prefix-matching tsquery with AND logic\n    -- Sanitize each word to prevent tsquery injection\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != '' \n            AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n \n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count,\n    (SELECT COALESCE(COUNT(uv.id), 0)::integer\n     FROM useful_votes uv\n     WHERE uv.content_id = r.id\n       AND uv.content_type = 'research'\n       AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n  FROM research r\n  JOIN profiles prof ON prof.id = r.created_by\n  WHERE\n    (search_query IS NULL OR\n     r.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.published_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUsefulLastWeek' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv\n         WHERE uv.content_id = r.id\n           AND uv.content_type = 'research'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.published_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_research_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer, research_status public.research_status DEFAULT NULL::public.research_status)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words and create prefix-matching tsquery with AND logic\n    -- Sanitize each word to prevent tsquery injection\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != '' \n            AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM research r\n    INNER JOIN profiles prof ON prof.id = r.created_by\n    WHERE\n      (search_query IS NULL OR\n       r.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR r.category = category_id) AND\n      (research_status IS NULL OR r.status = research_status) AND\n      (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n      (r.deleted IS NULL OR r.deleted = FALSE)\n  );\nEND;\n$function$\n;\n"
  },
  {
    "path": "supabase/migrations/20260421051050_pwa_icons.sql",
    "content": "ALTER TABLE tenant_settings ADD COLUMN pwa_icons JSONB;\n\nALTER TABLE tenant_settings\nADD CONSTRAINT check_pwa_icons_schema\nCHECK (\n  pwa_icons IS NULL\n  OR (\n    jsonb_typeof(pwa_icons) = 'object'\n    AND pwa_icons - ARRAY['16','32','192','256','512'] = '{}'::jsonb\n    AND (pwa_icons->>'16')  IS NOT NULL\n    AND (pwa_icons->>'32')  IS NOT NULL\n    AND (pwa_icons->>'192') IS NOT NULL\n    AND (pwa_icons->>'256') IS NOT NULL\n    AND (pwa_icons->>'512') IS NOT NULL\n    AND jsonb_typeof(pwa_icons->'16')  = 'string'\n    AND jsonb_typeof(pwa_icons->'32')  = 'string'\n    AND jsonb_typeof(pwa_icons->'192') = 'string'\n    AND jsonb_typeof(pwa_icons->'256') = 'string'\n    AND jsonb_typeof(pwa_icons->'512') = 'string'\n  )\n);"
  },
  {
    "path": "supabase/schemas/banners.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"banners\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"modified_at\" timestamp with time zone,\n    \"text\" \"text\" NOT NULL,\n    \"url\" \"text\",\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nALTER TABLE \"public\".\"banners\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"banners\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n\n"
  },
  {
    "path": "supabase/schemas/categories.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"categories\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"name\" \"text\" NOT NULL,\n    \"legacy_id\" \"text\",\n    \"type\" \"public\".\"content_types\"\n);\n\nALTER TABLE \"public\".\"categories\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"categories\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n"
  },
  {
    "path": "supabase/schemas/comments.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"comments\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"comment\" \"text\" NOT NULL,\n    \"source_id\" bigint,\n    \"parent_id\" bigint,\n    \"tenant_id\" \"text\" DEFAULT ''::\"text\" NOT NULL,\n    \"created_by\" bigint,\n    \"source_type\" \"text\" NOT NULL,\n    \"modified_at\" timestamp with time zone,\n    \"source_id_legacy\" \"text\",\n    \"deleted\" boolean,\n    \"legacy_id\" \"text\"\n);\n\nCREATE INDEX \"comments_created_by_idx\" ON \"public\".\"comments\" USING \"btree\" (\"created_by\");\nCREATE INDEX \"comments_source_type_source_id_created_at_idx\" ON \"public\".\"comments\" USING \"btree\" (\"source_type\", \"source_id\", \"created_at\");\n\nALTER TABLE ONLY \"public\".\"comments\"\n    ADD CONSTRAINT \"comment_created_by_fkey\" FOREIGN KEY (\"created_by\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE \"public\".\"comments\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"comments\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n\nCREATE OR REPLACE FUNCTION \"public\".\"comment_authors_by_source_id\"(\"source_id_input\" bigint) RETURNS SETOF \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $$\n  SELECT DISTINCT (p.username)\n  FROM comments c\n  INNER JOIN profiles p\n  ON c.created_by = p.id\n  WHERE c.source_id = source_id_input\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_comments_with_votes\"(\"p_source_type\" \"text\", \"p_source_id\" bigint, \"p_current_user_id\" bigint DEFAULT NULL::bigint) RETURNS TABLE(\"id\" bigint, \"comment\" \"text\", \"created_at\" timestamp with time zone, \"modified_at\" timestamp with time zone, \"deleted\" boolean, \"source_id\" bigint, \"source_type\" \"text\", \"parent_id\" bigint, \"created_by\" bigint, \"profile\" \"json\", \"vote_count\" bigint, \"has_voted\" boolean)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  RETURN QUERY\n  SELECT \n    c.id,\n    c.comment,\n    c.created_at,\n    c.modified_at,\n    c.deleted,\n    c.source_id,\n    c.source_type,\n    c.parent_id,\n    c.created_by,\n    -- Build the profile JSON structure\n    CASE \n      WHEN p.id IS NOT NULL THEN \n        json_build_object(\n          'id', p.id,\n          'display_name', p.display_name,\n          'username', p.username,\n          'photo', p.photo,\n          'country', p.country,\n          'badges', COALESCE(badges_agg.badges_array, '[]'::json)\n        )\n      ELSE NULL \n    END as profile,\n    -- Count of useful votes for this comment\n    COALESCE(vote_counts.vote_count, 0) as vote_count,\n    -- Whether current user has voted on this comment\n    CASE \n      WHEN p_current_user_id IS NOT NULL AND user_votes.content_id IS NOT NULL \n      THEN TRUE \n      ELSE FALSE \n    END as has_voted\n  FROM comments c\n  LEFT JOIN profiles p ON c.created_by = p.id\n  -- Aggregate badges for each profile\n  LEFT JOIN (\n    SELECT \n      pbr.profile_id,\n      json_agg(\n        json_build_object(\n          'id', pb.id,\n          'name', pb.name,\n          'display_name', pb.display_name,\n          'image_url', pb.image_url,\n          'action_url', pb.action_url\n        )\n      ) as badges_array\n    FROM profile_badges_relations pbr\n    JOIN profile_badges pb ON pbr.profile_badge_id = pb.id\n    GROUP BY pbr.profile_id\n  ) badges_agg ON p.id = badges_agg.profile_id\n  -- Count useful votes for each comment\n  LEFT JOIN (\n    SELECT \n      uv.content_id,\n      COUNT(*) as vote_count\n    FROM useful_votes uv\n    WHERE uv.content_type = 'comments'\n    GROUP BY uv.content_id\n  ) vote_counts ON c.id = vote_counts.content_id\n  -- Check if current user has voted on each comment\n  LEFT JOIN (\n    SELECT DISTINCT uv.content_id\n    FROM useful_votes uv\n    WHERE uv.content_type = 'comments'\n      AND uv.user_id = p_current_user_id\n  ) user_votes ON c.id = user_votes.content_id\n  WHERE \n    c.source_type = p_source_type\n    AND c.source_id = p_source_id\n  ORDER BY c.created_at ASC;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"update_comment_count\"() RETURNS \"trigger\"\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$BEGIN\n  IF (TG_OP = 'INSERT') THEN\n    IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL AND (NEW.deleted IS NULL OR NEW.deleted = false) THEN\n      IF NEW.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'research_updates' THEN\n        UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'news' THEN\n        UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      ELSIF NEW.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n        WHERE id = NEW.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: source_type or source_id is NULL, or comment is deleted';\n    END IF;\n  ELSIF (TG_OP = 'UPDATE') THEN\n    IF (COALESCE(OLD.deleted, false) = false AND NEW.deleted = true) THEN\n      IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n        IF OLD.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'research_updates' THEN\n          UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'news' THEN\n          UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        ELSIF OLD.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n          WHERE id = OLD.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n      END IF;\n    ELSIF (OLD.deleted = true AND COALESCE(NEW.deleted, false) = false) THEN\n      IF NEW.source_type IS NOT NULL AND NEW.source_id IS NOT NULL THEN\n        IF NEW.source_type = 'questions' THEN\n          UPDATE questions SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'research_updates' THEN\n          UPDATE research_updates SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'news' THEN\n          UPDATE news SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        ELSIF NEW.source_type = 'projects' THEN\n          UPDATE projects SET comment_count = COALESCE(comment_count, 0) + 1\n          WHERE id = NEW.source_id;\n        END IF;\n      ELSE\n        RAISE NOTICE 'Warning: NEW.source_type or NEW.source_id is NULL';\n      END IF;\n    END IF;\n  ELSIF (TG_OP = 'DELETE') THEN\n    IF OLD.source_type IS NOT NULL AND OLD.source_id IS NOT NULL THEN\n      IF OLD.source_type = 'questions' THEN\n        UPDATE questions SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'research_updates' THEN\n        UPDATE research_updates SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'news' THEN\n        UPDATE news SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      ELSIF OLD.source_type = 'projects' THEN\n        UPDATE projects SET comment_count = GREATEST(COALESCE(comment_count, 0) - 1, 0)\n        WHERE id = OLD.source_id;\n      END IF;\n    ELSE\n      RAISE NOTICE 'Warning: OLD.source_type or OLD.source_id is NULL';\n    END IF;\n  END IF;\n  \n  RETURN NULL;\nEND;$$;\n\nCREATE OR REPLACE TRIGGER \"update_comment_count\" AFTER INSERT OR DELETE OR UPDATE ON \"public\".\"comments\" FOR EACH ROW EXECUTE FUNCTION \"public\".\"update_comment_count\"();\n"
  },
  {
    "path": "supabase/schemas/common.sql",
    "content": "CREATE TYPE \"public\".\"content_types\" AS ENUM (\n    'questions',\n    'projects',\n    'research',\n    'news',\n    'comments'\n);\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_email_by_id\"(\"id\" \"uuid\") RETURNS TABLE(\"email\" character varying)\n    LANGUAGE \"plpgsql\" SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.id = $1;\nEND;\n$_$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_email_by_profile_id\"(\"id\" bigint) RETURNS TABLE(\"email\" character varying)\n    LANGUAGE \"plpgsql\" SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au inner join public.profiles p on au.id = p.auth_id WHERE p.id = $1;\nEND;\n$_$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_email_by_username\"(\"username\" \"text\") RETURNS TABLE(\"email\" character varying)\n    LANGUAGE \"plpgsql\" SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$\nBEGIN\n  RETURN QUERY SELECT au.email FROM auth.users au WHERE au.raw_user_meta_data->>'username' = $1;\nEND;\n$_$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_id_by_email\"(\"email\" \"text\") RETURNS TABLE(\"id\" \"uuid\")\n    LANGUAGE \"plpgsql\" SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$BEGIN\n  RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;\nEND;$_$;\n\nCREATE OR REPLACE FUNCTION public.get_storage_object_path(\n  object_id uuid,\n  bucket_name text\n)\nRETURNS TABLE (\n  id uuid,\n  name text,\n  path_tokens text[],\n  bucket_id text\n) \nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public, pg_temp\nAS $$\nBEGIN\n  RETURN QUERY\n  SELECT \n    so.id,\n    so.name,\n    so.path_tokens,\n    so.bucket_id\n  FROM storage.objects so\n  WHERE so.id = object_id\n    AND so.bucket_id = bucket_name;\nEND;\n$$;\n\n-- Grant execute permission to authenticated users\nGRANT EXECUTE ON FUNCTION public.get_storage_object_path(uuid, text) TO authenticated;\n"
  },
  {
    "path": "supabase/schemas/map.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"map_pins\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"profile_id\" bigint NOT NULL,\n    \"country\" \"text\" NOT NULL,\n    \"country_code\" \"text\" NOT NULL,\n    \"administrative\" \"text\",\n    \"post_code\" \"text\",\n    \"lat\" \"text\" NOT NULL,\n    \"lng\" \"text\" NOT NULL,\n    \"moderation\" \"text\" NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"moderation_feedback\" \"text\",\n    \"name\" \"text\"\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"map_settings\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"default_type_filters\" \"text\"[],\n    \"setting_filters\" \"text\"[] NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nCREATE INDEX \"idx_map_pins_moderation\" ON \"public\".\"map_pins\" USING \"btree\" (\"moderation\");\nCREATE INDEX \"idx_map_pins_moderation_tenant\" ON \"public\".\"map_pins\" USING \"btree\" (\"moderation\", \"tenant_id\");\nCREATE INDEX \"idx_map_pins_tenant_id\" ON \"public\".\"map_pins\" USING \"btree\" (\"tenant_id\");\nCREATE INDEX \"map_pins_lat_lng_idx\" ON \"public\".\"map_pins\" USING \"btree\" (\"lat\", \"lng\");\nCREATE INDEX \"map_pins_user_id_idx\" ON \"public\".\"map_pins\" USING \"btree\" (\"profile_id\");\n\nALTER TABLE ONLY \"public\".\"map_pins\"\n    ADD CONSTRAINT \"map_pins_user_id_fkey\" FOREIGN KEY (\"profile_id\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE \"public\".\"map_pins\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"map_settings\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"map_pins\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"map_settings\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n"
  },
  {
    "path": "supabase/schemas/messages.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"messages\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"message\" \"text\" NOT NULL,\n    \"sender_id\" bigint NOT NULL,\n    \"receiver_id\" bigint,\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nALTER TABLE ONLY \"public\".\"messages\"\n    ADD CONSTRAINT \"messages_sender_fkey\" FOREIGN KEY (\"sender_id\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE ONLY \"public\".\"messages\"\n    ADD CONSTRAINT \"messages_receiver_fkey\" FOREIGN KEY (\"receiver_id\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nCREATE POLICY \"message_isolation\" ON \"public\".\"messages\" AS RESTRICTIVE TO \"authenticated\" USING (((\"auth\".\"uid\"() IN ( SELECT \"profiles\".\"auth_id\"\n   FROM \"public\".\"profiles\"\n  WHERE (\"profiles\".\"id\" = \"messages\".\"sender_id\"))) OR (\"auth\".\"uid\"() IN ( SELECT \"profiles\".\"auth_id\"\n   FROM \"public\".\"profiles\"\n  WHERE (\"profiles\".\"id\" = \"messages\".\"receiver_id\")))));\n\nALTER TABLE \"public\".\"messages\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"messages\" TO \"authenticated\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n"
  },
  {
    "path": "supabase/schemas/news.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"news\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"created_by\" bigint,\n    \"deleted\" boolean,\n    \"modified_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\"),\n    \"comment_count\" bigint DEFAULT '0'::bigint,\n    \"body\" \"text\" NOT NULL,\n    \"moderation\" \"text\",\n    \"slug\" \"text\" NOT NULL,\n    \"previous_slugs\" \"text\"[],\n    \"category\" bigint,\n    \"tags\" bigint[],\n    \"title\" \"text\" NOT NULL,\n    \"total_views\" bigint,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"hero_image\" \"json\",\n    \"summary\" \"text\",\n    \"fts\" \"tsvector\" GENERATED ALWAYS AS (\"to_tsvector\"('\"english\"'::\"regconfig\", (((\"title\" || ' '::\"text\") || \"body\") || (\"summary\" || ''::\"text\")))) STORED,\n    \"is_draft\" boolean DEFAULT false NOT NULL,\n    \"profile_badge\" bigint,\n    \"published_at\" timestamp with time zone\n);\n\nCREATE OR REPLACE FUNCTION \"public\".\"news_search_fields\"(\"public\".\"news\") RETURNS \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $_$\n  SELECT $1.title || ' ' || $1.body;\n$_$;\n\nALTER TABLE ONLY \"public\".\"news\"\n    ADD CONSTRAINT \"news_tenant_id_slug_key\" UNIQUE (\"tenant_id\", \"slug\");\n\nCREATE INDEX \"news_category_idx\" ON \"public\".\"news\" USING \"btree\" (\"category\");\nCREATE INDEX \"news_created_by_idx\" ON \"public\".\"news\" USING \"btree\" (\"created_by\");\nCREATE INDEX \"news_deleted_moderation_category_total_views_tags_created_a_idx\" ON \"public\".\"news\" USING \"btree\" (\"deleted\", \"moderation\", \"category\", \"total_views\", \"tags\", \"created_at\", \"comment_count\", \"created_by\");\nCREATE INDEX \"news_tags_idx\" ON \"public\".\"news\" USING \"gin\" (\"tags\");\n\nALTER TABLE ONLY \"public\".\"news\"\n    ADD CONSTRAINT \"news_category_fkey\" FOREIGN KEY (\"category\") REFERENCES \"public\".\"categories\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE ONLY \"public\".\"news\"\n    ADD CONSTRAINT \"news_created_by_fkey\" FOREIGN KEY (\"created_by\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE ONLY \"public\".\"news\"\n    ADD CONSTRAINT \"news_profile_badge_fkey\" FOREIGN KEY (\"profile_badge\") REFERENCES \"public\".\"profile_badges\"(\"id\") ON DELETE SET NULL;\n\nALTER TABLE \"public\".\"news\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"news\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n"
  },
  {
    "path": "supabase/schemas/notifications.sql",
    "content": "CREATE TYPE \"public\".\"notification_action_types\" AS ENUM (\n    'newComment',\n    'newContent'\n);\nALTER TYPE \"public\".\"notification_action_types\" OWNER TO \"postgres\";\n\nCREATE TYPE \"public\".\"notification_content_types\" AS ENUM (\n    'news',\n    'research',\n    'researchUpdate',\n    'library',\n    'questions',\n    'comment',\n    'reply'\n);\nALTER TYPE \"public\".\"notification_content_types\" OWNER TO \"postgres\";\n\nCREATE TYPE \"public\".\"notification_source_content_type\" AS ENUM (\n    'news',\n    'research',\n    'researchUpdate',\n    'library',\n    'questions'\n);\nALTER TYPE \"public\".\"notification_source_content_type\" OWNER TO \"postgres\";\n\nCREATE TABLE IF NOT EXISTS \"public\".\"notifications\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"modified_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\"),\n    \"title\" text,\n    \"owned_by_id\" bigint NOT NULL,\n    \"triggered_by_id\" bigint NOT NULL,\n    \"content_type\" \"public\".\"notification_content_types\" NOT NULL,\n    \"content_id\" bigint NOT NULL,\n    \"is_read\" boolean DEFAULT false,\n    \"action_type\" \"public\".\"notification_action_types\" NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"source_content_type\" \"text\",\n    \"source_content_id\" bigint,\n    \"parent_comment_id\" bigint,\n    \"parent_content_id\" bigint,\n    \"should_email\" boolean\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"notifications_preferences\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"user_id\" bigint NOT NULL,\n    \"comments\" boolean NOT NULL,\n    \"replies\" boolean NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"research_updates\" boolean NOT NULL,\n    \"is_unsubscribed\" boolean DEFAULT false NOT NULL\n);\n\nALTER TABLE ONLY \"public\".\"notifications\"\n    ADD CONSTRAINT \"notifications_owned_by_id_fkey\" FOREIGN KEY (\"owned_by_id\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE ONLY \"public\".\"notifications_preferences\"\n    ADD CONSTRAINT \"notifications_preferences_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"profiles\"(\"id\");\n\nALTER TABLE ONLY \"public\".\"notifications\"\n    ADD CONSTRAINT \"notifications_triggered_by_id_fkey\" FOREIGN KEY (\"triggered_by_id\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE;\n\nALTER TABLE \"public\".\"notifications\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"notifications_preferences\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"notifications\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"notifications_preferences\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n\nCREATE OR REPLACE FUNCTION public.get_subscribed_users_emails_to_notify(p_content_id bigint, p_content_type text)\n RETURNS TABLE(\n    email character varying,\n    profile_id bigint,\n    profile_created_at timestamp with time zone,\n    display_name character varying,\n    comments boolean,\n    replies boolean,\n    research_updates boolean,\n    is_unsubscribed boolean\n)\n LANGUAGE plpgsql\n SECURITY DEFINER\nAS $function$\nBEGIN\n    RETURN QUERY\n    SELECT DISTINCT ON (p.id)\n        u.email,\n        p.id AS profile_id,\n        p.created_at AS profile_created_at,\n        p.display_name::character varying,\n        np.comments,\n        np.replies,\n        np.research_updates,\n        np.is_unsubscribed\n    FROM subscribers s\n    INNER JOIN profiles p ON s.user_id = p.id\n    INNER JOIN auth.users u ON p.auth_id = u.id\n    LEFT JOIN notifications_preferences np ON np.user_id = p.id\n    WHERE s.content_id = p_content_id AND s.content_type = p_content_type\n    ORDER BY p.id;\nEND;\n$function$\n;"
  },
  {
    "path": "supabase/schemas/patreon.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"patreon_settings\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"tiers\" \"json\",\n    \"tenant_id\" \"text\"\n);\n\nALTER TABLE \"public\".\"patreon_settings\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"patreon_settings\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n"
  },
  {
    "path": "supabase/schemas/profiles.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"profile_badges\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"name\" \"text\" NOT NULL,\n    \"display_name\" \"text\" NOT NULL,\n    \"image_url\" \"text\" NOT NULL,\n    \"action_url\" \"text\",\n    \"tenant_id\" \"text\" NOT NULL,\n    \"premium_tier\" integer DEFAULT NULL\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"profile_badges_relations\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"profile_id\" bigint NOT NULL,\n    \"profile_badge_id\" bigint NOT NULL,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"profile_tags\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"name\" \"text\" NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"profile_type\" \"text\"\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"upgrade_badge\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"action_label\" \"text\" NOT NULL,\n    \"badge_id\" bigint NOT NULL,\n    \"is_space\" boolean NOT NULL,\n    \"action_url\" \"text\" NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"profile_tags_relations\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"profile_id\" bigint NOT NULL,\n    \"profile_tag_id\" bigint NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"profile_types\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"name\" \"text\" NOT NULL,\n    \"display_name\" \"text\" NOT NULL,\n    \"order\" smallint NOT NULL,\n    \"image_url\" \"text\" NOT NULL,\n    \"small_image_url\" \"text\" NOT NULL,\n    \"description\" \"text\" NOT NULL,\n    \"map_pin_name\" \"text\" NOT NULL,\n    \"is_space\" boolean NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"profiles\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"firebase_auth_id\" \"text\",\n    \"display_name\" \"text\" NOT NULL,\n    \"country\" \"text\",\n    \"about\" \"text\",\n    \"tenant_id\" \"text\" NOT NULL,\n    \"username\" \"text\",\n    \"roles\" \"text\"[],\n    \"impact\" \"json\",\n    \"is_blocked_from_messaging\" boolean,\n    \"is_contactable\" boolean default true,\n    \"is_supporter\" boolean,\n    \"patreon\" \"json\",\n    \"total_views\" integer,\n    \"type\" \"text\",\n    \"auth_id\" \"uuid\",\n    \"legacy_id\" \"text\",\n    \"cover_images\" \"json\"[],\n    \"last_active\" timestamp with time zone,\n    \"photo\" \"json\",\n    \"visitor_policy\" \"json\",\n    \"website\" \"text\",\n    \"profile_type\" bigint,\n    \"donations_enabled\" boolean DEFAULT false NOT NULL\n);\n\nALTER TABLE ONLY \"public\".\"profiles\"\n    ADD CONSTRAINT \"profiles_auth_id_tenant_id_key\" UNIQUE (\"auth_id\", \"tenant_id\");\n\nCREATE INDEX \"idx_profile_badges_relations_badge_id\" ON \"public\".\"profile_badges_relations\" USING \"btree\" (\"profile_badge_id\");\nCREATE INDEX \"idx_profile_badges_relations_profile_id\" ON \"public\".\"profile_badges_relations\" USING \"btree\" (\"profile_id\");\nCREATE INDEX \"idx_profile_badges_relations_tenant_id\" ON \"public\".\"profile_badges_relations\" USING \"btree\" (\"tenant_id\");\nCREATE INDEX \"idx_profile_badges_tenant_id\" ON \"public\".\"profile_badges\" USING \"btree\" (\"tenant_id\");\nCREATE INDEX \"idx_profile_tags_relations_profile_id\" ON \"public\".\"profile_tags_relations\" USING \"btree\" (\"profile_id\");\nCREATE INDEX \"idx_profile_tags_relations_tag_id\" ON \"public\".\"profile_tags_relations\" USING \"btree\" (\"profile_tag_id\");\nCREATE INDEX \"idx_profile_tags_relations_tenant_id\" ON \"public\".\"profile_tags_relations\" USING \"btree\" (\"tenant_id\");\nCREATE INDEX \"idx_profile_tags_tenant_id\" ON \"public\".\"profile_tags\" USING \"btree\" (\"tenant_id\");\nCREATE INDEX \"idx_profile_types_tenant_id\" ON \"public\".\"profile_types\" USING \"btree\" (\"tenant_id\");\nCREATE INDEX \"idx_profiles_profile_type\" ON \"public\".\"profiles\" USING \"btree\" (\"profile_type\");\nCREATE INDEX \"idx_upgrade_badge_tenant_id\" ON \"public\".\"upgrade_badge\" USING \"btree\" (\"tenant_id\");\nCREATE INDEX \"idx_upgrade_badge_is_space\" ON \"public\".\"upgrade_badge\" USING \"btree\" (\"is_space\");\n\nCREATE INDEX \"profile_badges_relations_profile_tenant_idx\" ON \"public\".\"profile_badges_relations\" USING \"btree\" (\"profile_id\", \"tenant_id\");\nCREATE INDEX \"profile_tags_relations_profile_tenant_idx\" ON \"public\".\"profile_tags_relations\" USING \"btree\" (\"profile_id\", \"tenant_id\");\nCREATE INDEX \"profiles_firebase_auth_id_idx\" ON \"public\".\"profiles\" USING \"btree\" (\"firebase_auth_id\");\nCREATE INDEX \"profiles_tenant_created_at_idx\" ON \"public\".\"profiles\" USING \"btree\" (\"tenant_id\", \"created_at\" DESC);\nCREATE UNIQUE INDEX \"profiles_username_tenant_id_key\" ON \"public\".\"profiles\" (\"username\", \"tenant_id\") WHERE (\"username\" IS NOT NULL);\n\nALTER TABLE ONLY \"public\".\"profile_badges_relations\"\n    ADD CONSTRAINT \"profile_badges_relations_profile_badge_id_fkey\" FOREIGN KEY (\"profile_badge_id\") REFERENCES \"public\".\"profile_badges\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE ONLY \"public\".\"profile_badges_relations\"\n    ADD CONSTRAINT \"profile_badges_relations_profile_id_fkey\" FOREIGN KEY (\"profile_id\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE ONLY \"public\".\"profile_tags_relations\"\n    ADD CONSTRAINT \"profile_tags_relations_profile_id_fkey\" FOREIGN KEY (\"profile_id\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE ONLY \"public\".\"profile_tags_relations\"\n    ADD CONSTRAINT \"profile_tags_relations_profile_tag_id_fkey\" FOREIGN KEY (\"profile_tag_id\") REFERENCES \"public\".\"profile_tags\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE ONLY \"public\".\"profiles\"\n    ADD CONSTRAINT \"profiles_auth_id_fkey\" FOREIGN KEY (\"auth_id\") REFERENCES \"auth\".\"users\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE ONLY \"public\".\"profiles\"\n    ADD CONSTRAINT \"profiles_profile_type_fkey\" FOREIGN KEY (\"profile_type\") REFERENCES \"public\".\"profile_types\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE ONLY \"public\".\"upgrade_badge\"\n    ADD CONSTRAINT \"upgrade_badge_badge_id_fkey\" FOREIGN KEY (\"badge_id\") REFERENCES \"public\".\"profile_badges\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE \"public\".\"profile_badges\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"profile_badges_relations\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"profile_tags\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"profile_tags_relations\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"profile_types\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"profiles\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"upgrade_badge\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"profile_badges\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"profile_badges_relations\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"profile_tags\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"profile_tags_relations\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"profile_types\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"profiles\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"upgrade_badge\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n\nCREATE OR REPLACE FUNCTION \"public\".\"is_username_available\"(\"username\" \"text\", \"exclude_profile_id\" integer DEFAULT NULL)\n    RETURNS boolean\n    LANGUAGE \"sql\" STABLE SECURITY DEFINER\n    SET search_path = public, pg_temp\n    AS $_$\n  SELECT CASE WHEN $1 IS NULL THEN false\n  ELSE NOT EXISTS (\n    SELECT 1 FROM profiles\n    WHERE LOWER(profiles.username) = LOWER($1)\n      AND ($2 IS NULL OR profiles.id != $2)\n      AND tenant_id = ((SELECT current_setting('request.headers', true))::json ->> 'x-tenant-id')\n  ) END;\n$_$;\n"
  },
  {
    "path": "supabase/schemas/projects.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"project_steps\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"project_id\" bigint NOT NULL,\n    \"title\" \"text\" NOT NULL,\n    \"description\" \"text\" NOT NULL,\n    \"images\" \"json\",\n    \"video_url\" \"text\",\n    \"order\" smallint,\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"projects\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"modified_at\" timestamp with time zone,\n    \"title\" \"text\" NOT NULL,\n    \"slug\" \"text\" NOT NULL,\n    \"previous_slugs\" \"text\"[],\n    \"description\" \"text\" NOT NULL,\n    \"created_by\" bigint,\n    \"deleted\" boolean,\n    \"category\" bigint,\n    \"difficulty_level\" \"text\",\n    \"cover_image\" \"json\",\n    \"file_link\" \"text\",\n    \"files\" \"json\"[],\n    \"tags\" \"text\"[],\n    \"is_draft\" boolean,\n    \"time\" \"text\",\n    \"file_download_count\" integer,\n    \"moderation\" \"text\",\n    \"moderation_feedback\" \"text\",\n    \"tenant_id\" \"text\" NOT NULL,\n    \"fts\" \"tsvector\",\n    \"total_views\" bigint,\n    \"comment_count\" integer,\n    \"legacy_id\" \"text\",\n    \"published_at\" timestamp with time zone\n);\n\nALTER TABLE ONLY \"public\".\"projects\"\n    ADD CONSTRAINT \"projects_slug_key\" UNIQUE (\"slug\", \"tenant_id\");\n\nALTER TABLE ONLY \"public\".\"projects\"\n    ADD CONSTRAINT \"projects_title_key\" UNIQUE (\"title\", \"tenant_id\");\n\nCREATE INDEX \"projects_created_by_idx\" ON \"public\".\"projects\" USING \"btree\" (\"created_by\");\n\n\nALTER TABLE ONLY \"public\".\"project_steps\"\n    ADD CONSTRAINT \"project_steps_project_id_fkey\" FOREIGN KEY (\"project_id\") REFERENCES \"public\".\"projects\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE ONLY \"public\".\"projects\"\n    ADD CONSTRAINT \"projects_category_id_fkey\" FOREIGN KEY (\"category\") REFERENCES \"public\".\"categories\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE ONLY \"public\".\"projects\"\n    ADD CONSTRAINT \"projects_created_by_fkey\" FOREIGN KEY (\"created_by\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE \"public\".\"project_steps\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"projects\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"project_steps\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"projects\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n\n\nCREATE OR REPLACE FUNCTION \"public\".\"combined_project_search_fields\"(\"project_id_param\" bigint) RETURNS \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $$\n  SELECT\n    (SELECT p.title || ' ' || p.description FROM projects p WHERE p.id = project_id_param) || ' ' ||\n    COALESCE(string_agg(ps.title || ' ' || ps.description, ' '), '')\n  FROM project_steps ps\n  WHERE ps.project_id = project_id_param;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_projects\"(\"search_query\" \"text\" DEFAULT NULL::\"text\", \"category_id\" bigint DEFAULT NULL::bigint, \"sort_by\" \"text\" DEFAULT 'Newest'::\"text\", \"limit_val\" integer DEFAULT 12, \"offset_val\" integer DEFAULT 0, \"current_username\" \"text\" DEFAULT NULL::\"text\", \"days_back\" integer DEFAULT 7) RETURNS TABLE(\"id\" bigint, \"created_at\" timestamp with time zone, \"created_by\" bigint, \"modified_at\" timestamp with time zone, \"description\" \"text\", \"slug\" \"text\", \"cover_image\" \"json\", \"category\" \"json\", \"tags\" \"text\"[], \"title\" \"text\", \"moderation\" \"text\", \"total_views\" bigint, \"author\" \"json\", \"comment_count\" integer, \"useful_votes_last_week\" integer)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$DECLARE\n    ts_query tsquery;\nBEGIN\n    -- Parse search query once if provided, using prefix matching for partial words\n    IF search_query IS NOT NULL THEN\n        -- Split search query into words and create prefix-matching tsquery with AND logic\n        -- Sanitize each word to prevent tsquery injection\n        ts_query := to_tsquery('english', \n          array_to_string(\n            ARRAY(\n              SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n              FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n              WHERE word != '' \n                AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n            ),\n            ' & '\n          )\n        );\n    END IF;\n    \n    RETURN QUERY\n    SELECT\n        p.id,\n        p.created_at,\n        p.created_by,\n        p.modified_at,\n        p.description,\n        p.slug,\n        p.cover_image,\n        (SELECT json_build_object('id', c.id, 'name', c.name)\n         FROM categories c\n         WHERE c.id = p.category) AS category,\n        p.tags,\n        p.title,\n        p.moderation,\n        p.total_views,\n        (SELECT json_build_object(\n          'id', prof.id,\n          'display_name', prof.display_name,\n          'username', prof.username,\n          'country', prof.country,\n          'badges', COALESCE(\n            (SELECT json_agg(\n              json_build_object(\n                'id', pb.id,\n                'name', pb.name,\n                'display_name', pb.display_name,\n                'image_url', pb.image_url,\n                'action_url', pb.action_url\n                )\n              )\n              FROM profile_badges_relations pbr\n              JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n              WHERE pbr.profile_id = prof.id),\n              '[]'::json\n          )\n        ) FROM profiles prof WHERE prof.id = p.created_by) AS author,\n        p.comment_count,\n        (SELECT COALESCE(COUNT(uv.id), 0)::integer\n         FROM useful_votes uv\n         WHERE uv.content_id = p.id\n           AND uv.content_type = 'projects'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n    FROM projects p\n    JOIN profiles prof ON prof.id = p.created_by  -- Add explicit JOIN\n    WHERE\n        (search_query IS NULL OR\n         p.fts @@ ts_query OR\n         prof.username ILIKE '%' || search_query || '%'\n        ) AND\n        (category_id IS NULL OR p.category = category_id) AND\n        (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n        (p.deleted IS NULL OR p.deleted = FALSE) AND\n        (p.moderation = 'accepted' OR prof.username = current_username)\n    ORDER BY\n        -- Add relevance ranking when search query is provided\n        CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(p.fts, ts_query) END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'Newest' THEN extract(epoch from p.published_at)\n            WHEN sort_by = 'LatestUpdated' THEN extract(epoch from p.modified_at)\n            WHEN sort_by = 'MostComments' THEN p.comment_count\n            WHEN sort_by = 'MostDownloads' THEN p.file_download_count\n            WHEN sort_by = 'MostUseful' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id AND uv.content_type = 'projects')\n            WHEN sort_by = 'MostUsefulLastWeek' THEN\n                (SELECT COALESCE(COUNT(uv.id), 0)\n                 FROM useful_votes uv\n                 WHERE uv.content_id = p.id\n                   AND uv.content_type = 'projects'\n                   AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n            WHEN sort_by = 'MostViews' THEN p.total_views\n            ELSE 0\n        END DESC NULLS LAST,\n        CASE\n            WHEN sort_by = 'LeastComments' THEN p.comment_count\n        END ASC NULLS LAST,\n        p.published_at DESC\n    LIMIT limit_val OFFSET offset_val;\nEND;$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_projects_count\"(\"search_query\" \"text\" DEFAULT NULL::\"text\", \"category_id\" integer DEFAULT NULL::integer, \"current_username\" \"text\" DEFAULT NULL::\"text\") RETURNS integer\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nDECLARE\n  ts_query tsquery;\nBEGIN\n    IF search_query IS NOT NULL THEN\n      -- Split search query into words and create prefix-matching tsquery with AND logic\n      -- Sanitize each word to prevent tsquery injection\n      ts_query := to_tsquery('english', \n        array_to_string(\n          ARRAY(\n            SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n            FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n            WHERE word != '' \n              AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n          ),\n          ' & '\n        )\n      );\n    END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM projects p\n    INNER JOIN profiles prof ON prof.id = p.created_by\n    WHERE\n      (search_query IS NULL OR\n       p.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR p.category = category_id) AND\n      (p.is_draft IS NULL OR p.is_draft = FALSE) AND\n      (p.deleted IS NULL OR p.deleted = FALSE) AND\n      (p.moderation = 'accepted' OR prof.username = current_username)\n  );\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION public.get_user_projects(username_param text)\n RETURNS TABLE(id bigint, comment_count integer, cover_image json, title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    pr.id,\n    pr.comment_count,\n    pr.cover_image,\n    pr.title,\n    pr.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM projects pr\n  INNER JOIN profiles p ON p.id = pr.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = pr.id AND uv.content_type = 'projects'\n  WHERE p.username = username_param\n  AND (pr.deleted IS NULL OR pr.deleted = FALSE)\n  AND (pr.is_draft IS NULL OR pr.is_draft = FALSE)\n  AND (pr.moderation = 'accepted')\n  GROUP BY pr.id, pr.title, pr.slug;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION \"public\".\"update_project_tsvector\"() RETURNS \"trigger\"\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  IF TG_TABLE_NAME = 'project_steps' THEN\n    UPDATE projects\n    SET fts = to_tsvector('english', public.combined_project_search_fields(NEW.project_id))\n    WHERE id = NEW.project_id;\n  ELSEIF TG_TABLE_NAME = 'projects' THEN\n    UPDATE projects\n    SET fts = to_tsvector('english', public.combined_project_search_fields(NEW.id))\n    WHERE id = NEW.id;\n  END IF;\n  RETURN NEW;\nEND;\n$$;\n\nCREATE OR REPLACE TRIGGER \"project_step_trigger\" AFTER INSERT OR DELETE OR UPDATE OF \"title\", \"description\" ON \"public\".\"project_steps\" FOR EACH ROW EXECUTE FUNCTION \"public\".\"update_project_tsvector\"();\nCREATE OR REPLACE TRIGGER \"project_text_trigger\" AFTER INSERT OR UPDATE OF \"title\", \"description\" ON \"public\".\"projects\" FOR EACH ROW EXECUTE FUNCTION \"public\".\"update_project_tsvector\"();\n"
  },
  {
    "path": "supabase/schemas/questions.sql",
    "content": "CREATE OR REPLACE FUNCTION public.get_user_questions(username_param text)\n RETURNS TABLE(id bigint, comment_count bigint, images json[], title text, slug text, total_useful bigint)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nBEGIN\n  RETURN QUERY\n  SELECT \n    q.id,\n    q.comment_count,\n    q.images,\n    q.title,\n    q.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM questions q\n  INNER JOIN profiles p ON p.id = q.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = q.id AND uv.content_type = 'questions'\n  WHERE p.username = username_param\n  AND (q.deleted IS NULL OR q.deleted = FALSE)\n  GROUP BY q.id, q.title, q.slug;\nEND;\n$function$\n;\n\nCREATE TABLE IF NOT EXISTS \"public\".\"questions\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"created_by\" bigint,\n    \"deleted\" boolean,\n    \"modified_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"comment_count\" bigint DEFAULT '0'::bigint,\n    \"description\" \"text\" NOT NULL,\n    \"moderation\" \"text\",\n    \"slug\" \"text\" NOT NULL,\n    \"previous_slugs\" \"text\"[],\n    \"category\" bigint,\n    \"tags\" bigint[],\n    \"title\" \"text\" NOT NULL,\n    \"total_views\" bigint,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"fts\" \"tsvector\" GENERATED ALWAYS AS (\"to_tsvector\"('\"english\"'::\"regconfig\", ((\"title\" || ' '::\"text\") || \"description\"))) STORED,\n    \"images\" \"json\"[],\n    \"legacy_id\" \"text\",\n    \"is_draft\" boolean DEFAULT false NOT NULL,\n    \"published_at\" timestamp with time zone\n);\n\nCREATE OR REPLACE FUNCTION \"public\".\"questions_search_fields\"(\"public\".\"questions\") RETURNS \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $_$\n  SELECT $1.title || ' ' || $1.description;\n$_$;\n\nCREATE OR REPLACE FUNCTION public.get_questions(search_query text DEFAULT NULL::text, category_id bigint DEFAULT NULL::bigint, sort_by text DEFAULT 'Newest'::text, limit_val integer DEFAULT 20, offset_val integer DEFAULT 0)\n RETURNS TABLE(id bigint, created_at timestamp with time zone, created_by bigint, modified_at timestamp with time zone, published_at timestamp with time zone, description text, slug text, category json, tags bigint[], title text, total_views bigint, is_draft boolean, comment_count bigint, images json[], author json)\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words and create prefix-matching tsquery with AND logic\n    -- Sanitize each word to prevent tsquery injection\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != '' \n            AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n\n  RETURN QUERY\n  SELECT\n    q.id,\n    q.created_at,\n    q.created_by,\n    q.modified_at,\n    q.published_at,\n    q.description,\n    q.slug,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = q.category) AS category,\n    q.tags,\n    q.title,\n    q.total_views,\n    q.is_draft,\n    q.comment_count,\n    q.images,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'photo', p.photo,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = q.created_by) AS author\n  FROM questions q\n  JOIN profiles prof ON prof.id = q.created_by\n  WHERE\n    (search_query IS NULL OR\n     q.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR q.category = category_id) AND\n    q.is_draft = FALSE AND\n    (q.deleted IS NULL OR q.deleted = FALSE)\n  ORDER BY\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(q.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from q.published_at)\n      WHEN sort_by = 'Comments' THEN q.comment_count\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN q.comment_count\n    END ASC NULLS LAST,\n    q.published_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$function$\n;\n\nCREATE OR REPLACE FUNCTION public.get_questions_count(search_query text DEFAULT NULL::text, category_id integer DEFAULT NULL::integer)\n RETURNS integer\n LANGUAGE plpgsql\n SET search_path TO 'public', 'pg_temp'\nAS $function$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words and create prefix-matching tsquery with AND logic\n    -- Sanitize each word to prevent tsquery injection\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != '' \n            AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM questions q\n    INNER JOIN profiles prof ON prof.id = q.created_by\n    WHERE\n      (search_query IS NULL OR\n       q.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR q.category = category_id) AND\n      q.is_draft = FALSE AND\n      (q.deleted IS NULL OR q.deleted = FALSE)\n  );\nEND;\n$function$\n;\n\nALTER TABLE ONLY \"public\".\"questions\"\n    ADD CONSTRAINT \"unique_tenant_slug\" UNIQUE (\"tenant_id\", \"slug\");\n\nCREATE INDEX \"questions_category_idx\" ON \"public\".\"questions\" USING \"btree\" (\"category\");\nCREATE INDEX \"questions_created_by_idx\" ON \"public\".\"questions\" USING \"btree\" (\"created_by\");\nCREATE INDEX \"questions_deleted_moderation_category_total_views_tags_crea_idx\" ON \"public\".\"questions\" USING \"btree\" (\"deleted\", \"moderation\", \"category\", \"total_views\", \"tags\", \"created_at\", \"comment_count\", \"created_by\");\nCREATE INDEX \"questions_tags_idx\" ON \"public\".\"questions\" USING \"gin\" (\"tags\");\n\nALTER TABLE ONLY \"public\".\"questions\"\n    ADD CONSTRAINT \"question_created_by_fkey\" FOREIGN KEY (\"created_by\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE ONLY \"public\".\"questions\"\n    ADD CONSTRAINT \"questions_category_fkey\" FOREIGN KEY (\"category\") REFERENCES \"public\".\"categories\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE \"public\".\"questions\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"questions\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n"
  },
  {
    "path": "supabase/schemas/research.sql",
    "content": "CREATE TYPE \"public\".\"research_status\" AS ENUM (\n    'in-progress',\n    'complete',\n    'archived'\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"research\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"modified_at\" timestamp with time zone DEFAULT \"now\"(),\n    \"title\" \"text\" NOT NULL,\n    \"slug\" \"text\" NOT NULL,\n    \"description\" \"text\" NOT NULL,\n    \"category\" bigint,\n    \"created_by\" bigint,\n    \"tags\" \"text\"[],\n    \"deleted\" boolean,\n    \"total_views\" integer,\n    \"total_useful\" integer,\n    \"previous_slugs\" \"text\"[],\n    \"status\" \"public\".\"research_status\",\n    \"is_draft\" boolean,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"fts\" \"tsvector\",\n    \"collaborators\" \"text\"[],\n    \"image\" \"json\",\n    \"legacy_id\" \"text\",\n    \"published_at\" timestamp with time zone\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"research_updates\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"research_id\" bigint NOT NULL,\n    \"title\" \"text\" NOT NULL,\n    \"description\" \"text\" NOT NULL,\n    \"images\" \"json\"[],\n    \"files\" \"json\"[],\n    \"video_url\" \"text\",\n    \"is_draft\" boolean,\n    \"comment_count\" integer,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"modified_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\"),\n    \"deleted\" boolean,\n    \"file_link\" \"text\",\n    \"file_download_count\" integer,\n    \"created_by\" bigint,\n    \"legacy_id\" \"text\",\n    \"published_at\" timestamp with time zone\n);\n\nCREATE INDEX \"research_created_by_idx\" ON \"public\".\"research\" USING \"btree\" (\"created_by\");\nCREATE INDEX \"research_fts_idx\" ON \"public\".\"research\" USING \"gin\" (\"fts\");\nCREATE INDEX \"research_updates_created_by_idx\" ON \"public\".\"research_updates\" USING \"btree\" (\"created_by\");\n\nALTER TABLE ONLY \"public\".\"research\"\n    ADD CONSTRAINT \"research_category_fkey\" FOREIGN KEY (\"category\") REFERENCES \"public\".\"categories\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE ONLY \"public\".\"research\"\n    ADD CONSTRAINT \"research_created_by_fkey\" FOREIGN KEY (\"created_by\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE ONLY \"public\".\"research_updates\"\n    ADD CONSTRAINT \"research_update_research_id_fkey\" FOREIGN KEY (\"research_id\") REFERENCES \"public\".\"research\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE ONLY \"public\".\"research_updates\"\n    ADD CONSTRAINT \"research_updates_created_by_fkey\" FOREIGN KEY (\"created_by\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE SET NULL;\n\nALTER TABLE \"public\".\"research\" ENABLE ROW LEVEL SECURITY;\nALTER TABLE \"public\".\"research_updates\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"research\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"research_updates\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n\n\nCREATE OR REPLACE FUNCTION \"public\".\"combined_research_search_fields\"(\"research_id_param\" bigint) RETURNS \"text\"\n    LANGUAGE \"sql\"\n    SET search_path = public, pg_temp\n    AS $$\n  SELECT\n    (SELECT r.title || ' ' || r.description FROM research r WHERE r.id = research_id_param) || ' ' ||\n    COALESCE(string_agg(ru.title || ' ' || ru.description, ' '), '')\n  FROM research_updates ru\n  WHERE ru.research_id = research_id_param;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_research\"(\"search_query\" \"text\" DEFAULT NULL::\"text\", \"category_id\" bigint DEFAULT NULL::bigint, \"research_status\" \"public\".\"research_status\" DEFAULT NULL::\"public\".\"research_status\", \"sort_by\" \"text\" DEFAULT 'Newest'::\"text\", \"limit_val\" integer DEFAULT 10, \"offset_val\" integer DEFAULT 0, \"days_back\" integer DEFAULT 7) RETURNS TABLE(\"id\" bigint, \"created_at\" timestamp with time zone, \"created_by\" bigint, \"modified_at\" timestamp with time zone, \"description\" \"text\", \"slug\" \"text\", \"image\" \"json\", \"status\" \"public\".\"research_status\", \"category\" \"json\", \"tags\" \"text\"[], \"title\" \"text\", \"total_views\" integer, \"author\" \"json\", \"update_count\" bigint, \"comment_count\" bigint, \"useful_votes_last_week\" integer)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  -- Parse the search query once if provided, using prefix matching for partial words\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words and create prefix-matching tsquery with AND logic\n    -- Sanitize each word to prevent tsquery injection\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != '' \n            AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n \n  RETURN QUERY\n  SELECT\n    r.id,\n    r.created_at,\n    r.created_by,\n    GREATEST(\n      r.modified_at,\n      COALESCE(\n        (SELECT MAX(ru.modified_at) FROM research_updates ru\n         WHERE ru.research_id = r.id\n           AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n           AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n        r.modified_at\n      )\n    ) AS modified_at,\n    r.description,\n    r.slug,\n    r.image,\n    r.status,\n    (SELECT json_build_object('id', c.id, 'name', c.name) FROM categories c WHERE c.id = r.category) AS category,\n    r.tags,\n    r.title,\n    r.total_views,\n    (SELECT json_build_object(\n      'id', p.id,\n      'display_name', p.display_name,\n      'username', p.username,\n      'country', p.country,\n      'badges', COALESCE(\n        (SELECT json_agg(\n          json_build_object(\n            'id', pb.id,\n            'name', pb.name,\n            'display_name', pb.display_name,\n            'image_url', pb.image_url,\n            'action_url', pb.action_url\n          )\n        )\n        FROM profile_badges_relations pbr\n        JOIN profile_badges pb ON pb.id = pbr.profile_badge_id\n        WHERE pbr.profile_id = p.id),\n        '[]'::json\n      )\n    ) FROM profiles p WHERE p.id = r.created_by) AS author,\n    (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS update_count,\n    (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE)) AS comment_count,\n    (SELECT COALESCE(COUNT(uv.id), 0)::integer\n     FROM useful_votes uv\n     WHERE uv.content_id = r.id\n       AND uv.content_type = 'research'\n       AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back) AS useful_votes_last_week\n  FROM research r\n  JOIN profiles prof ON prof.id = r.created_by\n  WHERE\n    (search_query IS NULL OR\n     r.fts @@ ts_query OR\n     prof.username ILIKE '%' || search_query || '%'\n    ) AND\n    (category_id IS NULL OR r.category = category_id) AND\n    (research_status IS NULL OR r.status = research_status) AND\n    (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n    (r.deleted IS NULL OR r.deleted = FALSE)\n  ORDER BY\n    -- Add relevance ranking when search query is provided\n    CASE WHEN search_query IS NOT NULL THEN ts_rank_cd(r.fts, ts_query) END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'Newest' THEN extract(epoch from r.published_at)\n      WHEN sort_by = 'LatestUpdated' THEN extract(epoch from\n        GREATEST(\n          r.modified_at,\n          COALESCE(\n            (SELECT MAX(ru.modified_at) FROM research_updates ru\n             WHERE ru.research_id = r.id\n               AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n               AND (ru.deleted IS NULL OR ru.deleted = FALSE)),\n            r.modified_at\n          )\n        )\n      )\n      WHEN sort_by = 'MostComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      WHEN sort_by = 'MostUseful' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv WHERE uv.content_id = r.id AND uv.content_type = 'research')\n      WHEN sort_by = 'MostUsefulLastWeek' THEN\n        (SELECT COALESCE(COUNT(uv.id), 0) FROM useful_votes uv\n         WHERE uv.content_id = r.id\n           AND uv.content_type = 'research'\n           AND uv.created_at >= NOW() - INTERVAL '1 day' * days_back)\n      WHEN sort_by = 'MostUpdates' THEN\n        (SELECT COUNT(*) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n      ELSE 0\n    END DESC NULLS LAST,\n    CASE\n      WHEN sort_by = 'LeastComments' THEN\n        (SELECT COALESCE(SUM(ru.comment_count), 0) FROM research_updates ru WHERE ru.research_id = r.id AND (ru.is_draft IS NULL OR ru.is_draft = FALSE)\n          AND (ru.deleted IS NULL OR ru.deleted = FALSE))\n    END ASC NULLS LAST,\n    r.published_at DESC\n  LIMIT limit_val OFFSET offset_val;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_research_count\"(\"search_query\" \"text\" DEFAULT NULL::\"text\", \"category_id\" integer DEFAULT NULL::integer, \"research_status\" \"public\".\"research_status\" DEFAULT NULL::\"public\".\"research_status\") RETURNS integer\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nDECLARE\n  ts_query tsquery;\nBEGIN\n  IF search_query IS NOT NULL THEN\n    -- Split search query into words and create prefix-matching tsquery with AND logic\n    -- Sanitize each word to prevent tsquery injection\n    ts_query := to_tsquery('english', \n      array_to_string(\n        ARRAY(\n          SELECT quote_literal(regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g')) || ':*'\n          FROM unnest(string_to_array(trim(search_query), ' ')) AS word\n          WHERE word != '' \n            AND regexp_replace(lower(word), '[^a-z0-9_-]', '', 'g') != ''\n        ),\n        ' & '\n      )\n    );\n  END IF;\n\n  RETURN (\n    SELECT COUNT(*)\n    FROM research r\n    INNER JOIN profiles prof ON prof.id = r.created_by\n    WHERE\n      (search_query IS NULL OR\n       r.fts @@ ts_query OR\n       prof.username ILIKE '%' || search_query || '%'\n      ) AND\n      (category_id IS NULL OR r.category = category_id) AND\n      (research_status IS NULL OR r.status = research_status) AND\n      (r.is_draft IS NULL OR r.is_draft = FALSE) AND\n      (r.deleted IS NULL OR r.deleted = FALSE)\n  );\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_user_research\"(username_param text) \n RETURNS TABLE(\"id\" bigint, image json, title text, slug text, total_useful bigint)\n LANGUAGE \"plpgsql\"\n SET search_path = public, pg_temp\nAS $$\nBEGIN\n  RETURN QUERY\n  SELECT \n    r.id,\n    r.image,\n    r.title,\n    r.slug,\n    COALESCE(COUNT(uv.id), 0)::BIGINT AS total_useful\n  FROM research r\n  INNER JOIN profiles p ON p.id = r.created_by\n  LEFT JOIN useful_votes uv ON uv.content_id = r.id AND uv.content_type = 'research'\n  WHERE p.username = username_param\n  AND (r.deleted IS NULL OR r.deleted = FALSE)\n  AND (r.is_draft IS NULL OR r.is_draft = FALSE)\n  GROUP BY r.id, r.title, r.slug;\nEND;\n$$;\n\n\nCREATE OR REPLACE FUNCTION \"public\".\"update_research_tsvector\"() RETURNS \"trigger\"\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  IF TG_TABLE_NAME = 'research_updates' THEN\n    UPDATE research\n    SET fts = to_tsvector('english', public.combined_research_search_fields(NEW.research_id))\n    WHERE id = NEW.research_id;\n  ELSEIF TG_TABLE_NAME = 'research' THEN\n    UPDATE research\n    SET fts = to_tsvector('english', public.combined_research_search_fields(NEW.id))\n    WHERE id = NEW.id;\n  END IF;\n  RETURN NEW;\nEND;\n$$;\n\nCREATE OR REPLACE TRIGGER \"research_text_trigger\" AFTER INSERT OR UPDATE OF \"title\", \"description\" ON \"public\".\"research\" FOR EACH ROW EXECUTE FUNCTION \"public\".\"update_research_tsvector\"();\nCREATE OR REPLACE TRIGGER \"research_update_trigger\" AFTER INSERT OR DELETE OR UPDATE OF \"title\", \"description\" ON \"public\".\"research_updates\" FOR EACH ROW EXECUTE FUNCTION \"public\".\"update_research_tsvector\"();\n"
  },
  {
    "path": "supabase/schemas/subscribers.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"subscribers\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT (\"now\"() AT TIME ZONE 'utc'::\"text\") NOT NULL,\n    \"user_id\" bigint NOT NULL,\n    \"content_id\" bigint NOT NULL,\n    \"content_type\" \"text\" NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nALTER TABLE ONLY \"public\".\"subscribers\"\n    ADD CONSTRAINT \"subscribers_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE \"public\".\"subscribers\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"subscribers\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n"
  },
  {
    "path": "supabase/schemas/tags.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"tags\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"name\" \"text\" NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL,\n    \"legacy_id\" \"text\",\n    \"modified_at\" \"date\"\n);\n\nALTER TABLE \"public\".\"tags\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"tags\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n"
  },
  {
    "path": "supabase/schemas/tenant_settings.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"public\".\"tenant_settings\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"site_name\" \"text\" NOT NULL,\n    \"site_description\" \"text\",\n    \"site_url\" \"text\" NOT NULL,\n    \"message_sign_off\" \"text\",\n    \"tenant_id\" \"text\" NOT NULL,\n    \"email_from\" \"text\",\n    \"site_image\" \"text\",\n    \"site_favicon\" \"text\",\n    \"donation_settings\" JSON,\n    \"no_messaging\" BOOLEAN NOT NULL DEFAULT false,\n    \"library_heading\" \"text\",\n    \"academy_resource\" \"text\",\n    \"profile_guidelines\" \"text\",\n    \"questions_guidelines\" \"text\",\n    \"supported_modules\" \"text\",\n    \"patreon_id\" \"text\",\n    \"ga_tracking_id\" \"text\",\n    \"pwa_icons\" \"jsonb\",\n    CONSTRAINT \"check_pwa_icons_schema\" CHECK (\n        \"pwa_icons\" IS NULL\n        OR (\n            jsonb_typeof(\"pwa_icons\") = 'object'\n            AND \"pwa_icons\" - ARRAY['16','32','192','256','512'] = '{}'::jsonb\n            AND (\"pwa_icons\"->>'16')  IS NOT NULL\n            AND (\"pwa_icons\"->>'32')  IS NOT NULL\n            AND (\"pwa_icons\"->>'192') IS NOT NULL\n            AND (\"pwa_icons\"->>'256') IS NOT NULL\n            AND (\"pwa_icons\"->>'512') IS NOT NULL\n            AND jsonb_typeof(\"pwa_icons\"->'16')  = 'string'\n            AND jsonb_typeof(\"pwa_icons\"->'32')  = 'string'\n            AND jsonb_typeof(\"pwa_icons\"->'192') = 'string'\n            AND jsonb_typeof(\"pwa_icons\"->'256') = 'string'\n            AND jsonb_typeof(\"pwa_icons\"->'512') = 'string'\n        )\n    )\n);\n\nALTER TABLE \"public\".\"tenant_settings\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"tenant_settings\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n"
  },
  {
    "path": "supabase/schemas/useful.sql",
    "content": "CREATE TYPE \"public\".\"useful_content_types\" AS ENUM (\n    'questions',\n    'projects',\n    'research',\n    'news',\n    'comments'\n);\n\nCREATE TABLE IF NOT EXISTS \"public\".\"useful_votes\" (\n    \"id\" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    \"created_at\" timestamp with time zone DEFAULT \"now\"() NOT NULL,\n    \"content_id\" bigint NOT NULL,\n    \"content_type\" \"public\".\"useful_content_types\" NOT NULL,\n    \"user_id\" bigint NOT NULL,\n    \"tenant_id\" \"text\" NOT NULL\n);\n\nCREATE INDEX \"useful_votes_comments_content_id_idx\" ON \"public\".\"useful_votes\" USING \"btree\" (\"content_id\") WHERE (\"content_type\" = 'comments'::\"public\".\"useful_content_types\");\nCREATE INDEX \"useful_votes_comments_user_id_content_id_idx\" ON \"public\".\"useful_votes\" USING \"btree\" (\"user_id\", \"content_id\") WHERE (\"content_type\" = 'comments'::\"public\".\"useful_content_types\");\nCREATE INDEX \"useful_votes_content_type_content_id_idx\" ON \"public\".\"useful_votes\" USING \"btree\" (\"content_type\", \"content_id\");\n\nALTER TABLE ONLY \"public\".\"useful_votes\"\n    ADD CONSTRAINT \"useful_votes_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"profiles\"(\"id\") ON UPDATE CASCADE ON DELETE CASCADE;\n\nALTER TABLE \"public\".\"useful_votes\" ENABLE ROW LEVEL SECURITY;\n\nCREATE POLICY \"tenant_isolation\" ON \"public\".\"useful_votes\" USING ((\"tenant_id\" = ((SELECT current_setting('request.headers'::\"text\", true))::\"json\" ->> 'x-tenant-id'::\"text\")));\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_author_vote_counts\"(\"author_id\" bigint) RETURNS TABLE(\"content_type\" \"text\", \"vote_count\" bigint)\n    LANGUAGE \"sql\" STABLE\n    SET search_path = public, pg_temp\n    AS $$\n    SELECT \n        uv.content_type,\n        COUNT(*) as vote_count\n    FROM useful_votes uv\n    WHERE (uv.content_type = 'questions' AND EXISTS (\n        SELECT 1 FROM questions q WHERE q.id = uv.content_id AND q.created_by = author_id and (q.deleted is null or q.deleted = false)\n    ))\n    OR (uv.content_type = 'projects' AND EXISTS (\n        SELECT 1 FROM projects p WHERE p.id = uv.content_id AND p.created_by = author_id and (p.deleted is null or p.deleted = false)\n    ))\n    OR (uv.content_type = 'news' AND EXISTS (\n        SELECT 1 FROM news n WHERE n.id = uv.content_id AND n.created_by = author_id and (n.deleted is null or n.deleted = false)\n    ))\n    OR (uv.content_type = 'research' AND EXISTS (\n        SELECT 1 FROM research r WHERE r.id = uv.content_id AND r.created_by = author_id and (r.deleted is null or r.deleted = false)\n    ))\n    GROUP BY uv.content_type\n    ORDER BY vote_count DESC;\n$$;\n\nCREATE OR REPLACE FUNCTION \"public\".\"get_useful_votes_count_by_content_id\"(\"p_content_type\" \"public\".\"useful_content_types\", \"p_content_ids\" bigint[]) RETURNS TABLE(\"content_id\" bigint, \"count\" bigint)\n    LANGUAGE \"plpgsql\"\n    SET search_path = public, pg_temp\n    AS $$\nBEGIN\n  RETURN QUERY\n  SELECT v.content_id, COUNT(*) as count\n  FROM public.useful_votes v\n  WHERE v.content_type = p_content_type\n    AND v.content_id = ANY(p_content_ids)\n  GROUP BY v.content_id;\nEND;\n$$;\n\n"
  },
  {
    "path": "supabase/seed.sql",
    "content": ""
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es2021\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"types\": [\"vite/client\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"skipDefaultLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"Bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"@emotion/react\",\n    \"noImplicitAny\": false,\n    \"strictPropertyInitialization\": false,\n    \"experimentalDecorators\": true,\n    \"typeRoots\": [\n      \"./node_modules/@types\",\n      \"./types\",\n      \"@testing-library/jest-dom\",\n      \"../src/themes/types\",\n      \"./node_modules\"\n    ],\n    \"declaration\": false,\n    \"noEmit\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"useUnknownInCatchVariables\": false,\n    \"paths\": {\n      \"oa-shared\": [\"./shared/index.ts\"],\n      \"oa-shared/*\": [\"./shared/*\"],\n      \"oa-components\": [\"./packages/components/src/index.ts\"],\n      \"oa-components/*\": [\"./packages/components/src/*\"],\n      \"oa-themes\": [\"./packages/themes/src/index.ts\"],\n      \"oa-themes/*\": [\"./packages/themes/src/*\"],\n      \"*\": [\"./*\"]\n    }\n  },\n  \"include\": [\"src/**/*\", \"types\", \"vite-env.d.ts\"],\n  \"exclude\": [\n    \"node_modules\",\n    \"build\",\n    \"jest\",\n    \"src/setupTests.ts\",\n    \"packages/components\",\n    \"packages/themes\"\n  ]\n}\n"
  },
  {
    "path": "tsconfig.prod.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\"\n}\n"
  },
  {
    "path": "vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n/// <reference types=\"vite-plugin-svgr/client\" />\n/// <reference types=\"vite-plugin-pwa/client\" />\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { reactRouter } from '@react-router/dev/vite';\nimport react from '@vitejs/plugin-react';\nimport { dirname, resolve } from 'path';\nimport { fileURLToPath } from 'url';\n/// <reference types=\"vitest\" />\nimport { defineConfig } from 'vite';\nimport { VitePWA } from 'vite-plugin-pwa';\nimport svgr from 'vite-plugin-svgr';\nimport ViteTsConfigPathsPlugin from 'vite-tsconfig-paths';\n\nimport type { ViteUserConfig } from 'vitest/config';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst vitestConfig: ViteUserConfig = {\n  test: {\n    environment: 'jsdom',\n    globals: true,\n    setupFiles: './src/test/setup.ts',\n    teardownTimeout: 20000,\n    testTimeout: 20000,\n    coverage: {\n      provider: 'v8',\n      reporter: ['text'],\n    },\n    include: ['./src/**/*.test.?(c|m)[jt]s?(x)'],\n    logHeapUsage: true,\n    sequence: {\n      hooks: 'list',\n    },\n  },\n};\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  define: {\n    global: 'globalThis',\n  },\n  build: {\n    target: ['es2020'],\n    sourcemap: process.env.NODE_ENV !== 'production', // to enable local server-side debugging\n    commonjsOptions: {\n      transformMixedEsModules: true,\n    },\n    rollupOptions: {\n      output: {\n        manualChunks(id) {\n          // Split leaflet and related packages into their own chunk\n          if (\n            id.includes('node_modules/leaflet') ||\n            id.includes('node_modules/react-leaflet') ||\n            id.includes('node_modules/@react-leaflet')\n          ) {\n            return 'leaflet';\n          }\n        },\n      },\n    },\n  },\n  plugins: [\n    !process.env.VITEST ? reactRouter() : react(),\n    // TODO - confirm if required (given manual resolutions below)\n    ViteTsConfigPathsPlugin({\n      root: './',\n    }),\n    // support import of svg files\n    svgr(),\n    VitePWA({\n      registerType: 'autoUpdate',\n      includeAssets: ['favicon.ico', 'robots.txt'],\n      injectRegister: null,\n      outDir: 'build/client',\n      base: '/',\n      manifest: false,\n      workbox: {\n        globPatterns: ['**/*.{js,css,ico,png,svg,woff,woff2}'],\n        navigateFallback: null, // Disable for SSR - let server handle navigation\n        runtimeCaching: [\n          {\n            urlPattern: ({ request }) => request.mode === 'navigate',\n            handler: 'NetworkFirst',\n            options: {\n              cacheName: 'pages-cache',\n              networkTimeoutSeconds: 3,\n              expiration: {\n                maxEntries: 50,\n                maxAgeSeconds: 60 * 60 * 24, // 24 hours\n              },\n            },\n          },\n          {\n            urlPattern: /^https:\\/\\/fonts\\.googleapis\\.com\\/.*/i,\n            handler: 'CacheFirst',\n            options: {\n              cacheName: 'google-fonts-cache',\n              expiration: {\n                maxEntries: 10,\n                maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year\n              },\n              cacheableResponse: {\n                statuses: [0, 200],\n              },\n            },\n          },\n          {\n            urlPattern: /^https:\\/\\/storage\\.googleapis\\.com\\/.*/i,\n            handler: 'StaleWhileRevalidate',\n            options: {\n              cacheName: 'gcs-cache',\n              expiration: {\n                maxEntries: 50,\n                maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days\n              },\n              cacheableResponse: {\n                statuses: [0, 200],\n              },\n            },\n          },\n        ],\n      },\n      devOptions: {\n        enabled: false,\n      },\n    }),\n  ],\n  // open browser with server (note, will open at 127.0.1 not localhost on node <17)\n  // https://vitejs.dev/config/server-options.html#server-options\n  ssr: {\n    noExternal: ['remix-utils', '@mui/base', '@mui/utils', '@mui/types'],\n  },\n  resolve: {\n    alias: {\n      'oa-shared': resolve(__dirname, './shared/index.ts'),\n      'oa-components': resolve(__dirname, './packages/components/src/index.ts'),\n    },\n  },\n  test: vitestConfig.test,\n});\n"
  }
]