From 0eda43e9302d204016ad1585eede2d5585d9d501 Mon Sep 17 00:00:00 2001
From: Paperclip
Date: Sat, 4 Apr 2026 12:27:23 +0000
Subject: [PATCH 01/21] fix(GRO-451): re-seal UAT secrets with correct cluster
certificate
UAT is down (503) because sealed secrets were encrypted with the wrong
key. This commit:
- Adds groombook/overlays/uat/ with fresh postgres and auth sealed
secrets sealed with the correct UAT cluster certificate
- Adds kustomization.yaml that:
- Uses correct image tags (2026.04.03-90be1be)
- Injects all auth env vars from groombook-auth-uat
- Points to groombook-postgres-credentials-uat
- Uses UAT hostname (groombook.uat.farh.net)
- Deletes the base component's postgres-credentials SealedSecret
(namespace-scoped, not namespace-wide, causes noise in UAT)
Co-Authored-By: Paperclip
---
.../overlays/uat/auth-sealed-secret.yaml | 29 +++++
.../groombook/overlays/uat/kustomization.yaml | 112 ++++++++++++++++++
.../overlays/uat/postgres-sealed-secret.yaml | 33 ++++++
3 files changed, 174 insertions(+)
create mode 100644 apps/groombook/overlays/uat/auth-sealed-secret.yaml
create mode 100644 apps/groombook/overlays/uat/kustomization.yaml
create mode 100644 apps/groombook/overlays/uat/postgres-sealed-secret.yaml
diff --git a/apps/groombook/overlays/uat/auth-sealed-secret.yaml b/apps/groombook/overlays/uat/auth-sealed-secret.yaml
new file mode 100644
index 0000000..9417ae5
--- /dev/null
+++ b/apps/groombook/overlays/uat/auth-sealed-secret.yaml
@@ -0,0 +1,29 @@
+# =============================================================================
+# GroomBook Auth Credentials — SealedSecret (groombook-uat namespace)
+# =============================================================================
+# Fresh credentials generated 2026-04-04. Encrypted with UAT cluster sealing
+# certificate via kubeseal --scope namespace-wide.
+# =============================================================================
+
+apiVersion: bitnami.com/v1alpha1
+kind: SealedSecret
+metadata:
+ annotations:
+ sealedsecrets.bitnami.com/namespace-wide: "true"
+ name: groombook-auth-uat
+ namespace: groombook-uat
+spec:
+ encryptedData:
+ BETTER_AUTH_SECRET: AgBQ3AtxX/LXMeIDlppSaxO4uC8J6wm8vvyv6XMX5129A1kupcog0a//w5n0/R6JKPdg12egipBysoza9O3SXNjWyVEOs6+q5Vs3y0oDNsc1a45o4lTrPPsn1m8LW7nvk8B7lZOSCUgsUfQa9djaIjb5eKoHZvngET7fpQbPt4qQpjHYIR/WVgr5gVkLTHZi2OBVfv5QoX6KIONdoBUb0hJBh4W8j7r+3Usm+GE0W4TDZu+JN1+YYDwmsschTrFuqK2DlVVw9/I7vpX2H25miT/Sv1nEJ8k6mSd0aXACPUrXvJ/E+bkbs1/deSgOvfykRoAURvE00+4aJnbhsyvujyy9Wb/iFowrcmCxQTmu2B19cunBqTMiMlBEGemUpj4QbDXt4GS8C2tsYmHGhYW40ERf7Q6/b4ijlsKE8/gXzEKgZO7nqU1Xn67LWAiHIzNRkT4vnHYv/Xunchq5YoC6AYJb6UIVdX8C5BMQA3l6xlGUWhTSwLYXTJEZ/+LwkXIG3wJxR5pZ2T4q6Q8IGADECfSJjOVC2L0Hs1+CU13xahepikX6Xqp8clBE78ypusSuFioF4Mog0Bv5fvoHQl3g+0zwtKxjQu+1xBqO8KnOpy1PM17xehkr0j5/eh9HXr7psabwqRSjP23It/MxZVPY0FdWdOBy44PPnOavAz4Btr5C8CXyutyZ+vCxxwu6zUB360KGqRaLGetNa25uDVTdkpQ8znv40J0JQ2VprMoWCFcLGnpOqMYZHrSW5XuSeLyiTBmdZ5WhbGgnvCGyiFfV7x7Z
+ BETTER_AUTH_URL: AgBs+/Ju9T/36zpOdoPAZx7nPXGvxoMzXbElQ2M3rPPioOsPRWiHGNefENGnJS43Rozn5tnCdKzepAHL8kShqKqZKRVThhYVwj2/LCNT8J+I5jW4XkvnokFCUoP6GHoWZXa7Woe1w5bH6oGjrrHfPv2MN4oriO8bYI/hwNbHmSWmAXVv8lE9tbAz1eJDMG/E5VyrHrc9wC7Cw5ITP1/LCb7UrFGHiR97D5zx0XQRTwFfqSFv8oZp+vkUEhLwloe/j2l5RObUN4qtTcGtmpM2B28wiU1TQG2H2sgq3XQY2cotjcJZ80/RQxp/YZgcpOKet/geatLQzAbKnKbLcmM+8NIfpqadwnEbZyDdGvyHENmDWn6xthK77iQFyv6akNIAPjGdWWX4HwGs/rU7pqb1rJMtTWbOsjA+5ocRPQelrpinGugBc3MMquKdMv5PxKHeXqZtUVEN/oEIluKg3LWzbtIRTBh21muZGrsuQHGbpV7G2ct8qga2F6SNCZrfj85w0LKhwky9fysiJ8y1b8wdvsooMYQWmOOJxqIFt/4ajbeAaf4MCU/p395d96ozhx7Ql93FZjZpCRcQFy0IppqYrlR/IkBLg0pBKvzm7dDkya5jg+v6pJv75PDLY6QgflWnsqD3hVbQ8+/k1eVpZsTlVUN/g57C7PTKbeyRBw5iMeXGuE2GDh/37R2mUsnWDLvFouK3c1WDgxFG2bG1tfVPtIK18J5KKoSQ0Tg/HWdu80vGyMxvYfQKU8w=
+ OIDC_AUDIENCE: AgCJqWqHDMI/Rv8aOPe0dtW/4MKjWSs1s6Nx8TAXl9Y5DzgJFRfewBAnW0yZipCoiLNoYzXPy1lMzjd0SXLv6bNhZ1RKW2TIT8kwP9jFNht+4U4302bSkM6Ud5NlGSb5TxPK7fx327etKg4/9g8Gvk1hI0TB438j0RP66U/nt71d8PSmfJorUvWkMnFgnQ4U2ulcqvj23mb1YU06AVoRvm2hLcPxx/oFMfuN5q9vU4k5pSoXeohlBiy9RCLkjeYc9GKkArsgHI22KMCvE+/1xEA+wfyJEykYjfpi5tlHhB/aA8wElLa93DbfNHy+uT7e/uGJ7x3/I/0lzKaxktEt6E8076bZEch5QkeSRdkEwjx/rRZC4HFMsbTnnagn4YpYsEDisC1kDmj04069PJI0ccwzyIybTkOLWcTb3C6DU26TsBgwD8ktWgrmN/vVR6FODfjArdZacZsZY2zg1hjx35fujl/JYOMWfKSzGPBYC3jbhC5z3yExu8cHQuz2Ayeval3dH1+yzZChvNYjErn2fFEMK79SUmugH4heTgWOhz/JuwiLKMwwYwTkUJwJog8N8LI0IYWul+gRRsiT4Z9bvfcQS+aPKIcFh1jyd+7flKNdoNhvW8ER6L9T7miOWjZZp5NEdFMpcEm++oEnNZTBoub5RVVlHQ71JrpoFUGCfcgYHdSd2h2GZs+Nlt+zlfVQYc+srKY8UCuGBRDx8Pp8WKOIKaXFdPrQH0657gfarkS8qjAbtpLmemCv
+ OIDC_CLIENT_ID: AgBywUiMkID9wfQwYRxrgzwh6ryLrbQ3mPocvCVywLRuuU8KT8OP7cUVaLcWHaRW7bdDIlzfk8xwNvJoSPsbHLdrpgXugCsbD8oXJy0ZFBxmJT9xNnZtOI0jldEjKze68DW0G4wVvV01IrJPwTIjta7LfOXSuc5rDpYQMDP3fSPdb/HnD5gieOnfsMx6/BdwIgWXQ2ovwklF+0JU2EDPzpkeyiuwJUZjz4UyZj6YWSsHTwod4IN/vMKnD78bD+P48Zn1cQnE8rjDjUQHPJQ414IHoPGebC7eXrICxFWuFJr+GFRhDmrEYQEx+y2SP89MyCOR3pEX+OX0WCOyyRg7PP/D6W/oFB2Z9lEa6+eXm+f37OM7AV/aD4W1IoAIe44dHM2FFnWOiTy8Tx/LdqYyvbg1oISb13dpDle58Za/58ChHyfbf+jP+iAK2dCFJlVonYK3/A7eVihT+Hd8+t3Y/O+84QfmR2bv+she1RpMH2PSjnaULMRKvSf6B6Y5Hvt264wYFbfm+H0WIcNfpZ6OUOlclkMp5CY+UHyQCI6AiivtUmE8s1R6eBuvm3s1uzIdEJMWay80rtNjQors3nbrL/BvWSKoTse4T4Biox9NdfCyKgc59PM/nQ7OpaHSYoCf6lV8gWqe8ps9/JEffujGo9qRHQhRhZU28nKc/pVpgYZIgyqCS6Kg+PmtRRuPKGAblbeYF31EqeB/KBIbsYAfGESE2i7Tuwz2AvAhy48vmKqCsKik7+Lv6eMM
+ OIDC_CLIENT_SECRET: AgAgEVL+T1VbtIC0ZFDCZsAMfkfaYiwXlDZOpwa/03EPASGwW1bhb2OJBOHV2+/kRwQMD5hyt4BZ5gxDwUxdaD3o9/xe7LYakVglayV3edDkuKqHTg9+87khiWTi/4x2xUTh7/FIu+sVGQyZkGnLQOr8yQ1wh7IT9PD2rwwAVFWYKhrmlIa1IIX0gikiX28lKzrqIGro2MwVQmjnn122OrIkPJHPhOfmxqpljkf1WPr3lIQl5U0V5Z0sl1Ji26/FQiq3332gl++bXw0ecI07+ZZNC+JNZyhLj3dff+dhhI7oIEn0CJ8w39HdRomBHqA3LXa/UtGaJPW6ybaT1QMUUvWHPfMS6jzkjLKRUhF8nC6bgYn411RmTY2z+sPDLMY3QD7BVPhoXe2A+xl02R8Elmz3VnBzwaLt0IWrayb9WxYlRz0ottY+gb1cjWKMAeF/2UocYWWhIGxvN4M/f6QrFzcwQNnmROG3ltZZR8tQZDYnfJsilSZRlWE6iifxHhXHKFz+jni3ntipFFXZYU/+KcYbopYZQY/AJ0FG61f80fzO3SnHVXj3IPHXUy1NO+iupqmw9OivY+0pxjxvzCPc9sWjtLKL4w0Pqp2fYWWvp/YPOLzVMPg0VbbmFeRpjeyZP1hWT7Ejq7whqf3N7Tu9hKAkAD27C20dgzOsi5gRz50PekzBH8CZVaTQRkGRw9C5nwyePtDcwrbGoBzNAuep7V5+l+XA2spivgeSNcxttbfeDhfOdMKH3N5RyNFXS2pWy330zYoa8q5sV4mX04HI78nBXzTvACojze4hui21EzawChcU/5cLPLaVeA0X22YhnH7RrZInwVOYF3fep2fHlRVh0A==
+ OIDC_INTERNAL_BASE: AgCYH+f1sZJCLqoR0ZYWrhpNx7R/9xEWDjeiFDyFlDvV+OKon6ZmIzzgQw4WZY67/Pbio0PxQ530+z/cFBo9+7n8TvSrauaBi/a6KjGn4/ayYGSMTGKK4ItzV0rAdgKM47/yx/TTbk0d2DwMaqTGp7+vOGg2HVq0bqsu0LG//QrLPxbx5Jc4qFEW7oiRrhA9JpUhVKkcqpDtT9qKBDOmjtegAEIM0WZncr1d/apcPjJu+2ugS65p0ILr/rAuCBDQDLsNJ7IdNH/YLdvFAOWOZ0VP1TJY7DLUzq2ToYbA71QLVnVaA8XhOm8rzjSrWZccCYe6V7fqGc96uRM76i1vhdWNZ5ViFaoj37F1DXLw/x51jHCoDPRAl8LUVGzoYHnxzOwUW6wh6O5Zo4tw1AKt4RgqFldqGUvnrhQPT2tIpnBYdeEkyLod/qA0lLNThPr6LP3X5LgVfe64zPB00J3UajfrL1Xb9Xhc903lSnt4oszcm1n5sVIHlr8q8ioSVTimksDf/be/CzgisnHpJQ/7YocucV47DN7l5X/O63j5IMFhKaJuukcJK3bMMWqkJGfRHDqCiqT0uTOGpdsn01smC4MidqEmBNQFgk5ehH1Vs3xAJZ08qSWBkPUS9c7pzAlsIJC48V8pwQXPgDGBsbPIkqM6vYp8obZFdHm6tH4ITEmDPQbeFl3sZm46tKXTPU3vZ4wZwXsIBeqEaAd9tMmTb02O3G6qrxgqojR7LX1wQy8wGh5Q101ILjBxcAEJw8bf
+ OIDC_ISSUER: AgAyv5SDPr1j5AM86jlnby72v8ZRtTQA6k4VTsyp4OS6Z1lhN2j6GU7w9iuSGtqT3TlX//CR8e1BQoB3hb2luzcEbxi+LAvRH+ZiB32cSCWUXx74fE1qLFc75Ri3FY4u70WoLwGSJuHoFmFMEI3TDrSxMIpDdjJa+adJZdW9+ERp1FdT3L6bqAnx/yAbaBjGvdBFVcd4Qu1aMgR7I1Pk/3e8W7B+U6GRc4IdkeIbNyxsVUsKWxPBMji5rgYhMqU5dl2/b0DfNM9z1iDKTqRdFsBIUJ6ynTcx9uZ6UIBGRdB5oI937eVJPVodwAr5RbOPEZsvcdyYtofjdDy1ZleO06saaXb3+SgWiej8kzWhf+53FexdIDdl6/sGa41NE4KSH4tdr8UcujxvwmfFL4/nrpE3I9YftLw7wvsrUvxaVAn7bcdtgFpgQTHokhSWzcddEcmEUZTCJHp21wTtrYiOFAw40LAzyMBkbD7gaK9w9I7oNRX6GF6dtA4ppn3z+jTwGHjWM3jJnTxFeJQxH/S6sgFsRHBrDrGIz5o1qnWvxjNVBhFaVc9ev7qb6jNNqYrUOfGMxpDOuQe0C+54vBCY5Bo7cimde4eBLIo6HmGsLd4ISUxtr3oiBpYShGBRSu3al5rGUWXk9S+sGO7wVHsqrzDb9OnxHJClARxlimxrdoH0FA2UY1hmeQdnzSA7d4mNnyOv+AS9E6PmCPcXrCzK+qmsGcvgjY0fpbpw+SbAzEcAp1VcTEekrm/4ZIWsp8io94oc0g==
+ template:
+ metadata:
+ annotations:
+ sealedsecrets.bitnami.com/namespace-wide: "true"
+ name: groombook-auth-uat
+ namespace: groombook-uat
diff --git a/apps/groombook/overlays/uat/kustomization.yaml b/apps/groombook/overlays/uat/kustomization.yaml
new file mode 100644
index 0000000..d9e78af
--- /dev/null
+++ b/apps/groombook/overlays/uat/kustomization.yaml
@@ -0,0 +1,112 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: groombook-uat
+images:
+ - name: ghcr.io/groombook/api
+ newTag: "2026.04.03-90be1be"
+ - name: ghcr.io/groombook/web
+ newTag: "2026.04.03-90be1be"
+ - name: ghcr.io/groombook/migrate
+ newTag: "2026.04.03-90be1be"
+ - name: ghcr.io/groombook/seed
+ newTag: "2026.04.03-90be1be"
+resources:
+ - ../../base
+ - postgres-sealed-secret.yaml
+ - auth-sealed-secret.yaml
+patches:
+ # UAT: delete the base postgres-credentials SealedSecret (scoped to groombook namespace, not groombook-uat)
+ # The base component ../components/postgres-credentials creates a namespace-scoped (not namespace-wide)
+ # SealedSecret that the namespace transformer cannot fix. Remove it to avoid noise.
+ - target:
+ kind: SealedSecret
+ name: groombook-postgres-credentials
+ patch: |
+ - op: remove
+ path: /metadata
+ # UAT: inject auth env vars from groombook-auth-uat sealed secret into API
+ - target:
+ kind: Deployment
+ name: api
+ patch: |
+ - op: add
+ path: /spec/template/spec/containers/0/env
+ value:
+ - name: NODE_ENV
+ value: production
+ - name: AUTH_DISABLED
+ value: "false"
+ - name: BETTER_AUTH_URL
+ valueFrom:
+ secretKeyRef:
+ name: groombook-auth-uat
+ key: BETTER_AUTH_URL
+ - name: BETTER_AUTH_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: groombook-auth-uat
+ key: BETTER_AUTH_SECRET
+ - name: OIDC_ISSUER
+ valueFrom:
+ secretKeyRef:
+ name: groombook-auth-uat
+ key: OIDC_ISSUER
+ - name: OIDC_CLIENT_ID
+ valueFrom:
+ secretKeyRef:
+ name: groombook-auth-uat
+ key: OIDC_CLIENT_ID
+ - name: OIDC_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: groombook-auth-uat
+ key: OIDC_CLIENT_SECRET
+ - name: OIDC_AUDIENCE
+ valueFrom:
+ secretKeyRef:
+ name: groombook-auth-uat
+ key: OIDC_AUDIENCE
+ - name: OIDC_INTERNAL_BASE
+ valueFrom:
+ secretKeyRef:
+ name: groombook-auth-uat
+ key: OIDC_INTERNAL_BASE
+ # UAT: single Postgres instance instead of 3
+ - target:
+ kind: Cluster
+ name: groombook-postgres
+ patch: |
+ - op: replace
+ path: /spec/instances
+ value: 1
+ - op: replace
+ path: /spec/storage/size
+ value: 5Gi
+ - op: replace
+ path: /spec/bootstrap/initdb/secret/name
+ value: groombook-postgres-credentials-uat
+ # UAT: use uat hostname for HTTPRoute
+ - target:
+ kind: HTTPRoute
+ name: groombook
+ patch: |
+ - op: replace
+ path: /spec/hostnames
+ value:
+ - groombook.uat.farh.net
+ # UAT: point migrate job at UAT postgres credentials
+ - target:
+ kind: Job
+ labelSelector: "app.kubernetes.io/name=migrate"
+ patch: |
+ - op: replace
+ path: /spec/template/spec/containers/0/env/0/valueFrom/secretKeyRef/name
+ value: groombook-postgres-credentials-uat
+ # UAT: point seed job at UAT postgres credentials
+ - target:
+ kind: Job
+ labelSelector: "app.kubernetes.io/name=seed"
+ patch: |
+ - op: replace
+ path: /spec/template/spec/containers/0/env/0/valueFrom/secretKeyRef/name
+ value: groombook-postgres-credentials-uat
diff --git a/apps/groombook/overlays/uat/postgres-sealed-secret.yaml b/apps/groombook/overlays/uat/postgres-sealed-secret.yaml
new file mode 100644
index 0000000..16e73d7
--- /dev/null
+++ b/apps/groombook/overlays/uat/postgres-sealed-secret.yaml
@@ -0,0 +1,33 @@
+# =============================================================================
+# GroomBook Postgres Credentials — SealedSecret (groombook-uat namespace)
+# =============================================================================
+# Fresh credentials generated 2026-04-04. Encrypted with UAT cluster sealing
+# certificate via kubeseal --scope namespace-wide.
+# CloudNativePG reads this secret at bootstrap time (postgres-cluster.yaml
+# bootstrap.initdb.secret).
+# =============================================================================
+
+apiVersion: bitnami.com/v1alpha1
+kind: SealedSecret
+metadata:
+ annotations:
+ sealedsecrets.bitnami.com/namespace-wide: "true"
+ name: groombook-postgres-credentials-uat
+ namespace: groombook-uat
+ labels:
+ app.kubernetes.io/name: postgres
+ app.kubernetes.io/part-of: groombook
+spec:
+ encryptedData:
+ # yamllint disable rule:line-length
+ password: AgB6lhWPCCCKvlxbQyupxhidg2rgPv1McRm3c2VPVhpyw9S0vdGL2VJhlhyr01ya2tOFJZOqhgYPi2Q0kTmd8bfMy83ygCBcvk0D1XfMXazVq1VOJmu7zBMWyRLjjDHQfV0ASKqk07wRP6TiY37BQwKtdHj+e0oHoh9D08oo3SuZXs+DkjuoP8tgU2MAMLj3PxXp5nDrOY/BYh9MEsUVC37PduGZXsTKCD/7uOHZRMj3NrheSc9ZyVSGLGsfW6dHeULFblW93NSQM03G9I/G8NhoGrZy3SaLfajZrm1TB7X+4qI4yCYTk0QqBxIBpEfR29TdSaYX1NNbgKgI1ebRVTQBQ9Kq1fYXL9saFVwcMmHT8zKjPSFYXDAJvRMqSOPCgntp/w8nnhVoFNaQLkwRI/unSFLgZ3yu+9eV6bixN3gUIrLvzJNjF5uV6mAyFJ8phLEFJJ50la+tdKu8QEMT00//PFqidNNe43inxKTSNtFUcfeC6dKKDzHkj99DMTVHbMSUe0TzwX/aDw8YsxN147Q8vuANm5PuaX8DZFkMcL92tppulZwdclER9+tLfcRWFfd52WX+u5Me0WbF9qAVcS1CJI7CMjKvNS5uYm7ZIKwmHHTHROZAHL4RsFZeUoE4Asuep4b+EjU9V/LDYYJdo9kW+RkKUVdtrurY6SL/NhtqyzHLtbvSWFBRsLT+UADpoFXa3z38tJLQD+kFbA/8ifHRKcEfzF3SflodLiAlNjRTMfLjWCU5a/bipR3U
+ uri: AgBM/6mX5eUTTok8dYECF7zR3aKuPHcloWMAfSVESnyNdZnEO1TgPCpcgp47dCy5C2bLPshxyMCZlwiQux2336qHHQyXv08rlF6duiUr26zJC92/Izvaj2YNmWYAnUA8bC/3N9Dk5+/tKbDwVwYSH+s4hAN1V+B2fH7k9/hFYv38Iih38fMqbQG4XZpYsNrJLthds/ox3DdJNLh4mjrDKLYHkWlGfg8qUxDqgVnFGozTuSrAMIqTvpxLFnM87GO9mILyP+ccFo53ikGLqQ0fniZSl9s+xF1b3jaqQZSL5gYf77Yt0uKFBurmBaw6Vpe56q2WDeFJJBYUd0PqyOK41RX6NnoPJF8KbmDC7qJYxgZMP2ghWh3xP+zpBozuoS1qMu470InaZNIBetnhxtFWF42tKv9UbZ2H6GQbnMWBaPeVra3foRqUn9oCvAWMThPSicaNJMLK0pGfjoAR9lurFa9aAMGqcWVJPuEbKJ5LfC6oHWTqMe2NnCaDg7RG787ntF/KK1VUnWVPQKn9sff/vQPi0pftq7bVQaMELMy0vclFOtxvVfsuKDyvEFUzJKuegPjiTZ3uISW5s6m/68bgTchSPjXKY40oFKnAu/l18DGX/LX7QkTfBEo7wlUrYhF3gwjWJoNk0KCydNodRyglPkyrbvc44fCCofmA1x/YRj0RerxhL+tUzp+pMNO+lsKTvocuewMNfWuDUsUZCxYJAPh3FtC2dYqjFECsdzpBurAeLLopEgK5XyKSyjhW60rDp5SEhd46Pcui5Pf4xgf0YfaZcHYUu72yikIf26EMZjIDjRGOZB25l/TAtcQNAlwYdZu51AOS7DEjfw8RhZNhQSEn6L+ieRew+17i
+ username: AgCxwdyCROIRBHmewuxmWlcPe3Ngj1EvXRi7xyZMw6iHO7YQGcX5IQbXSsWvy/hcPcRj3rGpj0O9qn602QEcgeSeNlTye4NmTU8ExWEC6ObvMRP2j+rPdWdGUp2eIGA846rE9/OUiCb3GkoRwKWbYI9QISBAc4IzoaBSvi/pscCeKWuWqx/EJQqasUcaTeA+HcvKUB3eo7gMMBYDC+cds50iiDD4UmG8ZM9m6t9yfYFKdjgoiU47REUancrJHDs1umR3zuYFAKVVr84xGSplAIHk0Cc1iuxmzXMR/o4dBqZJzJUseBHCS2KLptNqIM02yIKQtMT0QgQzXUY3Ox+8fhDLWABxDmDKw6a6dUVuBACG4FkLFx5gksmXXsmsc0882L6q3k1AcZPakemD3j2ESPxVkcxbRNxjLf2bt8V1pUbOh5TgCwhM4YOu0MiXRWr5VhVJZ0hKTiW6JUEuW8zjEzHQp48PM6THNiWyxK89pMdeqPo4vaQfeJxeFs6y/xWyDnNNTJ/95fYCMG69I5cP5pA8XcUwu5p+mUuspmpfx/O5jFz2ksRxzUWP0vBdJXE4bg0lmqhL/8/OMGMX/MGFFY9wRD8hnEJeDQ5R27mEmqUKB0IlbMvhIrWS3Ro3KEetdndOFzN2ALOEUQIhgUEM7uH1vrDivcMX/W89oDlz0fFeqBRK8q69dygY+PXKS8qyhoaO2ER++Xw55tU=
+ # yamllint enable rule:line-length
+ template:
+ metadata:
+ annotations:
+ sealedsecrets.bitnami.com/namespace-wide: "true"
+ name: groombook-postgres-credentials-uat
+ namespace: groombook-uat
+ type: kubernetes.io/basic-auth
--
2.52.0
From 1c7628459f750b8078085415901e42b4d451a485 Mon Sep 17 00:00:00 2001
From: Paperclip
Date: Sat, 4 Apr 2026 13:14:18 +0000
Subject: [PATCH 02/21] fix(db): use random per-encryption salt in crypto.ts
(GRO-453)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Generate a unique 16-byte random salt for each encryptSecret() call
and store it as a prefix in the ciphertext. Format changed from
iv:ciphertext:authTag → salt:iv:ciphertext:authTag
decryptSecret() detects legacy 3-part format and uses the fixed
package salt for backward compatibility with existing encrypted rows.
Co-Authored-By: Paperclip
---
apps/api/src/__tests__/crypto.test.ts | 11 +++--
packages/db/src/crypto.ts | 58 ++++++++++++++++++---------
2 files changed, 43 insertions(+), 26 deletions(-)
diff --git a/apps/api/src/__tests__/crypto.test.ts b/apps/api/src/__tests__/crypto.test.ts
index 36663f3..765c327 100644
--- a/apps/api/src/__tests__/crypto.test.ts
+++ b/apps/api/src/__tests__/crypto.test.ts
@@ -24,11 +24,11 @@ describe("encryptSecret / decryptSecret", () => {
expect(decrypted).toBe(plaintext);
});
- it("produces output in iv:ciphertext:authTag format", () => {
+ it("produces output in salt:iv:ciphertext:authTag format", () => {
const encrypted = encryptSecret("test");
const parts = encrypted.split(":");
- expect(parts).toHaveLength(3);
+ expect(parts).toHaveLength(4);
// Each part should be valid base64
parts.forEach((part) => {
expect(() => Buffer.from(part, "base64")).not.toThrow();
@@ -61,12 +61,11 @@ describe("encryptSecret / decryptSecret", () => {
});
it("throws when decrypting invalid format (wrong number of parts)", () => {
- const encrypted = encryptSecret("test");
- // Replace the last ":authTag" part by matching colon + non-colon chars at the end
- const invalid = encrypted.replace(/:[^:]+$/, "");
+ // 2 parts is invalid for both legacy (3) and new (4) format
+ const invalid = "not-enough-parts";
expect(() => decryptSecret(invalid)).toThrow(
- "Invalid encrypted value format: expected iv:ciphertext:authTag"
+ "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
);
});
diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts
index 371c3d3..b335af4 100644
--- a/packages/db/src/crypto.ts
+++ b/packages/db/src/crypto.ts
@@ -6,19 +6,22 @@ const AUTH_TAG_LENGTH = 16; // 128-bit auth tag
const SALT_LENGTH = 16;
/**
- * Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
- * BETTER_AUTH_SECRET is used as the password, with a fixed salt derived from the package name.
+ * Legacy fixed salt used for backward-compatible decryption of pre-salt format values.
+ * Do not use for new encryptions.
*/
-function deriveKey(secret: string): Buffer {
- // Use a fixed salt derived from the package name for key derivation
- // This gives us stable key derivation without storing an extra salt
- const packageSalt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
- return scryptSync(secret, packageSalt, 32);
+const LEGACY_PACKAGE_SALT = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
+
+/**
+ * Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
+ * Uses the provided salt (random per encryption for new values).
+ */
+function deriveKey(secret: string, salt: Buffer): Buffer {
+ return scryptSync(secret, salt, 32);
}
/**
* Encrypts a plaintext string using AES-256-GCM.
- * Returns a base64-encoded string in the format: iv:ciphertext:authTag
+ * Returns a base64-encoded string in the format: salt:iv:ciphertext:authTag
*/
export function encryptSecret(plaintext: string): string {
const secret = process.env.BETTER_AUTH_SECRET;
@@ -26,7 +29,8 @@ export function encryptSecret(plaintext: string): string {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
}
- const key = deriveKey(secret);
+ const salt = randomBytes(SALT_LENGTH);
+ const key = deriveKey(secret, salt);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, {
@@ -38,8 +42,9 @@ export function encryptSecret(plaintext: string): string {
const authTag = cipher.getAuthTag();
- // Format: base64(iv):base64(ciphertext):base64(authTag)
+ // Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag)
return [
+ salt.toString("base64"),
iv.toString("base64"),
ciphertext.toString("base64"),
authTag.toString("base64"),
@@ -48,7 +53,8 @@ export function encryptSecret(plaintext: string): string {
/**
* Decrypts a ciphertext string produced by encryptSecret.
- * Expects the format: iv:ciphertext:authTag (all base64-encoded)
+ * Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag).
+ * All values are base64-encoded.
*/
export function decryptSecret(encrypted: string): string {
const secret = process.env.BETTER_AUTH_SECRET;
@@ -57,18 +63,30 @@ export function decryptSecret(encrypted: string): string {
}
const parts = encrypted.split(":");
- if (parts.length !== 3) {
- throw new Error("Invalid encrypted value format: expected iv:ciphertext:authTag");
+ if (parts.length !== 3 && parts.length !== 4) {
+ throw new Error("Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag");
}
- const ivBase64 = parts[0]!;
- const ciphertextBase64 = parts[1]!;
- const authTagBase64 = parts[2]!;
- const iv = Buffer.from(ivBase64, "base64");
- const ciphertext = Buffer.from(ciphertextBase64, "base64");
- const authTag = Buffer.from(authTagBase64, "base64");
+ let salt: Buffer;
+ let iv: Buffer;
+ let ciphertext: Buffer;
+ let authTag: Buffer;
- const key = deriveKey(secret);
+ if (parts.length === 4) {
+ // New format: salt:iv:ciphertext:authTag
+ salt = Buffer.from(parts[0]!, "base64");
+ iv = Buffer.from(parts[1]!, "base64");
+ ciphertext = Buffer.from(parts[2]!, "base64");
+ authTag = Buffer.from(parts[3]!, "base64");
+ } else {
+ // Legacy format: iv:ciphertext:authTag — use fixed package salt
+ salt = LEGACY_PACKAGE_SALT;
+ iv = Buffer.from(parts[0]!, "base64");
+ ciphertext = Buffer.from(parts[1]!, "base64");
+ authTag = Buffer.from(parts[2]!, "base64");
+ }
+
+ const key = deriveKey(secret, salt);
const decipher = createDecipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
--
2.52.0
From d9e6b09fe5fa82dc9ad07cd7543670441f092aa9 Mon Sep 17 00:00:00 2001
From: Paperclip
Date: Sat, 4 Apr 2026 13:16:19 +0000
Subject: [PATCH 03/21] fix(api): use correct schema in POST
/admin/auth-provider/test (GRO-454)
Switch the test endpoint from putAuthProviderSchema.omit({ clientSecret })
(which requires providerId, displayName, clientId, scopes) to the
minimal authProviderTestSchema (issuerUrl, internalBaseUrl?) that matches
what the Settings.tsx frontend actually sends.
Co-Authored-By: Paperclip
---
apps/api/src/routes/authProvider.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/routes/authProvider.ts b/apps/api/src/routes/authProvider.ts
index 4467afa..e53e909 100644
--- a/apps/api/src/routes/authProvider.ts
+++ b/apps/api/src/routes/authProvider.ts
@@ -19,6 +19,12 @@ const putAuthProviderSchema = z.object({
scopes: z.string().default("openid profile email"),
});
+/** Minimal schema for the test endpoint — only issuer/internal URLs are needed for OIDC discovery. */
+const authProviderTestSchema = z.object({
+ issuerUrl: z.string().url(),
+ internalBaseUrl: z.string().url().nullable().optional(),
+});
+
/**
* GET /api/admin/auth-provider
* Returns the current provider config with clientSecret redacted.
@@ -131,7 +137,7 @@ let encryptedSecret: string;
authProviderRouter.post(
"/test",
requireSuperUser(),
- zValidator("json", putAuthProviderSchema.omit({ clientSecret: true })),
+ zValidator("json", authProviderTestSchema),
async (c) => {
const body = c.req.valid("json");
--
2.52.0
From 6819bff2bf52395092c7c11fc6be5232facd2c9e Mon Sep 17 00:00:00 2001
From: Paperclip
Date: Sat, 4 Apr 2026 13:16:19 +0000
Subject: [PATCH 04/21] fix(api): use correct schema in POST
/admin/auth-provider/test (GRO-454)
Switch the test endpoint from putAuthProviderSchema.omit({ clientSecret })
(which requires providerId, displayName, clientId, scopes) to the
minimal authProviderTestSchema (issuerUrl, internalBaseUrl?) that matches
what the Settings.tsx frontend actually sends.
Co-Authored-By: Paperclip
---
apps/api/src/routes/authProvider.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/routes/authProvider.ts b/apps/api/src/routes/authProvider.ts
index 4467afa..e53e909 100644
--- a/apps/api/src/routes/authProvider.ts
+++ b/apps/api/src/routes/authProvider.ts
@@ -19,6 +19,12 @@ const putAuthProviderSchema = z.object({
scopes: z.string().default("openid profile email"),
});
+/** Minimal schema for the test endpoint — only issuer/internal URLs are needed for OIDC discovery. */
+const authProviderTestSchema = z.object({
+ issuerUrl: z.string().url(),
+ internalBaseUrl: z.string().url().nullable().optional(),
+});
+
/**
* GET /api/admin/auth-provider
* Returns the current provider config with clientSecret redacted.
@@ -131,7 +137,7 @@ let encryptedSecret: string;
authProviderRouter.post(
"/test",
requireSuperUser(),
- zValidator("json", putAuthProviderSchema.omit({ clientSecret: true })),
+ zValidator("json", authProviderTestSchema),
async (c) => {
const body = c.req.valid("json");
--
2.52.0
From 78a67583499a355c4d6a9e69783794a91079b07d Mon Sep 17 00:00:00 2001
From: Paperclip
Date: Sat, 4 Apr 2026 21:25:32 +0000
Subject: [PATCH 05/21] fix(db): generate unique random salt per encryptSecret
call (GRO-453)
Use a 16-byte random salt per encryption instead of the fixed
"groombook-auth-provider-config" salt. This prevents identical
plaintexts from producing identical ciphertexts, closing the
timing/anagram security gap identified in GRO-452.
New format: salt:iv:ciphertext:authTag (all base64).
Legacy format (iv:ciphertext:authTag) is still accepted for
backward-compatible decryption of existing stored values.
Co-Authored-By: Paperclip
---
apps/api/src/__tests__/crypto.test.ts | 8 +++++---
packages/db/src/crypto.ts | 22 ++++++++--------------
2 files changed, 13 insertions(+), 17 deletions(-)
diff --git a/apps/api/src/__tests__/crypto.test.ts b/apps/api/src/__tests__/crypto.test.ts
index 765c327..2602264 100644
--- a/apps/api/src/__tests__/crypto.test.ts
+++ b/apps/api/src/__tests__/crypto.test.ts
@@ -61,8 +61,10 @@ describe("encryptSecret / decryptSecret", () => {
});
it("throws when decrypting invalid format (wrong number of parts)", () => {
- // 2 parts is invalid for both legacy (3) and new (4) format
- const invalid = "not-enough-parts";
+ const encrypted = encryptSecret("test");
+ // Replace the last two parts with a single part to create a 2-part string
+ // This can't be parsed as either legacy (3 parts) or new (4 parts) format
+ const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, "");
expect(() => decryptSecret(invalid)).toThrow(
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
@@ -92,4 +94,4 @@ describe("encryptSecret / decryptSecret", () => {
expect(decrypted).toBe(plaintext);
});
-});
\ No newline at end of file
+});
diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts
index b335af4..541d5a3 100644
--- a/packages/db/src/crypto.ts
+++ b/packages/db/src/crypto.ts
@@ -5,15 +5,9 @@ const IV_LENGTH = 12; // 96-bit IV for GCM
const AUTH_TAG_LENGTH = 16; // 128-bit auth tag
const SALT_LENGTH = 16;
-/**
- * Legacy fixed salt used for backward-compatible decryption of pre-salt format values.
- * Do not use for new encryptions.
- */
-const LEGACY_PACKAGE_SALT = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
-
/**
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
- * Uses the provided salt (random per encryption for new values).
+ * A unique random salt is generated per encryptSecret() call and prepended to the output.
*/
function deriveKey(secret: string, salt: Buffer): Buffer {
return scryptSync(secret, salt, 32);
@@ -54,7 +48,6 @@ export function encryptSecret(plaintext: string): string {
/**
* Decrypts a ciphertext string produced by encryptSecret.
* Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag).
- * All values are base64-encoded.
*/
export function decryptSecret(encrypted: string): string {
const secret = process.env.BETTER_AUTH_SECRET;
@@ -63,9 +56,6 @@ export function decryptSecret(encrypted: string): string {
}
const parts = encrypted.split(":");
- if (parts.length !== 3 && parts.length !== 4) {
- throw new Error("Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag");
- }
let salt: Buffer;
let iv: Buffer;
@@ -78,12 +68,16 @@ export function decryptSecret(encrypted: string): string {
iv = Buffer.from(parts[1]!, "base64");
ciphertext = Buffer.from(parts[2]!, "base64");
authTag = Buffer.from(parts[3]!, "base64");
- } else {
+ } else if (parts.length === 3) {
// Legacy format: iv:ciphertext:authTag — use fixed package salt
- salt = LEGACY_PACKAGE_SALT;
+ salt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
iv = Buffer.from(parts[0]!, "base64");
ciphertext = Buffer.from(parts[1]!, "base64");
authTag = Buffer.from(parts[2]!, "base64");
+ } else {
+ throw new Error(
+ "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
+ );
}
const key = deriveKey(secret, salt);
@@ -97,4 +91,4 @@ export function decryptSecret(encrypted: string): string {
plaintext = Buffer.concat([plaintext, decipher.final()]);
return plaintext.toString("utf8");
-}
\ No newline at end of file
+}
--
2.52.0
From ff216ea54c4ab43513e298097e30d0c54400498b Mon Sep 17 00:00:00 2001
From: "groombook-engineer[bot]"
<269742240+groombook-engineer[bot]@users.noreply.github.com>
Date: Sat, 4 Apr 2026 23:29:18 +0000
Subject: [PATCH 06/21] fix(api): remove duplicate authProviderRouter
registration (#226)
The authProviderRouter was registered twice at /admin/auth-provider in
apps/api/src/index.ts. The second registration is a no-op but creates
confusion. Remove the duplicate line.
Co-authored-by: Paperclip
---
apps/api/src/index.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 2d93fbd..e663986 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -167,7 +167,6 @@ api.route("/impersonation", impersonationRouter);
api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
-api.route("/admin/auth-provider", authProviderRouter);
api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000);
--
2.52.0
From b090f8b810964e34ec38dba14e1408100fc4ce8f Mon Sep 17 00:00:00 2001
From: "groombook-engineer[bot]"
<269742240+groombook-engineer[bot]@users.noreply.github.com>
Date: Sun, 5 Apr 2026 08:55:07 +0000
Subject: [PATCH 07/21] fix(GRO-472): exclude OAuth callback from service
worker caching (#228)
The NetworkFirst route for /api/* was intercepting the OIDC callback
(/api/auth/oauth2/callback/authentik?code=...), returning a cached
index.html instead of forwarding to the API server.
Added navigateFallbackDenylist regex to exclude the callback path
from service worker navigation handling, allowing the callback request
to reach the API server normally.
Fixes GRO-472.
Co-authored-by: Flea Flicker
Co-authored-by: Paperclip
---
apps/web/vite.config.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 066b753..7beaaa5 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -40,6 +40,9 @@ export default defineConfig({
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
+ navigateFallbackDenylist: [
+ /^\/api\/auth\/oauth2\/callback\//,
+ ],
runtimeCaching: [
{
urlPattern: /^http.*\/api\/.*/i,
--
2.52.0
From 90ad46f0d5968f1d8ad73e91fc5113eae236a0e7 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Sun, 5 Apr 2026 11:14:17 +0000
Subject: [PATCH 08/21] fix(ci): rename base Jobs in promote-to-uat and
promote-prod workflows (GRO-311)
Both workflows now update base migration/seed Job names with short SHA
extracted from the image tag, matching the dev CI cd job pattern.
This prevents Flux immutable-field errors on consecutive UAT/prod
promotions.
Co-Authored-By: Paperclip
---
.github/workflows/promote-prod.yml | 26 ++++++++++++++++++++++++--
.github/workflows/promote-to-uat.yml | 24 ++++++++++++++++++++++--
2 files changed, 46 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml
index 65cd94c..e890112 100644
--- a/.github/workflows/promote-prod.yml
+++ b/.github/workflows/promote-prod.yml
@@ -31,16 +31,38 @@ jobs:
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- - name: Update prod overlay image tags
+ - name: Update prod overlay image tags and base Job names
env:
TAG: ${{ inputs.tag }}
run: |
cd /tmp/infra
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
+
+ SHORT_SHA="${TAG##*-}"
+ export SHORT_SHA
+ export TAG
+
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
+
+ # Update migrate Job name to include short SHA (immutable template fix)
+ MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
+ if [ -f "$MIGRATE_JOB" ]; then
+ yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
+ yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
+ yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
+ fi
+
+ # Update seed Job name to include short SHA (immutable template fix)
+ SEED_JOB="apps/groombook/base/seed-job.yaml"
+ if [ -f "$SEED_JOB" ]; then
+ yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
+ yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
+ yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
+ fi
+
git -C /tmp/infra diff --stat
- name: Create PR on groombook/infra
@@ -52,7 +74,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "release/promote-prod-${TAG}"
- git add apps/groombook/overlays/prod/
+ git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "release: promote ${TAG} to production"
git push -u origin "release/promote-prod-${TAG}"
gh pr create \
diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml
index 587e749..c0ccff9 100644
--- a/.github/workflows/promote-to-uat.yml
+++ b/.github/workflows/promote-to-uat.yml
@@ -32,7 +32,7 @@ jobs:
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- - name: Update UAT overlay image tags
+ - name: Update UAT overlay image tags and base Job names
env:
TAG: ${{ inputs.image_tag }}
run: |
@@ -45,11 +45,31 @@ jobs:
exit 1
fi
+ SHORT_SHA="${TAG##*-}"
+ export SHORT_SHA
+ export TAG
+
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
+ # Update migrate Job name to include short SHA (immutable template fix)
+ MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
+ if [ -f "$MIGRATE_JOB" ]; then
+ yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
+ yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
+ yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
+ fi
+
+ # Update seed Job name to include short SHA (immutable template fix)
+ SEED_JOB="apps/groombook/base/seed-job.yaml"
+ if [ -f "$SEED_JOB" ]; then
+ yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
+ yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
+ yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
+ fi
+
git -C /tmp/infra diff --stat
- name: Create PR on groombook/infra
@@ -61,7 +81,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-uat-image-tags-${TAG}"
- git add apps/groombook/overlays/uat/
+ git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "chore: promote ${TAG} to UAT"
git push -u origin "chore/update-uat-image-tags-${TAG}"
--
2.52.0
From 711981e6f34e38da20b38b1348f10b8491e9dc5a Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Sun, 5 Apr 2026 14:30:25 +0000
Subject: [PATCH 09/21] fix(api): auto-link staff to Better-Auth user via email
on first SSO login (GRO-480)
When a staff record exists with a matching email but no userId (e.g. seed data
or admin UI-created records), resolveStaffMiddleware now auto-links it to the
Better-Auth user record on first SSO login instead of returning 403.
Safety: only links when userId IS NULL, never overwrites an existing link.
Email matching is safe since it comes from the trusted SSO provider (Authentik).
Staff emails are unique by schema.
Co-Authored-By: Paperclip
---
apps/api/src/middleware/rbac.ts | 22 +++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts
index d5e764e..9075ee9 100644
--- a/apps/api/src/middleware/rbac.ts
+++ b/apps/api/src/middleware/rbac.ts
@@ -1,5 +1,6 @@
import type { MiddlewareHandler } from "hono";
-import { eq, getDb, staff } from "@groombook/db";
+import { isNull } from "drizzle-orm";
+import { and, eq, getDb, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -89,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler = async (
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
if (!fallbackRow) {
+ // Auto-link: staff record exists with matching email but no userId — link it now
+ if (jwt.email) {
+ const [linkedStaff] = await db
+ .select()
+ .from(staff)
+ .where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
+ if (linkedStaff) {
+ await db
+ .update(staff)
+ .set({ userId: jwt.sub })
+ .where(eq(staff.id, linkedStaff.id));
+ console.log(
+ `[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
+ );
+ c.set("staff", linkedStaff);
+ await next();
+ return;
+ }
+ }
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
--
2.52.0
From e39924b236afde31e0d9577871050f645964c20b Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Sun, 5 Apr 2026 14:39:22 +0000
Subject: [PATCH 10/21] fix(api): import isNull from @groombook/db instead of
drizzle-orm directly
drizzle-orm is not a direct dependency of @groombook/api, causing
TS2307 at typecheck time. Re-export isNull from @groombook/db and
update the import in rbac.ts.
Co-Authored-By: Paperclip
---
apps/api/src/middleware/rbac.ts | 3 +--
packages/db/src/index.ts | 2 +-
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts
index 9075ee9..1fab0cc 100644
--- a/apps/api/src/middleware/rbac.ts
+++ b/apps/api/src/middleware/rbac.ts
@@ -1,6 +1,5 @@
import type { MiddlewareHandler } from "hono";
-import { isNull } from "drizzle-orm";
-import { and, eq, getDb, staff } from "@groombook/db";
+import { and, eq, getDb, isNull, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 9cd8c01..8b3b01f 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -4,7 +4,7 @@ import * as schema from "./schema.js";
export * from "./schema.js";
export { encryptSecret, decryptSecret } from "./crypto.js";
-export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
+export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
let _db: ReturnType | null = null;
--
2.52.0
From 006c05ac777b817619f8db7312adff24402fbe07 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Sun, 5 Apr 2026 15:44:29 +0000
Subject: [PATCH 11/21] fix(ci): delete completed Jobs before Flux reconciles
(GRO-481)
Both promote-to-uat and promote-prod workflows now delete any
existing completed Jobs with the same short SHA suffix before Flux
reconciles. This prevents the immutable-podTemplate error that was
blocking UAT at image tag a67e541:
Job.batch "migrate-schema-xxx" is invalid: spec.template: field is immutable
Also added missing failure notification step to promote-prod workflow.
Co-Authored-By: Paperclip
---
.github/workflows/promote-prod.yml | 24 +++++++++++++++++++++++-
.github/workflows/promote-to-uat.yml | 10 ++++++++++
2 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml
index e890112..da8c9b0 100644
--- a/.github/workflows/promote-prod.yml
+++ b/.github/workflows/promote-prod.yml
@@ -82,4 +82,26 @@ jobs:
--base main \
--head "release/promote-prod-${TAG}" \
--title "release: promote ${TAG} to production" \
- --body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood"
\ No newline at end of file
+ --body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood"
+
+ - name: Delete existing completed Jobs before Flux reconciles
+ env:
+ TAG: ${{ inputs.tag }}
+ run: |
+ SHORT_SHA="${TAG##*-}"
+ echo "Deleting completed Jobs with name suffix: $SHORT_SHA"
+ kubectl delete job "migrate-schema-${SHORT_SHA}" -n groombook --ignore-not-found
+ kubectl delete job "seed-test-data-${SHORT_SHA}" -n groombook --ignore-not-found
+ echo "Jobs deleted, Flux will reconcile with fresh objects"
+
+ - name: Notify on failure
+ if: failure()
+ uses: actions/github-script@v7
+ with:
+ script: |
+ github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: '## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details.'
+ });
diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml
index c0ccff9..8fe3a37 100644
--- a/.github/workflows/promote-to-uat.yml
+++ b/.github/workflows/promote-to-uat.yml
@@ -95,6 +95,16 @@ jobs:
--body "[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO")
gh pr merge "$PR_URL" --merge
+ - name: Delete existing completed Jobs before Flux reconciles
+ env:
+ TAG: ${{ inputs.image_tag }}
+ run: |
+ SHORT_SHA="${TAG##*-}"
+ echo "Deleting completed Jobs with name suffix: $SHORT_SHA"
+ kubectl delete job "migrate-schema-${SHORT_SHA}" -n groombook-uat --ignore-not-found
+ kubectl delete job "seed-test-data-${SHORT_SHA}" -n groombook-uat --ignore-not-found
+ echo "Jobs deleted, Flux will reconcile with fresh objects"
+
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
--
2.52.0
From 25ac34828f8f77fba8e132122254608ba6aac03e Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Sun, 5 Apr 2026 16:32:23 +0000
Subject: [PATCH 12/21] fix(ci): remove dead kubectl steps and misleading TTL
fallback lines
These steps always fail because the runner has no kubeconfig. Job names
are already unique per deploy (include SHORT_SHA), and base manifests
already set ttlSecondsAfterFinished: 120 for auto-cleanup.
Co-Authored-By: Paperclip
---
.github/workflows/promote-prod.yml | 12 ------------
.github/workflows/promote-to-uat.yml | 12 ------------
2 files changed, 24 deletions(-)
diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml
index da8c9b0..483e8cd 100644
--- a/.github/workflows/promote-prod.yml
+++ b/.github/workflows/promote-prod.yml
@@ -52,7 +52,6 @@ jobs:
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
- yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
fi
# Update seed Job name to include short SHA (immutable template fix)
@@ -60,7 +59,6 @@ jobs:
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
- yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
fi
git -C /tmp/infra diff --stat
@@ -84,16 +82,6 @@ jobs:
--title "release: promote ${TAG} to production" \
--body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood"
- - name: Delete existing completed Jobs before Flux reconciles
- env:
- TAG: ${{ inputs.tag }}
- run: |
- SHORT_SHA="${TAG##*-}"
- echo "Deleting completed Jobs with name suffix: $SHORT_SHA"
- kubectl delete job "migrate-schema-${SHORT_SHA}" -n groombook --ignore-not-found
- kubectl delete job "seed-test-data-${SHORT_SHA}" -n groombook --ignore-not-found
- echo "Jobs deleted, Flux will reconcile with fresh objects"
-
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml
index 8fe3a37..a1a79d4 100644
--- a/.github/workflows/promote-to-uat.yml
+++ b/.github/workflows/promote-to-uat.yml
@@ -59,7 +59,6 @@ jobs:
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
- yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
fi
# Update seed Job name to include short SHA (immutable template fix)
@@ -67,7 +66,6 @@ jobs:
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
- yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
fi
git -C /tmp/infra diff --stat
@@ -95,16 +93,6 @@ jobs:
--body "[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO")
gh pr merge "$PR_URL" --merge
- - name: Delete existing completed Jobs before Flux reconciles
- env:
- TAG: ${{ inputs.image_tag }}
- run: |
- SHORT_SHA="${TAG##*-}"
- echo "Deleting completed Jobs with name suffix: $SHORT_SHA"
- kubectl delete job "migrate-schema-${SHORT_SHA}" -n groombook-uat --ignore-not-found
- kubectl delete job "seed-test-data-${SHORT_SHA}" -n groombook-uat --ignore-not-found
- echo "Jobs deleted, Flux will reconcile with fresh objects"
-
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
--
2.52.0
From fa18c41677648450f3c026be52f02869fb125869 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Sun, 5 Apr 2026 19:37:23 +0000
Subject: [PATCH 13/21] fix(api): exempt OOBE setup from staff middleware and
auto-create staff (GRO-485)
Exempt POST /api/setup from resolveStaffMiddleware so OOBE users (with no
pre-existing staff record) can complete the out-of-box experience without
getting blocked by the "no staff record found" 403 error.
Changes:
- rbac.ts: add /api/setup to path exemption alongside /api/auth/
- setup.ts POST /: add find-or-create logic that:
- Looks up existing staff by userId from JWT
- Auto-links legacy staff records by email if userId is null
- Creates a new staff record if none exists (OOBE case)
- Returns 400 if JWT has no email and no staff record found
- setup.test.ts: add regression tests for all scenarios
Fixes GRO-485 (OOBE regression introduced by GRO-480).
Co-Authored-By: Paperclip
---
apps/api/src/__tests__/setup.test.ts | 314 +++++++++++++++++++++++++--
apps/api/src/middleware/rbac.ts | 3 +-
apps/api/src/routes/setup.ts | 74 ++++++-
3 files changed, 360 insertions(+), 31 deletions(-)
diff --git a/apps/api/src/__tests__/setup.test.ts b/apps/api/src/__tests__/setup.test.ts
index 5940976..095d791 100644
--- a/apps/api/src/__tests__/setup.test.ts
+++ b/apps/api/src/__tests__/setup.test.ts
@@ -13,8 +13,10 @@ interface MockStaff {
// ─── Mock DB state ────────────────────────────────────────────────────────────
let dbStaffRows: MockStaff[] = [];
+let dbBusinessSettingsRows: { id: string; businessName: string }[] = [];
let dbAuthConfigRows: { id: string; enabled: boolean }[] = [];
let insertedAuthConfig: Record[] = [];
+let insertedStaff: Record[] = [];
let encryptCalls: string[] = [];
// Track env vars set per test
@@ -22,8 +24,10 @@ const originalEnv = { ...process.env };
function resetMock() {
dbStaffRows = [];
+ dbBusinessSettingsRows = [];
dbAuthConfigRows = [];
insertedAuthConfig = [];
+ insertedStaff = [];
encryptCalls = [];
}
@@ -58,39 +62,173 @@ vi.mock("@groombook/db", () => {
}
);
- return {
- getDb: () => ({
+ const businessSettings = new Proxy(
+ { _name: "business_settings" },
+ {
+ get(_target, prop) {
+ if (prop === "_name") return "business_settings";
+ if (prop === "$inferSelect") return {};
+ return { table: "business_settings", column: prop };
+ },
+ }
+ );
+
+ // Build a shared tx mock that operates on current-state snapshots
+ function makeTxMock() {
+ function getRowsForTable(table: unknown) {
+ if (table === authProviderConfig) return dbAuthConfigRows;
+ if (table === staff) return dbStaffRows;
+ if (table === businessSettings) return dbBusinessSettingsRows;
+ return [];
+ }
+
+ return {
select: () => ({
- from: (table: unknown) => ({
- where: () => ({
- limit: () => {
- if (table === authProviderConfig) return dbAuthConfigRows;
- if (table === staff) return dbStaffRows;
- return [];
+ from: (table: unknown) => {
+ const rows = getRowsForTable(table);
+ const base = {
+ where: (cond?: unknown) => {
+ const filtered = cond ? rows.filter((r) => evaluateCond(cond, r)) : rows;
+ return {
+ limit: () => filtered,
+ for: () => ({
+ limit: () => filtered,
+ [Symbol.iterator]: function* () {
+ for (const item of filtered) yield item;
+ },
+ 0: filtered[0],
+ length: filtered.length,
+ }),
+ [Symbol.iterator]: function* () {
+ for (const item of filtered) yield item;
+ },
+ 0: filtered[0],
+ length: filtered.length,
+ };
},
[Symbol.iterator]: function* () {
- const rows = table === authProviderConfig ? dbAuthConfigRows : dbStaffRows;
for (const item of rows) yield item;
},
- 0: (table === authProviderConfig ? dbAuthConfigRows : dbStaffRows)[0],
- length: (table === authProviderConfig ? dbAuthConfigRows : dbStaffRows).length,
- }),
- }),
+ 0: rows[0],
+ length: rows.length,
+ };
+ // Some calls use .limit() directly on from() result (no where())
+ (base as any).limit = () => rows;
+ return base;
+ },
}),
insert: () => ({
values: (vals: Record) => {
- const row = { ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() };
- insertedAuthConfig.push(vals);
+ const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() };
if (vals.providerId) {
+ insertedAuthConfig.push(vals);
dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean });
+ } else if (vals.email) {
+ // staff insert
+ insertedStaff.push(vals);
+ dbStaffRows.push(row as MockStaff);
+ } else if (vals.businessName) {
+ dbBusinessSettingsRows.push(row as { id: string; businessName: string });
}
return { returning: () => [row] };
},
}),
+ update: () => ({
+ set: (vals: Record) => ({
+ where: () => ({
+ returning: () => {
+ const updated = { ...dbStaffRows[0], ...vals, updatedAt: new Date() };
+ return [updated];
+ },
+ }),
+ }),
+ }),
+ };
+ }
+
+ return {
+ getDb: () => ({
+ select: () => ({
+ from: (table: unknown) => ({
+ where: (cond?: unknown) => {
+ const rows =
+ table === authProviderConfig
+ ? dbAuthConfigRows
+ : table === staff
+ ? dbStaffRows
+ : table === businessSettings
+ ? dbBusinessSettingsRows
+ : [];
+ const filtered = cond ? rows.filter((r) => evaluateCond(cond, r)) : rows;
+ return {
+ limit: () => filtered,
+ for: () => ({
+ limit: () => filtered,
+ [Symbol.iterator]: function* () {
+ for (const item of filtered) yield item;
+ },
+ 0: filtered[0],
+ length: filtered.length,
+ }),
+ [Symbol.iterator]: function* () {
+ for (const item of filtered) yield item;
+ },
+ 0: filtered[0],
+ length: filtered.length,
+ };
+ },
+ [Symbol.iterator]: function* () {
+ const rows =
+ table === authProviderConfig
+ ? dbAuthConfigRows
+ : table === staff
+ ? dbStaffRows
+ : table === businessSettings
+ ? dbBusinessSettingsRows
+ : [];
+ for (const item of rows) yield item;
+ },
+ 0:
+ table === authProviderConfig
+ ? dbAuthConfigRows[0]
+ : table === staff
+ ? dbStaffRows[0]
+ : table === businessSettings
+ ? dbBusinessSettingsRows[0]
+ : undefined,
+ length:
+ table === authProviderConfig
+ ? dbAuthConfigRows.length
+ : table === staff
+ ? dbStaffRows.length
+ : table === businessSettings
+ ? dbBusinessSettingsRows.length
+ : 0,
+ }),
+ }),
+ insert: () => ({
+ values: (vals: Record) => {
+ const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() };
+ if (vals.providerId) {
+ insertedAuthConfig.push(vals);
+ dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean });
+ } else if (vals.email) {
+ insertedStaff.push(vals);
+ dbStaffRows.push(row as MockStaff);
+ } else if (vals.businessName) {
+ dbBusinessSettingsRows.push(row as { id: string; businessName: string });
+ }
+ return { returning: () => [row] };
+ },
+ }),
+ transaction: (cb: (tx: unknown) => Promise) => cb(makeTxMock()),
}),
authProviderConfig,
staff,
- eq: (_col: unknown, _val: unknown) => ({ col: _col, val: _val }),
+ businessSettings,
+ eq: (col: unknown, val: unknown) => ({ __type: "eq", col, val }),
+ and: (...conds: unknown[]) => ({ __type: "and", conds }),
+ isNull: (col: unknown) => ({ __type: "isNull", col }),
encryptSecret: (val: string) => {
encryptCalls.push(val);
return `encrypted:${val}`;
@@ -98,13 +236,42 @@ vi.mock("@groombook/db", () => {
};
});
+// Helper to evaluate mock conditions against a row
+function evaluateCond(cond: unknown, row: Record): boolean {
+ if (!cond || typeof cond !== "object") return true;
+ const c = cond as Record;
+ if (c.__type === "eq") {
+ const colObj = c.col as Record;
+ const colName = colObj.column as string;
+ return row[colName] === c.val;
+ }
+ if (c.__type === "and") {
+ return (c.conds as unknown[]).every((sub) => evaluateCond(sub, row));
+ }
+ if (c.__type === "isNull") {
+ const colObj = c.col as Record;
+ const colName = colObj.column as string;
+ return row[colName] === null || row[colName] === undefined;
+ }
+ return true;
+}
+
// ─── Build test app ───────────────────────────────────────────────────────────
-function makeApp(staff?: MockStaff | null) {
+interface JwtPayload {
+ sub: string;
+ email?: string;
+ name?: string;
+}
+
+function makeApp(staff?: MockStaff | null, jwtPayload?: JwtPayload | null) {
const app = new Hono();
- // Inject optional staff context for authenticated routes
+ // Inject optional staff and jwtPayload context for authenticated routes
app.use("/setup/*", async (c, next) => {
+ if (jwtPayload) {
+ (c as any).set("jwtPayload", jwtPayload);
+ }
if (staff) {
(c as any).set("staff", staff);
}
@@ -156,6 +323,22 @@ async function postAuthProviderTest(app: Hono, body: unknown) {
return { status: res.status, body: parsed };
}
+async function postSetup(app: Hono, body: unknown) {
+ const res = await app.request("/setup", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const text = await res.text();
+ let parsed: ResponseBody;
+ try {
+ parsed = JSON.parse(text) as ResponseBody;
+ } catch {
+ parsed = { error: text };
+ }
+ return { status: res.status, body: parsed };
+}
+
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /setup/status — OOBE bootstrap logic", () => {
@@ -388,4 +571,99 @@ describe("POST /setup/auth-provider/test — OOBE test connection", () => {
vi.restoreAllMocks();
});
+});
+
+describe("POST /setup — OOBE regression (GRO-485)", () => {
+ beforeEach(() => {
+ resetMock();
+ process.env = { ...originalEnv };
+ clearAuthEnv();
+ });
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ it("creates staff record during OOBE when no staff record exists for authenticated user", async () => {
+ // No staff rows — this is a fresh OOBE user
+ dbStaffRows = [];
+ dbBusinessSettingsRows = [];
+
+ const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
+ const app = makeApp(null, jwtPayload);
+
+ const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
+
+ expect(status).toBe(201);
+ expect(body.ok).toBe(true);
+ expect(body.staff).toBeDefined();
+ expect(body.staff.isSuperUser).toBe(true);
+ expect(body.staff.email).toBe("alice@example.com");
+ expect(body.staff.role).toBe("manager");
+ // New staff record was created
+ expect(insertedStaff.length).toBe(1);
+ expect(insertedStaff[0]!.email).toBe("alice@example.com");
+ expect(insertedStaff[0]!.userId).toBe("user-123");
+ });
+
+ it("still works for user who already has a staff record", async () => {
+ // Staff record exists for this user
+ dbStaffRows = [{ id: "staff-existing", role: "groomer", isSuperUser: false }];
+ dbBusinessSettingsRows = [];
+
+ const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
+ // Inject the existing staff record into context
+ const app = makeApp({ id: "staff-existing", role: "groomer", isSuperUser: false }, jwtPayload);
+
+ const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
+
+ expect(status).toBe(201);
+ expect(body.ok).toBe(true);
+ expect(body.staff.isSuperUser).toBe(true);
+ // No new staff was created (insertedStaff should be empty since staff was pre-existing)
+ });
+
+ it("auto-links staff by email if record exists with matching email but no userId", async () => {
+ // Staff record exists with matching email but no userId (legacy record)
+ dbStaffRows = [{ id: "staff-legacy", role: "manager", isSuperUser: false, email: "alice@example.com", userId: null }];
+ dbBusinessSettingsRows = [];
+
+ const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
+ // No staff injected into context — the handler must find it by email
+ const app = makeApp(null, jwtPayload);
+
+ const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
+
+ expect(status).toBe(201);
+ expect(body.ok).toBe(true);
+ expect(body.staff.isSuperUser).toBe(true);
+ });
+
+ it("returns 400 if JWT has no email claim and no staff record exists", async () => {
+ dbStaffRows = [];
+ dbBusinessSettingsRows = [];
+
+ // JWT with no email
+ const jwtPayload = { sub: "user-123" };
+ const app = makeApp(null, jwtPayload);
+
+ const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
+
+ expect(status).toBe(400);
+ expect(body.error).toMatch(/no email claim/i);
+ });
+
+ it("returns 409 if a super user already exists", async () => {
+ // Super user already exists
+ dbStaffRows = [{ id: "staff-super", role: "manager", isSuperUser: true }];
+ dbBusinessSettingsRows = [];
+
+ const jwtPayload = { sub: "user-456", email: "bob@example.com", name: "Bob" };
+ const app = makeApp(null, jwtPayload);
+
+ const { status, body } = await postSetup(app, { businessName: "Bob's Grooming" });
+
+ expect(status).toBe(409);
+ expect(body.error).toMatch(/already been completed/i);
+ });
});
\ No newline at end of file
diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts
index d5e764e..b8473e8 100644
--- a/apps/api/src/middleware/rbac.ts
+++ b/apps/api/src/middleware/rbac.ts
@@ -23,7 +23,8 @@ export const resolveStaffMiddleware: MiddlewareHandler = async (
next
) => {
// Better-Auth's own routes handle their own auth — skip staff resolution
- if (c.req.path.startsWith("/api/auth/")) {
+ // OOBE setup routes also handle their own auth — staff record is created during setup
+ if (c.req.path.startsWith("/api/auth/") || c.req.path.startsWith("/api/setup")) {
await next();
return;
}
diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts
index 2b94d20..b10cad7 100644
--- a/apps/api/src/routes/setup.ts
+++ b/apps/api/src/routes/setup.ts
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
-import { eq, getDb, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
+import { and, eq, getDb, isNull, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono();
@@ -44,20 +44,16 @@ const setupSchema = z.object({
businessName: z.string().min(1).max(200),
});
-// POST /api/setup — authenticated, marks current staff as super user and sets business name
+// POST /api/setup — authenticated (Better-Auth JWT), creates staff record if needed and sets business name
+// This endpoint is exempt from resolveStaffMiddleware so that OOBE users (with no staff record yet) can complete setup
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
const db = getDb();
const body = c.req.valid("json");
- const currentStaff = c.get("staff");
+ const jwt = c.get("jwtPayload");
+ const currentStaff = c.get("staff"); // may be undefined during OOBE
// Use a transaction with row-level locking to prevent race conditions
const result = await db.transaction(async (tx) => {
- // Lock the business_settings row for update to prevent concurrent setup
- const [existingSettings] = await tx
- .select({ id: businessSettings.id })
- .from(businessSettings)
- .limit(1);
-
// Lock super user rows to prevent concurrent claims
// FOR UPDATE serializes concurrent claims: second transaction blocks until first commits
const [existingSuperUser] = await tx
@@ -71,6 +67,12 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
}
+ // Lock the business_settings row for update to prevent concurrent setup
+ const [existingSettings] = await tx
+ .select({ id: businessSettings.id })
+ .from(businessSettings)
+ .limit(1);
+
// Update or create business settings with the business name
if (existingSettings) {
await tx
@@ -81,18 +83,66 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
await tx.insert(businessSettings).values({ businessName: body.businessName });
}
- // Mark the current staff as super user
+ // Find or create staff record for the authenticated user
+ let resolvedStaff = currentStaff;
+
+ if (!resolvedStaff) {
+ // Try to find by userId
+ const [byUserId] = await tx
+ .select()
+ .from(staff)
+ .where(eq(staff.userId, jwt.sub));
+ if (byUserId) {
+ resolvedStaff = byUserId;
+ }
+ }
+
+ if (!resolvedStaff && jwt.email) {
+ // Try auto-link by email: staff record exists with matching email but no userId
+ const [byEmail] = await tx
+ .select()
+ .from(staff)
+ .where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
+ if (byEmail) {
+ await tx
+ .update(staff)
+ .set({ userId: jwt.sub })
+ .where(eq(staff.id, byEmail.id));
+ resolvedStaff = { ...byEmail, userId: jwt.sub };
+ }
+ }
+
+ if (!resolvedStaff) {
+ // Brand new user during OOBE — create staff record
+ if (!jwt.email) {
+ return { error: "Cannot complete setup: authenticated user has no email claim", code: 400 };
+ }
+ const [newStaff] = await tx
+ .insert(staff)
+ .values({
+ name: jwt.name || jwt.email,
+ email: jwt.email,
+ userId: jwt.sub,
+ role: "manager",
+ isSuperUser: false, // will be set below
+ })
+ .returning();
+ resolvedStaff = newStaff;
+ }
+
+ // Mark as super user
const [updatedStaff] = await tx
.update(staff)
.set({ isSuperUser: true, updatedAt: new Date() })
- .where(eq(staff.id, currentStaff.id))
+ .where(eq(staff.id, resolvedStaff.id))
.returning();
return { staff: updatedStaff };
});
if ("error" in result) {
- return c.json({ error: result.error }, 409);
+ const status = (result as { code?: number }).code || 409;
+ return c.json({ error: result.error }, status as any);
}
return c.json({ ok: true, staff: result.staff }, 201);
--
2.52.0
From 8348f1c1527aeadc1cf0f155ee672ca9bd63c7ec Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Sun, 5 Apr 2026 20:05:53 +0000
Subject: [PATCH 14/21] fix(api): resolve CI typecheck failures in GRO-485 fix
Fix type errors that caused CI Lint & Typecheck job to fail:
- setup.ts: replace unavailable isNull import with sql template tag
(isNull not exported from @groombook/db; sql IS exported)
- setup.ts: add non-null assertion on newStaff after insert.returning()
- setup.test.ts: add sql mock template tag to @groombook/db mock
- setup.test.ts: fix evaluateCond to handle sql template tag type
- setup.test.ts: add type assertions for body.staff in OOBE regression tests
- setup.test.ts: fix dbStaffRows type casts in mock insert function
All 18 tests pass, full typecheck clean.
Co-Authored-By: Paperclip
---
apps/api/src/__tests__/setup.test.ts | 33 ++++++++++++++++++----------
apps/api/src/routes/setup.ts | 6 ++---
2 files changed, 24 insertions(+), 15 deletions(-)
diff --git a/apps/api/src/__tests__/setup.test.ts b/apps/api/src/__tests__/setup.test.ts
index 095d791..8fc4ffd 100644
--- a/apps/api/src/__tests__/setup.test.ts
+++ b/apps/api/src/__tests__/setup.test.ts
@@ -88,7 +88,7 @@ vi.mock("@groombook/db", () => {
const rows = getRowsForTable(table);
const base = {
where: (cond?: unknown) => {
- const filtered = cond ? rows.filter((r) => evaluateCond(cond, r)) : rows;
+ const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record)) : rows;
return {
limit: () => filtered,
for: () => ({
@@ -126,9 +126,9 @@ vi.mock("@groombook/db", () => {
} else if (vals.email) {
// staff insert
insertedStaff.push(vals);
- dbStaffRows.push(row as MockStaff);
+ dbStaffRows.push(row as unknown as MockStaff);
} else if (vals.businessName) {
- dbBusinessSettingsRows.push(row as { id: string; businessName: string });
+ dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string });
}
return { returning: () => [row] };
},
@@ -159,7 +159,7 @@ vi.mock("@groombook/db", () => {
: table === businessSettings
? dbBusinessSettingsRows
: [];
- const filtered = cond ? rows.filter((r) => evaluateCond(cond, r)) : rows;
+ const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record)) : rows;
return {
limit: () => filtered,
for: () => ({
@@ -214,9 +214,9 @@ vi.mock("@groombook/db", () => {
dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean });
} else if (vals.email) {
insertedStaff.push(vals);
- dbStaffRows.push(row as MockStaff);
+ dbStaffRows.push(row as unknown as MockStaff);
} else if (vals.businessName) {
- dbBusinessSettingsRows.push(row as { id: string; businessName: string });
+ dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string });
}
return { returning: () => [row] };
},
@@ -229,6 +229,11 @@ vi.mock("@groombook/db", () => {
eq: (col: unknown, val: unknown) => ({ __type: "eq", col, val }),
and: (...conds: unknown[]) => ({ __type: "and", conds }),
isNull: (col: unknown) => ({ __type: "isNull", col }),
+ sql: (strings: TemplateStringsArray, ...values: unknown[]) => {
+ // Mock sql template tag — raw SQL can't be evaluated in mock, always passes
+ void strings; void values;
+ return { __type: "sql" };
+ },
encryptSecret: (val: string) => {
encryptCalls.push(val);
return `encrypted:${val}`;
@@ -253,6 +258,10 @@ function evaluateCond(cond: unknown, row: Record): boolean {
const colName = colObj.column as string;
return row[colName] === null || row[colName] === undefined;
}
+ if (c.__type === "sql") {
+ // Raw SQL can't be evaluated in mock — pass through
+ return true;
+ }
return true;
}
@@ -597,9 +606,9 @@ describe("POST /setup — OOBE regression (GRO-485)", () => {
expect(status).toBe(201);
expect(body.ok).toBe(true);
expect(body.staff).toBeDefined();
- expect(body.staff.isSuperUser).toBe(true);
- expect(body.staff.email).toBe("alice@example.com");
- expect(body.staff.role).toBe("manager");
+ expect((body.staff as MockStaff).isSuperUser).toBe(true);
+ expect((body.staff as any).email).toBe("alice@example.com");
+ expect((body.staff as MockStaff).role).toBe("manager");
// New staff record was created
expect(insertedStaff.length).toBe(1);
expect(insertedStaff[0]!.email).toBe("alice@example.com");
@@ -619,13 +628,13 @@ describe("POST /setup — OOBE regression (GRO-485)", () => {
expect(status).toBe(201);
expect(body.ok).toBe(true);
- expect(body.staff.isSuperUser).toBe(true);
+ expect((body.staff as MockStaff).isSuperUser).toBe(true);
// No new staff was created (insertedStaff should be empty since staff was pre-existing)
});
it("auto-links staff by email if record exists with matching email but no userId", async () => {
// Staff record exists with matching email but no userId (legacy record)
- dbStaffRows = [{ id: "staff-legacy", role: "manager", isSuperUser: false, email: "alice@example.com", userId: null }];
+ dbStaffRows = [{ id: "staff-legacy", role: "manager", isSuperUser: false, email: "alice@example.com", userId: null } as unknown as MockStaff];
dbBusinessSettingsRows = [];
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
@@ -636,7 +645,7 @@ describe("POST /setup — OOBE regression (GRO-485)", () => {
expect(status).toBe(201);
expect(body.ok).toBe(true);
- expect(body.staff.isSuperUser).toBe(true);
+ expect((body.staff as MockStaff).isSuperUser).toBe(true);
});
it("returns 400 if JWT has no email claim and no staff record exists", async () => {
diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts
index b10cad7..079636a 100644
--- a/apps/api/src/routes/setup.ts
+++ b/apps/api/src/routes/setup.ts
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
-import { and, eq, getDb, isNull, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
+import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono();
@@ -102,7 +102,7 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
const [byEmail] = await tx
.select()
.from(staff)
- .where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
+ .where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
if (byEmail) {
await tx
.update(staff)
@@ -127,7 +127,7 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
isSuperUser: false, // will be set below
})
.returning();
- resolvedStaff = newStaff;
+ resolvedStaff = newStaff!;
}
// Mark as super user
--
2.52.0
From 5effe07cd64d8b2a3eb7338fd1511504501a83e7 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Sun, 5 Apr 2026 21:16:53 +0000
Subject: [PATCH 15/21] fix(web): redirect authenticated admin users to /admin
Preserve customer portal impersonation flow via ?sessionId= query param.
Co-Authored-By: Paperclip
---
apps/web/src/App.tsx | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index b0ac1c7..8e8c79e 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -262,6 +262,12 @@ export function App() {
return ;
}
+ // Redirect authenticated users to /admin (but preserve impersonation flow via ?sessionId=)
+ const searchParams = new URLSearchParams(location.search);
+ if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
+ return ;
+ }
+
// Don't render portal chrome at /login — DevLoginSelector is shown instead
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
--
2.52.0
From e435fe344eedf36f02b494a40d9b0466c968ff85 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Sun, 5 Apr 2026 23:09:43 +0000
Subject: [PATCH 16/21] fix(web): clear needsSetup state after OOBE completion
to prevent loop
When SetupWizard completes POST /api/setup and navigates to /admin,
App.tsx still has needsSetup=true in React state, causing an immediate
redirect back to /setup. Pass onSetupComplete callback to SetupWizard
which clears the state before navigating, breaking the loop.
Co-Authored-By: Paperclip
---
apps/web/src/App.tsx | 2 +-
apps/web/src/pages/SetupWizard.jsx | 4 +++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 8e8c79e..9fc0d1b 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -231,7 +231,7 @@ export function App() {
if (location.pathname === "/setup") {
return (
-
+ setNeedsSetup(false)} />
);
}
diff --git a/apps/web/src/pages/SetupWizard.jsx b/apps/web/src/pages/SetupWizard.jsx
index aaaf269..666b67c 100644
--- a/apps/web/src/pages/SetupWizard.jsx
+++ b/apps/web/src/pages/SetupWizard.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useBranding } from "../BrandingContext.js";
-export function SetupWizard() {
+export function SetupWizard({ onSetupComplete }) {
const navigate = useNavigate();
const { refresh: refreshBranding } = useBranding();
@@ -160,6 +160,8 @@ export function SetupWizard() {
}
// Refresh branding so the nav bar shows the new business name
refreshBranding();
+ // Clear needsSetup state in App so the redirect to /admin sticks
+ if (onSetupComplete) onSetupComplete();
} catch (e) {
setError("Network error. Please try again.");
setLoading(false);
--
2.52.0
From 0fe10434e10064182c5830a7cc02330545742947 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Tue, 7 Apr 2026 19:23:03 +0000
Subject: [PATCH 17/21] feat(invoices): add indexes, pagination, and client
name enrichment
- Add database migration 0024 with indexes on invoices, invoice_line_items, and invoice_tip_splits
- Update Drizzle schema with index definitions for sync
- Add pagination (limit/offset) to GET /api/invoices with max 200 limit
- Add LEFT JOIN to include clientName in invoice list response
- Return { data: [...], total: N } response shape for pagination
Co-Authored-By: Paperclip
---
apps/api/src/routes/invoices.ts | 40 +-
.../db/migrations/0024_invoice_indexes.sql | 5 +
.../db/migrations/meta/0024_snapshot.json | 2226 +++++++++++++++++
packages/db/migrations/meta/_journal.json | 7 +
packages/db/src/schema.ts | 98 +-
5 files changed, 2330 insertions(+), 46 deletions(-)
create mode 100644 packages/db/migrations/0024_invoice_indexes.sql
create mode 100644 packages/db/migrations/meta/0024_snapshot.json
diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts
index ee2f473..e3f256e 100644
--- a/apps/api/src/routes/invoices.ts
+++ b/apps/api/src/routes/invoices.ts
@@ -10,6 +10,8 @@ import {
invoiceTipSplits,
appointments,
services,
+ clients,
+ sql,
} from "@groombook/db";
export const invoicesRouter = new Hono();
@@ -46,18 +48,46 @@ invoicesRouter.get("/", async (c) => {
const clientId = c.req.query("clientId");
const appointmentId = c.req.query("appointmentId");
const status = c.req.query("status");
+ const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
+ const offset = parseInt(c.req.query("offset") || "0", 10);
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
- const rows =
- conditions.length > 0
- ? await db.select().from(invoices).where(and(...conditions)).orderBy(invoices.createdAt)
- : await db.select().from(invoices).orderBy(invoices.createdAt);
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
- return c.json(rows);
+ const [totalResult] = await db
+ .select({ count: sql`count(*)` })
+ .from(invoices)
+ .where(whereClause);
+
+ const rows = await db
+ .select({
+ id: invoices.id,
+ appointmentId: invoices.appointmentId,
+ clientId: invoices.clientId,
+ clientName: clients.name,
+ subtotalCents: invoices.subtotalCents,
+ taxCents: invoices.taxCents,
+ tipCents: invoices.tipCents,
+ totalCents: invoices.totalCents,
+ status: invoices.status,
+ paymentMethod: invoices.paymentMethod,
+ paidAt: invoices.paidAt,
+ notes: invoices.notes,
+ createdAt: invoices.createdAt,
+ updatedAt: invoices.updatedAt,
+ })
+ .from(invoices)
+ .leftJoin(clients, eq(invoices.clientId, clients.id))
+ .where(whereClause)
+ .orderBy(invoices.createdAt)
+ .limit(limit)
+ .offset(offset);
+
+ return c.json({ data: rows, total: totalResult?.count ?? 0 });
});
// Get single invoice with line items and tip splits
diff --git a/packages/db/migrations/0024_invoice_indexes.sql b/packages/db/migrations/0024_invoice_indexes.sql
new file mode 100644
index 0000000..46ad858
--- /dev/null
+++ b/packages/db/migrations/0024_invoice_indexes.sql
@@ -0,0 +1,5 @@
+CREATE INDEX idx_invoices_client_id ON invoices(client_id);
+CREATE INDEX idx_invoices_status ON invoices(status);
+CREATE INDEX idx_invoices_created_at ON invoices(created_at);
+CREATE INDEX idx_invoice_line_items_invoice_id ON invoice_line_items(invoice_id);
+CREATE INDEX idx_invoice_tip_splits_invoice_id ON invoice_tip_splits(invoice_id);
\ No newline at end of file
diff --git a/packages/db/migrations/meta/0024_snapshot.json b/packages/db/migrations/meta/0024_snapshot.json
new file mode 100644
index 0000000..511c1cd
--- /dev/null
+++ b/packages/db/migrations/meta/0024_snapshot.json
@@ -0,0 +1,2226 @@
+{
+ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "prevId": "b43b79e0-feca-42ed-83cc-9ec67431c3cb",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.appointment_groups": {
+ "name": "appointment_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "appointment_groups_client_id_clients_id_fk": {
+ "name": "appointment_groups_client_id_clients_id_fk",
+ "tableFrom": "appointment_groups",
+ "tableTo": "clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.appointments": {
+ "name": "appointments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pet_id": {
+ "name": "pet_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "service_id": {
+ "name": "service_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "staff_id": {
+ "name": "staff_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bather_staff_id": {
+ "name": "bather_staff_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "appointment_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'scheduled'"
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "series_id": {
+ "name": "series_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "series_index": {
+ "name": "series_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmation_status": {
+ "name": "confirmation_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "confirmed_at": {
+ "name": "confirmed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmation_token": {
+ "name": "confirmation_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "customer_notes": {
+ "name": "customer_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "appointments_client_id_clients_id_fk": {
+ "name": "appointments_client_id_clients_id_fk",
+ "tableFrom": "appointments",
+ "tableTo": "clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "appointments_pet_id_pets_id_fk": {
+ "name": "appointments_pet_id_pets_id_fk",
+ "tableFrom": "appointments",
+ "tableTo": "pets",
+ "columnsFrom": [
+ "pet_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "appointments_service_id_services_id_fk": {
+ "name": "appointments_service_id_services_id_fk",
+ "tableFrom": "appointments",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "appointments_staff_id_staff_id_fk": {
+ "name": "appointments_staff_id_staff_id_fk",
+ "tableFrom": "appointments",
+ "tableTo": "staff",
+ "columnsFrom": [
+ "staff_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "appointments_bather_staff_id_staff_id_fk": {
+ "name": "appointments_bather_staff_id_staff_id_fk",
+ "tableFrom": "appointments",
+ "tableTo": "staff",
+ "columnsFrom": [
+ "bather_staff_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "appointments_series_id_recurring_series_id_fk": {
+ "name": "appointments_series_id_recurring_series_id_fk",
+ "tableFrom": "appointments",
+ "tableTo": "recurring_series",
+ "columnsFrom": [
+ "series_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "appointments_group_id_appointment_groups_id_fk": {
+ "name": "appointments_group_id_appointment_groups_id_fk",
+ "tableFrom": "appointments",
+ "tableTo": "appointment_groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "appointments_confirmation_token_unique": {
+ "name": "appointments_confirmation_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "confirmation_token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_provider_config": {
+ "name": "auth_provider_config",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issuer_url": {
+ "name": "issuer_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "internal_base_url": {
+ "name": "internal_base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_secret": {
+ "name": "client_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'openid profile email'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "auth_provider_config_provider_id_unique": {
+ "name": "auth_provider_config_provider_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "provider_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.business_settings": {
+ "name": "business_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "business_name": {
+ "name": "business_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'GroomBook'"
+ },
+ "logo_base64": {
+ "name": "logo_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logo_mime_type": {
+ "name": "logo_mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#4f8a6f'"
+ },
+ "accent_color": {
+ "name": "accent_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#8b7355'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.clients": {
+ "name": "clients",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "address": {
+ "name": "address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_opt_out": {
+ "name": "email_opt_out",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "status": {
+ "name": "status",
+ "type": "client_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "disabled_at": {
+ "name": "disabled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.grooming_visit_logs": {
+ "name": "grooming_visit_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "pet_id": {
+ "name": "pet_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "appointment_id": {
+ "name": "appointment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "staff_id": {
+ "name": "staff_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cut_style": {
+ "name": "cut_style",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "products_used": {
+ "name": "products_used",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "groomed_at": {
+ "name": "groomed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "grooming_visit_logs_pet_id_pets_id_fk": {
+ "name": "grooming_visit_logs_pet_id_pets_id_fk",
+ "tableFrom": "grooming_visit_logs",
+ "tableTo": "pets",
+ "columnsFrom": [
+ "pet_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "grooming_visit_logs_appointment_id_appointments_id_fk": {
+ "name": "grooming_visit_logs_appointment_id_appointments_id_fk",
+ "tableFrom": "grooming_visit_logs",
+ "tableTo": "appointments",
+ "columnsFrom": [
+ "appointment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "grooming_visit_logs_staff_id_staff_id_fk": {
+ "name": "grooming_visit_logs_staff_id_staff_id_fk",
+ "tableFrom": "grooming_visit_logs",
+ "tableTo": "staff",
+ "columnsFrom": [
+ "staff_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.impersonation_audit_logs": {
+ "name": "impersonation_audit_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_visited": {
+ "name": "page_visited",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "impersonation_audit_logs_session_id_idx": {
+ "name": "impersonation_audit_logs_session_id_idx",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": {
+ "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk",
+ "tableFrom": "impersonation_audit_logs",
+ "tableTo": "impersonation_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.impersonation_sessions": {
+ "name": "impersonation_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "staff_id": {
+ "name": "staff_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "impersonation_session_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "impersonation_sessions_staff_id_status_idx": {
+ "name": "impersonation_sessions_staff_id_status_idx",
+ "columns": [
+ {
+ "expression": "staff_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "impersonation_sessions_client_id_idx": {
+ "name": "impersonation_sessions_client_id_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "impersonation_sessions_staff_id_staff_id_fk": {
+ "name": "impersonation_sessions_staff_id_staff_id_fk",
+ "tableFrom": "impersonation_sessions",
+ "tableTo": "staff",
+ "columnsFrom": [
+ "staff_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "impersonation_sessions_client_id_clients_id_fk": {
+ "name": "impersonation_sessions_client_id_clients_id_fk",
+ "tableFrom": "impersonation_sessions",
+ "tableTo": "clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invoice_line_items": {
+ "name": "invoice_line_items",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "invoice_id": {
+ "name": "invoice_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "unit_price_cents": {
+ "name": "unit_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "total_cents": {
+ "name": "total_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_invoice_line_items_invoice_id": {
+ "name": "idx_invoice_line_items_invoice_id",
+ "columns": [
+ {
+ "expression": "invoice_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invoice_line_items_invoice_id_invoices_id_fk": {
+ "name": "invoice_line_items_invoice_id_invoices_id_fk",
+ "tableFrom": "invoice_line_items",
+ "tableTo": "invoices",
+ "columnsFrom": [
+ "invoice_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invoice_tip_splits": {
+ "name": "invoice_tip_splits",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "invoice_id": {
+ "name": "invoice_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "staff_id": {
+ "name": "staff_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "staff_name": {
+ "name": "staff_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "share_pct": {
+ "name": "share_pct",
+ "type": "numeric(5, 2)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "share_cents": {
+ "name": "share_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_invoice_tip_splits_invoice_id": {
+ "name": "idx_invoice_tip_splits_invoice_id",
+ "columns": [
+ {
+ "expression": "invoice_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invoice_tip_splits_invoice_id_invoices_id_fk": {
+ "name": "invoice_tip_splits_invoice_id_invoices_id_fk",
+ "tableFrom": "invoice_tip_splits",
+ "tableTo": "invoices",
+ "columnsFrom": [
+ "invoice_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invoice_tip_splits_staff_id_staff_id_fk": {
+ "name": "invoice_tip_splits_staff_id_staff_id_fk",
+ "tableFrom": "invoice_tip_splits",
+ "tableTo": "staff",
+ "columnsFrom": [
+ "staff_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invoices": {
+ "name": "invoices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "appointment_id": {
+ "name": "appointment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subtotal_cents": {
+ "name": "subtotal_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tax_cents": {
+ "name": "tax_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "tip_cents": {
+ "name": "tip_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_cents": {
+ "name": "total_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "invoice_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "payment_method": {
+ "name": "payment_method",
+ "type": "payment_method",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paid_at": {
+ "name": "paid_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_invoices_client_id": {
+ "name": "idx_invoices_client_id",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_invoices_status": {
+ "name": "idx_invoices_status",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_invoices_created_at": {
+ "name": "idx_invoices_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invoices_appointment_id_appointments_id_fk": {
+ "name": "invoices_appointment_id_appointments_id_fk",
+ "tableFrom": "invoices",
+ "tableTo": "appointments",
+ "columnsFrom": [
+ "appointment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "invoices_client_id_clients_id_fk": {
+ "name": "invoices_client_id_clients_id_fk",
+ "tableFrom": "invoices",
+ "tableTo": "clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pets": {
+ "name": "pets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "species": {
+ "name": "species",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "breed": {
+ "name": "breed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight_kg": {
+ "name": "weight_kg",
+ "type": "numeric(5, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "date_of_birth": {
+ "name": "date_of_birth",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "health_alerts": {
+ "name": "health_alerts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grooming_notes": {
+ "name": "grooming_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cut_style": {
+ "name": "cut_style",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "shampoo_preference": {
+ "name": "shampoo_preference",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "special_care_notes": {
+ "name": "special_care_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "custom_fields": {
+ "name": "custom_fields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "photo_key": {
+ "name": "photo_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "photo_uploaded_at": {
+ "name": "photo_uploaded_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "pets_client_id_clients_id_fk": {
+ "name": "pets_client_id_clients_id_fk",
+ "tableFrom": "pets",
+ "tableTo": "clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.recurring_series": {
+ "name": "recurring_series",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "frequency_weeks": {
+ "name": "frequency_weeks",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reminder_logs": {
+ "name": "reminder_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "appointment_id": {
+ "name": "appointment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reminder_type": {
+ "name": "reminder_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reminder_logs_appointment_id_appointments_id_fk": {
+ "name": "reminder_logs_appointment_id_appointments_id_fk",
+ "tableFrom": "reminder_logs",
+ "tableTo": "appointments",
+ "columnsFrom": [
+ "appointment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reminder_logs_appointment_id_reminder_type_unique": {
+ "name": "reminder_logs_appointment_id_reminder_type_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "appointment_id",
+ "reminder_type"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.services": {
+ "name": "services",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_price_cents": {
+ "name": "base_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "services_name_unique": {
+ "name": "services_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.staff": {
+ "name": "staff",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_sub": {
+ "name": "oidc_sub",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "staff_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'groomer'"
+ },
+ "is_super_user": {
+ "name": "is_super_user",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "ical_token": {
+ "name": "ical_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "staff_user_id_user_id_fk": {
+ "name": "staff_user_id_user_id_fk",
+ "tableFrom": "staff",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "staff_email_unique": {
+ "name": "staff_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ },
+ "staff_oidc_sub_unique": {
+ "name": "staff_oidc_sub_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "oidc_sub"
+ ]
+ },
+ "staff_ical_token_unique": {
+ "name": "staff_ical_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "ical_token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.waitlist_entries": {
+ "name": "waitlist_entries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pet_id": {
+ "name": "pet_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "service_id": {
+ "name": "service_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "preferred_date": {
+ "name": "preferred_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "preferred_time": {
+ "name": "preferred_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "waitlist_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "notified_at": {
+ "name": "notified_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_waitlist_client_id": {
+ "name": "idx_waitlist_client_id",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_waitlist_preferred_date": {
+ "name": "idx_waitlist_preferred_date",
+ "columns": [
+ {
+ "expression": "preferred_date",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_waitlist_status": {
+ "name": "idx_waitlist_status",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "waitlist_entries_client_id_clients_id_fk": {
+ "name": "waitlist_entries_client_id_clients_id_fk",
+ "tableFrom": "waitlist_entries",
+ "tableTo": "clients",
+ "columnsFrom": [
+ "client_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "waitlist_entries_pet_id_pets_id_fk": {
+ "name": "waitlist_entries_pet_id_pets_id_fk",
+ "tableFrom": "waitlist_entries",
+ "tableTo": "pets",
+ "columnsFrom": [
+ "pet_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "waitlist_entries_service_id_services_id_fk": {
+ "name": "waitlist_entries_service_id_services_id_fk",
+ "tableFrom": "waitlist_entries",
+ "tableTo": "services",
+ "columnsFrom": [
+ "service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.appointment_status": {
+ "name": "appointment_status",
+ "schema": "public",
+ "values": [
+ "scheduled",
+ "confirmed",
+ "in_progress",
+ "completed",
+ "cancelled",
+ "no_show"
+ ]
+ },
+ "public.client_status": {
+ "name": "client_status",
+ "schema": "public",
+ "values": [
+ "active",
+ "disabled"
+ ]
+ },
+ "public.impersonation_session_status": {
+ "name": "impersonation_session_status",
+ "schema": "public",
+ "values": [
+ "active",
+ "ended",
+ "expired"
+ ]
+ },
+ "public.invoice_status": {
+ "name": "invoice_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "pending",
+ "paid",
+ "void"
+ ]
+ },
+ "public.payment_method": {
+ "name": "payment_method",
+ "schema": "public",
+ "values": [
+ "cash",
+ "card",
+ "check",
+ "other"
+ ]
+ },
+ "public.staff_role": {
+ "name": "staff_role",
+ "schema": "public",
+ "values": [
+ "groomer",
+ "receptionist",
+ "manager"
+ ]
+ },
+ "public.waitlist_status": {
+ "name": "waitlist_status",
+ "schema": "public",
+ "values": [
+ "active",
+ "notified",
+ "expired",
+ "cancelled"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json
index fe6e6b8..3a1ec9c 100644
--- a/packages/db/migrations/meta/_journal.json
+++ b/packages/db/migrations/meta/_journal.json
@@ -169,6 +169,13 @@
"when": 1775309667192,
"tag": "0023_auth_provider_config",
"breakpoints": true
+ },
+ {
+ "idx": 24,
+ "version": "7",
+ "when": 1775396067192,
+ "tag": "0024_invoice_indexes",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 38ed4dc..9698b52 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -234,51 +234,67 @@ export const appointments = pgTable("appointments", {
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
-export const invoices = pgTable("invoices", {
- id: uuid("id").primaryKey().defaultRandom(),
- appointmentId: uuid("appointment_id").references(() => appointments.id, {
- onDelete: "restrict",
- }),
- clientId: uuid("client_id")
- .notNull()
- .references(() => clients.id, { onDelete: "restrict" }),
- subtotalCents: integer("subtotal_cents").notNull(),
- taxCents: integer("tax_cents").notNull().default(0),
- tipCents: integer("tip_cents").notNull().default(0),
- totalCents: integer("total_cents").notNull(),
- status: invoiceStatusEnum("status").notNull().default("draft"),
- paymentMethod: paymentMethodEnum("payment_method"),
- paidAt: timestamp("paid_at"),
- notes: text("notes"),
- createdAt: timestamp("created_at").notNull().defaultNow(),
- updatedAt: timestamp("updated_at").notNull().defaultNow(),
-});
+export const invoices = pgTable(
+ "invoices",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ appointmentId: uuid("appointment_id").references(() => appointments.id, {
+ onDelete: "restrict",
+ }),
+ clientId: uuid("client_id")
+ .notNull()
+ .references(() => clients.id, { onDelete: "restrict" }),
+ subtotalCents: integer("subtotal_cents").notNull(),
+ taxCents: integer("tax_cents").notNull().default(0),
+ tipCents: integer("tip_cents").notNull().default(0),
+ totalCents: integer("total_cents").notNull(),
+ status: invoiceStatusEnum("status").notNull().default("draft"),
+ paymentMethod: paymentMethodEnum("payment_method"),
+ paidAt: timestamp("paid_at"),
+ notes: text("notes"),
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
+ },
+ (t) => [
+ index("idx_invoices_client_id").on(t.clientId),
+ index("idx_invoices_status").on(t.status),
+ index("idx_invoices_created_at").on(t.createdAt),
+ ]
+);
-export const invoiceLineItems = pgTable("invoice_line_items", {
- id: uuid("id").primaryKey().defaultRandom(),
- invoiceId: uuid("invoice_id")
- .notNull()
- .references(() => invoices.id, { onDelete: "cascade" }),
- description: text("description").notNull(),
- quantity: integer("quantity").notNull().default(1),
- unitPriceCents: integer("unit_price_cents").notNull(),
- totalCents: integer("total_cents").notNull(),
- createdAt: timestamp("created_at").notNull().defaultNow(),
-});
+export const invoiceLineItems = pgTable(
+ "invoice_line_items",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ invoiceId: uuid("invoice_id")
+ .notNull()
+ .references(() => invoices.id, { onDelete: "cascade" }),
+ description: text("description").notNull(),
+ quantity: integer("quantity").notNull().default(1),
+ unitPriceCents: integer("unit_price_cents").notNull(),
+ totalCents: integer("total_cents").notNull(),
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ },
+ (t) => [index("idx_invoice_line_items_invoice_id").on(t.invoiceId)]
+);
// Per-staff tip allocation calculated when an invoice is paid.
// staff_name is snapshotted at calculation time so reports remain accurate if staff is deleted.
-export const invoiceTipSplits = pgTable("invoice_tip_splits", {
- id: uuid("id").primaryKey().defaultRandom(),
- invoiceId: uuid("invoice_id")
- .notNull()
- .references(() => invoices.id, { onDelete: "cascade" }),
- staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }),
- staffName: text("staff_name").notNull(),
- sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(),
- shareCents: integer("share_cents").notNull(),
- createdAt: timestamp("created_at").notNull().defaultNow(),
-});
+export const invoiceTipSplits = pgTable(
+ "invoice_tip_splits",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ invoiceId: uuid("invoice_id")
+ .notNull()
+ .references(() => invoices.id, { onDelete: "cascade" }),
+ staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }),
+ staffName: text("staff_name").notNull(),
+ sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(),
+ shareCents: integer("share_cents").notNull(),
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ },
+ (t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
+);
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
// reminder_type values: "confirmation", "24h", "2h"
--
2.52.0
From 9be6a8710516d263a731202aff2415b06e4346e5 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Tue, 7 Apr 2026 20:01:56 +0000
Subject: [PATCH 18/21] chore: implement hourly reset CronJob for prod and UAT
- Add ALLOW_RESET env var override to reset.ts safety guard
- Add reset Docker build target to Dockerfile
- Add reset image build step to CI docker job
- Add reset image tag update to CD job dev overlay update
Co-Authored-By: Paperclip
---
.github/workflows/ci.yml | 14 ++++++++++++++
apps/api/Dockerfile | 4 ++++
packages/db/src/reset.ts | 4 ++--
3 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fbafc46..69b8800 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -188,6 +188,19 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
+ - name: Build and push Reset image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: apps/api/Dockerfile
+ target: reset
+ push: true
+ tags: |
+ ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
+ ${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
- name: Build and push Web image
uses: docker/build-push-action@v6
with:
@@ -356,6 +369,7 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
+ yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index e7a0882..1a89f85 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -43,3 +43,7 @@ CMD ["pnpm", "db:migrate"]
# Seed stage — populates the database with test data
FROM builder AS seed
CMD ["pnpm", "db:seed"]
+
+# Reset stage — drops all tables, re-runs migrations, and re-seeds
+FROM builder AS reset
+CMD ["pnpm", "db:reset"]
diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts
index c390f1c..41c3ce8 100644
--- a/packages/db/src/reset.ts
+++ b/packages/db/src/reset.ts
@@ -16,8 +16,8 @@ async function reset() {
process.exit(1);
}
- if (process.env.NODE_ENV === "production") {
- console.error("[FATAL] db:reset must not be run in production.");
+ if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") {
+ console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true.");
process.exit(1);
}
--
2.52.0
From 94764d853273a63795180b9f8d76a60848657509 Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Tue, 7 Apr 2026 20:11:24 +0000
Subject: [PATCH 19/21] Frontend: use paginated invoices API, eliminate
over-fetching
- Replace loadAll() with single GET /api/invoices?limit=50&offset=0
- Remove parallel fetches of clients/appointments/services/staff from list load
- Use clientName from API response instead of client-side enrichment
- Add offset-based pagination controls with Previous/Next buttons
- Lazy-load staff/appointments only when opening invoice detail modal
- Lazy-load clients/appointments/services only when opening create form
- Filter changes only re-fetch invoices, not all endpoints
Co-Authored-By: Paperclip
---
apps/web/src/pages/Invoices.tsx | 147 ++++++++++++++++++++++----------
1 file changed, 103 insertions(+), 44 deletions(-)
diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx
index 5039dd3..8363695 100644
--- a/apps/web/src/pages/Invoices.tsx
+++ b/apps/web/src/pages/Invoices.tsx
@@ -52,6 +52,8 @@ interface CreateFromApptProps {
appointments: Appointment[];
clients: Client[];
services: Service[];
+ loading: boolean;
+ onOpen: () => void;
onCreated: () => void;
onClose: () => void;
}
@@ -60,10 +62,13 @@ function CreateFromAppointmentForm({
appointments,
clients,
services,
+ loading,
+ onOpen,
onCreated,
onClose,
}: CreateFromApptProps) {
const [selectedApptId, setSelectedApptId] = useState("");
+ useEffect(() => { onOpen(); }, [onOpen]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
@@ -99,6 +104,8 @@ function CreateFromAppointmentForm({
}
}
+ if (loading) return Loading…
;
+
return (
Create Invoice from Appointment
@@ -148,16 +155,21 @@ function InvoiceDetailModal({
invoice,
allStaff,
allAppointments,
+ loading,
+ onOpen,
onClose,
onUpdated,
}: {
invoice: Invoice;
allStaff: Staff[];
allAppointments: Appointment[];
+ loading: boolean;
+ onOpen: () => void;
onClose: () => void;
onUpdated: () => void;
}) {
const [saving, setSaving] = useState(false);
+ useEffect(() => { onOpen(); }, [onOpen]);
const [error, setError] = useState(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState(invoice.paymentMethod ?? "cash");
@@ -259,6 +271,8 @@ function InvoiceDetailModal({
}
}
+ if (loading) return Loading…
;
+
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
const newTotal = invoice.subtotalCents + invoice.taxCents + tipCentsCalc;
@@ -460,64 +474,77 @@ function SummaryRow({ label, value, bold }: { label: string; value: string; bold
// ─── Main Page ────────────────────────────────────────────────────────────────
+interface PaginatedResponse {
+ data: T[];
+ total: number;
+}
+
export function InvoicesPage() {
const [invoiceList, setInvoiceList] = useState([]);
- const [clients, setClients] = useState([]);
- const [appointments, setAppointments] = useState([]);
- const [services, setServices] = useState([]);
- const [allStaff, setAllStaff] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const [selectedInvoice, setSelectedInvoice] = useState(null);
const [statusFilter, setStatusFilter] = useState("");
+ const [total, setTotal] = useState(0);
+ const [offset, setOffset] = useState(0);
+ const [createData, setCreateData] = useState<{ clients: Client[]; appointments: Appointment[]; services: Service[] } | null>(null);
+ const [createLoading, setCreateLoading] = useState(false);
+ const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
- async function loadAll() {
- const [invRes, clientRes, apptRes, svcRes, staffRes] = await Promise.all([
- fetch("/api/invoices" + (statusFilter ? `?status=${statusFilter}` : "")),
- fetch("/api/clients"),
- fetch("/api/appointments"),
- fetch("/api/services?includeInactive=true"),
- fetch("/api/staff"),
- ]);
+ const LIMIT = 50;
- if (!invRes.ok || !clientRes.ok || !apptRes.ok || !svcRes.ok || !staffRes.ok) {
- throw new Error("Failed to load data");
- }
-
- const [invData, clientData, apptData, svcData, staffData] = await Promise.all([
- invRes.json() as Promise,
- clientRes.json() as Promise,
- apptRes.json() as Promise,
- svcRes.json() as Promise,
- staffRes.json() as Promise,
- ]);
-
- const clientMap = new Map(clientData.map((c) => [c.id, c.name]));
- const enriched: InvoiceWithClient[] = invData.map((inv) => ({
- ...inv,
- clientName: clientMap.get(inv.clientId),
- }));
-
- setInvoiceList(enriched);
- setClients(clientData);
- setAppointments(apptData);
- setServices(svcData);
- setAllStaff(staffData);
+ async function loadInvoices(newOffset: number) {
+ const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
+ if (statusFilter) params.set("status", statusFilter);
+ const res = await fetch(`/api/invoices?${params}`);
+ if (!res.ok) throw new Error("Failed to load invoices");
+ const page = (await res.json()) as PaginatedResponse;
+ setInvoiceList(page.data);
+ setTotal(page.total);
+ setOffset(newOffset);
}
useEffect(() => {
setLoading(true);
- loadAll()
+ loadInvoices(0)
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
.finally(() => setLoading(false));
}, [statusFilter]);
+ function loadCreateData() {
+ if (createData) return Promise.resolve();
+ setCreateLoading(true);
+ return Promise.all([
+ fetch("/api/clients"),
+ fetch("/api/appointments"),
+ fetch("/api/services?includeInactive=true"),
+ ])
+ .then(([c, a, s]) => Promise.all([c.json(), a.json(), s.json()]))
+ .then(([clients, appointments, services]) => {
+ setCreateData({ clients, appointments, services });
+ })
+ .finally(() => setCreateLoading(false));
+ }
+
+ function loadDetailData() {
+ if (detailData) return Promise.resolve();
+ setDetailLoading(true);
+ return Promise.all([fetch("/api/staff"), fetch("/api/appointments")])
+ .then(([s, a]) => Promise.all([s.json(), a.json()]))
+ .then(([staff, appointments]) => {
+ setDetailData({ staff, appointments });
+ })
+ .finally(() => setDetailLoading(false));
+ }
+
async function openInvoiceDetail(inv: InvoiceWithClient) {
const res = await fetch(`/api/invoices/${inv.id}`);
if (!res.ok) return;
const data = (await res.json()) as Invoice;
setSelectedInvoice(data);
+ loadDetailData();
}
if (loading) return Loading…
;
@@ -551,6 +578,7 @@ export function InvoicesPage() {
No invoices yet. Create one from a completed appointment.
) : (
+ <>
@@ -584,16 +612,41 @@ export function InvoicesPage() {
+
+
+ {offset + 1}–{Math.min(offset + LIMIT, total)} of {total} invoices
+
+
+
+
+
+
+ >
)}
{showCreate && (
loadCreateData()}
onCreated={() => {
setShowCreate(false);
- loadAll().catch(() => {});
+ setCreateData(null);
+ loadInvoices(0).catch(() => {});
}}
onClose={() => setShowCreate(false)}
/>
@@ -602,12 +655,18 @@ export function InvoicesPage() {
{selectedInvoice && (
setSelectedInvoice(null)}
+ allStaff={detailData?.staff ?? []}
+ allAppointments={detailData?.appointments ?? []}
+ loading={detailLoading}
+ onOpen={() => loadDetailData()}
+ onClose={() => {
+ setSelectedInvoice(null);
+ setDetailData(null);
+ }}
onUpdated={() => {
setSelectedInvoice(null);
- loadAll().catch(() => {});
+ setDetailData(null);
+ loadInvoices(offset).catch(() => {});
}}
/>
)}
--
2.52.0
From a84d5e7b9a8860334b5f4cf81df8fc218d7d4e4a Mon Sep 17 00:00:00 2001
From: Paperclip
Date: Wed, 8 Apr 2026 02:56:31 +0000
Subject: [PATCH 20/21] fix: set isSuperUser=false for Jordan Lee in full seed
Jordan Lee was being created with isSuperUser=true in the full seed path,
causing GET /api/setup/status to return needsSetup=false after UAT reset.
---
packages/db/src/seed.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts
index 0a165a8..706b69e 100644
--- a/packages/db/src/seed.ts
+++ b/packages/db/src/seed.ts
@@ -403,7 +403,7 @@ async function seed() {
// ── Staff ──
// Deterministic staff IDs so they can be referenced in scripts/tests
const managerStaff = [
- { id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: true },
+ { id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: false },
];
const receptionistStaff = [
--
2.52.0
From 948806bf09fa1a50a44d2675dadbdebaa96203fe Mon Sep 17 00:00:00 2001
From: Flea Flicker
Date: Wed, 8 Apr 2026 03:22:43 +0000
Subject: [PATCH 21/21] fix(db): set isSuperUser=false for Jordan Lee in full
seed path
Gro-511: Jordan Lee (manager account) was created with isSuperUser=true
in the full seed path, causing GET /api/setup/status to return
needsSetup=false and blocking the OOBE flow after UAT reset.
Co-Authored-By: Paperclip
---
packages/db/src/seed.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts
index 0a165a8..706b69e 100644
--- a/packages/db/src/seed.ts
+++ b/packages/db/src/seed.ts
@@ -403,7 +403,7 @@ async function seed() {
// ── Staff ──
// Deterministic staff IDs so they can be referenced in scripts/tests
const managerStaff = [
- { id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: true },
+ { id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: false },
];
const receptionistStaff = [
--
2.52.0